gabriel / musehub public
msign.md markdown
357 lines 12.3 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago

MSign — MuseHub Per-Request Signing

Last updated: 2026-04-04

MSign is the authentication scheme used by all MuseHub API requests. It is a per-request Ed25519 signature scheme. There are no passwords, no bearer tokens, and no server secrets. The key pair is the credential.


Contents

  1. Why MSign?
  2. Authorization header
  3. Canonical message
  4. Replay protection
  5. Key registration (challenge-response)
  6. Algorithm
  7. Security properties
  8. What MSign does not protect
  9. Client implementation guide
  10. Relation to HTTP Signatures RFC 9421
  11. Quantum migration path

Why MSign?

Traditional auth schemes have failure modes that don't apply here:

Scheme Failure mode Why it's eliminated
Password + session cookie Password brute-force, cookie theft No passwords exist
Bearer tokens (JWT/PASETO) Token theft, secret rotation, expiry management No tokens exist
OAuth flows Redirect hijacking, client_secret leakage No OAuth; no client secrets
mTLS Certificate infrastructure, CA trust Too heavy for a CLI tool

MSign signs every request with the client's Ed25519 private key. The server holds only the public key. A stolen request doesn't grant access to anything — it is bound to a specific method, path, timestamp, and body.


Authorization header

Every authenticated request must carry:

Authorization: MSign handle="<handle>" ts=<unix_seconds> sig="<b64url>"
Field Description
handle The identity's handle (e.g. gabriel)
ts Unix timestamp (seconds) at time of signing
sig URL-safe base64 (no padding) of the 64-byte Ed25519 signature

Example:

Authorization: MSign handle="gabriel" ts=1743800000 sig="abc123..."

On failure the server returns 401 with:

WWW-Authenticate: MSign realm="musehub"

Canonical message

The client signs the following byte string (UTF-8, newline-separated):

{METHOD}\n
{PATH_WITH_QUERY}\n
{UNIX_TS}\n
{SHA256_HEX(body_bytes)}

Rules:

  • METHOD — uppercase HTTP verb: GET, POST, PUT, PATCH, DELETE
  • PATH_WITH_QUERY — the full path including query string if present. GET /repos?page=2 signs /repos?page=2, not /repos. This is critical — omitting the query string allows a valid signed request to be replayed with different query parameters.
  • UNIX_TS — the same integer that appears in the ts field of the header
  • SHA256_HEX(body_bytes) — lowercase hex SHA-256 of the raw request body bytes. For requests with no body (GET, DELETE, etc.) use SHA256_HEX("")e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Python reference:

import hashlib

def build_canonical_message(method: str, path_with_query: str, ts: int, body: bytes) -> bytes:
    body_hash = hashlib.sha256(body).hexdigest()
    return f"{method}\n{path_with_query}\n{ts}\n{body_hash}".encode()

Replay protection

The server rejects any request where:

abs(server_time_unix - ts) > REPLAY_WINDOW_SECONDS

REPLAY_WINDOW_SECONDS is 30 (see musehub/auth/request_signing.py).

This means:

  • A replayed request is invalid after 30 seconds.
  • Clocks must agree within 30 seconds. NTP-synchronized systems are fine. Heavily out-of-sync clocks will get 401 with a skew diagnostic: "Request timestamp too far from server time (skew=Xs, max=30s)."

The 30-second window is tight enough to limit replay exposure and wide enough to tolerate normal clock drift on standard internet-connected hosts.


Key registration (challenge-response)

Before making authenticated requests, the client must register its public key.

Step 1 — Request a challenge

POST /api/auth/challenge
Content-Type: application/json

{
  "fingerprint": "<lowercase hex SHA-256 of raw public key bytes>",
  "algorithm": "ed25519"
}

Response:

{
  "challenge_token": "<64 hex chars = 32 random bytes>",
  "is_new_key": true,
  "expires_in": 300,
  "algorithm": "ed25519"
}

is_new_key: true means the fingerprint is not yet registered. A handle is required in the next step. is_new_key: false means the key is already registered (re-authentication).

Step 2 — Sign and verify

Sign bytes.fromhex(challenge_token) with your Ed25519 private key. Submit:

POST /api/auth/verify
Content-Type: application/json

{
  "challenge_token": "<same token>",
  "public_key_b64": "<URL-safe base64 of raw 32-byte public key>",
  "signature_b64": "<URL-safe base64 of 64-byte Ed25519 signature>",
  "handle": "gabriel",          // required only on first registration
  "display_name": "Gabriel",    // optional
  "label": "macbook-pro"        // optional, human label for this key
}

On success (200):

{
  "handle": "gabriel",
  "identity_id": "<uuid>",
  "is_new_identity": true,
  "auth_method": "ed25519",
  "key": {
    "key_id": "<uuid>",
    "algorithm": "ed25519",
    "fingerprint": "<64 hex chars>",
    "label": "macbook-pro",
    "created_at": "...",
    "last_used_at": "..."
  }
}

No token is returned. Registration is complete. Sign subsequent requests with your private key.

