# 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?](#why-msign) 2. [Authorization header](#authorization-header) 3. [Canonical message](#canonical-message) 4. [Replay protection](#replay-protection) 5. [Key registration (challenge-response)](#key-registration-challenge-response) 6. [Algorithm](#algorithm) 7. [Security properties](#security-properties) 8. [What MSign does not protect](#what-msign-does-not-protect) 9. [Client implementation guide](#client-implementation-guide) 10. [Relation to HTTP Signatures RFC 9421](#relation-to-http-signatures-rfc-9421) 11. [Quantum migration path](#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="" ts= sig="" ``` | 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: ```python 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": "", "algorithm": "ed25519" } ``` Response: ```json { "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": "", "public_key_b64": "", "signature_b64": "", "handle": "gabriel", // required only on first registration "display_name": "Gabriel", // optional "label": "macbook-pro" // optional, human label for this key } ``` On success (`200`): ```json { "handle": "gabriel", "identity_id": "", "is_new_identity": true, "auth_method": "ed25519", "key": { "key_id": "", "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](#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](#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 ```python 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: ```python 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: ```python 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)](https://www.rfc-editor.org/rfc/rfc9421). 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.