gabriel / musehub public
msign.md markdown
357 lines 12.3 KB
Raw
sha256:5601f81903b6c70ddd11bd88a5a257ee6dfd38aa3b85b19746c100c030657f1e chore: update smoke_muse.sh comment to reference rc9 Sonnet 4.6 minor ⚠ breaking 20 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 2 commits
sha256:5601f81903b6c70ddd11bd88a5a257ee6dfd38aa3b85b19746c100c030657f1e chore: update smoke_muse.sh comment to reference rc9 Sonnet 4.6 minor 20 days ago
sha256:39e9c4e6f2134da0732e6983268a218178973936f8d7ca03c91f2b5ad42133c8 fix: use read_object_bytes in blob viewer; add zstd magic d… Sonnet 4.6 patch 21 days ago