Challenge security properties

  • Challenges are single-use — consumed immediately on first verify attempt.
  • Challenges expire after 300 seconds (CHALLENGE_TTL_SECONDS).
  • The challenge nonce is 256 bits from the OS CSPRNG — not guessable.
  • A constant-time fingerprint comparison prevents key substitution attacks (submitting a different key for a fingerprint you don't control).

Algorithm

Currently implemented: Ed25519 only ("ed25519").

The algorithm field in ChallengeRequest is validated against IMPLEMENTED_ALGORITHMS (a frozenset in musehub/crypto/keys.py). Any other value is rejected at the challenge step with 422. This includes ml-dsa-65, which is defined but not yet implemented.

For per-request verification, the algorithm is never read from the Authorization header. The server looks up the algorithm from the stored key record. The client cannot influence which algorithm is used for verification by crafting the header.

See Quantum migration path for the ML-DSA-65 roadmap.


Security properties

Property How MSign provides it
Authentication Ed25519 signature over canonical message — only the private key holder can produce a valid sig
Request integrity SHA-256 of the body is part of the signed message — tampering invalidates the signature
Replay prevention 30-second timestamp window — a replayed request expires quickly
Path binding Full path including query string is signed — cannot replay to a different endpoint or with different params
Method binding HTTP verb is part of the signed message — a GET signature cannot be replayed as a POST
No token theft surface No tokens exist — a MitM on one request cannot escalate to persistent access
No server secret The server holds only public keys — a server database breach does not yield usable credentials
Brute-force resistance Ed25519 private keys are 256-bit entropy — not guessable or brute-forceable
Stuffing resistance Per-IP failure limiter with exponential backoff (musehub/auth/failure_limiter.py): 5 failures → 30s, 10 → 5min, 20 → 15min cooldown

What MSign does not protect

Transport confidentiality. MSign does not encrypt anything. Use HTTPS (TLS 1.2+) for all production traffic. The signature proves authenticity and integrity but the request body and headers are visible to any network observer on the path.

Key compromise. If the private key file is stolen, the attacker can sign requests indefinitely. Revoke compromised keys immediately via DELETE /api/auth/keys/{handle}/{key_id}. There is no automatic expiry — key revocation is a manual operation.

Quantum adversaries. Ed25519 is broken by Shor's algorithm on a sufficiently powerful quantum computer. See Quantum migration path.

Multi-worker replay within the window. The replay window is enforced in-process. In a multi-worker deployment (multiple uvicorn workers or processes), a request seen by worker A could be replayed to worker B within the 30-second window. The per-IP failure limiter also lives in-process. For strict replay prevention across workers, the timestamp check needs to move to a shared cache (Redis). For single-worker deployments (UVICORN_WORKERS=1) this is not a concern.


Client implementation guide

import base64
import hashlib
import time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

def b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

def msign_header(
    private_key: Ed25519PrivateKey,
    handle: str,
    method: str,
    path_with_query: str,
    body: bytes = b"",
) -> str:
    ts = int(time.time())
    body_hash = hashlib.sha256(body).hexdigest()
    canonical = f"{method}\n{path_with_query}\n{ts}\n{body_hash}".encode()
    sig = private_key.sign(canonical)
    return f'MSign handle="{handle}" ts={ts} sig="{b64url(sig)}"'

# Usage
headers = {
    "Authorization": msign_header(private_key, "gabriel", "GET", "/api/repos"),
    "Content-Type": "application/json",
}

For requests with a body:

import json

body = json.dumps({"name": "my-repo"}).encode()
headers = {
    "Authorization": msign_header(private_key, "gabriel", "POST", "/api/repos", body),
    "Content-Type": "application/json",
    "Content-Length": str(len(body)),
}

For requests with query parameters, include them in the path argument:

msign_header(private_key, "gabriel", "GET", "/api/repos?page=2&per_page=50")

Relation to HTTP Signatures RFC 9421

MSign is structurally similar to HTTP Signatures (RFC 9421). Both schemes:

  • Sign a derived "canonical message" constructed from request components
  • Use an asymmetric key pair (no shared secret)
  • Provide per-request integrity and authentication

The key differences:

MSign RFC 9421
Header format Single Authorization: MSign ... header Signature + Signature-Input headers
Covered components Method, path+query, timestamp, body hash Configurable field list
Algorithm negotiation Server-side only (from stored key) Client-specified in Signature-Input
Spec status Internal, first-party only IETF standard, broad ecosystem support
Client library availability muse CLI (first-party) Multiple independent implementations

For a system where the muse CLI is the only client, the divergence from RFC 9421 is acceptable — both ends are controlled. If third-party clients need to implement MSign (external tooling, community integrations), RFC 9421 interoperability would be a significant advantage. The migration path is straightforward since the semantic intent is identical.


Quantum migration path

Ed25519 is not quantum-safe. The migration path to ML-DSA-65 (FIPS 204, formerly CRYSTALS-Dilithium-3) is already prepared in the codebase:

  1. KeyAlgorithm.ML_DSA_65 is defined in musehub/crypto/keys.py
  2. _verify_ml_dsa_65 stub exists, ready for implementation
  3. IMPLEMENTED_ALGORITHMS is a frozenset — add ML_DSA_65 when the library is ready
  4. DEFAULT_ALGORITHM can be switched to ML_DSA_65

Migration steps when ML-DSA-65 support is available in PyCA cryptography or liboqs-python:

  1. Add the dependency to requirements.txt
  2. Implement _verify_ml_dsa_65 in musehub/crypto/keys.py
  3. Add KeyAlgorithm.ML_DSA_65 to IMPLEMENTED_ALGORITHMS
  4. Update the muse CLI key-generation command to produce ML-DSA-65 keys by default
  5. Existing Ed25519 keys continue to work — users re-register with a new ML-DSA-65 key at their own pace and revoke their Ed25519 key when ready

The fingerprint scheme (SHA-256 of raw key bytes) is quantum-safe at the 128-bit security level, which meets NIST's current threshold. No changes needed to the fingerprint logic.

File History 14 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 8 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 11 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 14 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 29 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 33 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 35 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 35 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago