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
- Why MSign?
- Authorization header
- Canonical message
- Replay protection
- Key registration (challenge-response)
- Algorithm
- Security properties
- What MSign does not protect
- Client implementation guide
- Relation to HTTP Signatures RFC 9421
- 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=2signs/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
tsfield 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
401with 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:
KeyAlgorithm.ML_DSA_65is defined inmusehub/crypto/keys.py_verify_ml_dsa_65stub exists, ready for implementationIMPLEMENTED_ALGORITHMSis a frozenset — addML_DSA_65when the library is readyDEFAULT_ALGORITHMcan be switched toML_DSA_65
Migration steps when ML-DSA-65 support is available in PyCA cryptography or liboqs-python:
- Add the dependency to
requirements.txt - Implement
_verify_ml_dsa_65inmusehub/crypto/keys.py - Add
KeyAlgorithm.ML_DSA_65toIMPLEMENTED_ALGORITHMS - Update the muse CLI key-generation command to produce ML-DSA-65 keys by default
- 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.