@claudewerk/mutual-key-auth
v0.2.0
Published
Mutual, independently-revocable bearer-key authentication with challenge-response direction proofs and downgrade binding.
Downloads
4,889
Maintainers
Readme
mutual-key-auth
Mutual, independently-revocable bearer-key authentication with
challenge-response direction proofs and downgrade binding. Standalone and
dependency-free -- node:crypto / Bun WebCrypto only, pino an optional peer.
It knows nothing about WebSockets, queues, or any larger system. It mints and
verifies keys and produces and checks direction proofs. A transport plugs it in
as an AuthProvider (duck-typed -- no import either way).
bun add @claudewerk/mutual-key-auth # or: npm i @claudewerk/mutual-key-auth
bun test # 58 tests, all attack cases includedWhy it exists (threat model)
Two peers on a connection each want to prove, continuously and independently, that the other still holds a valid credential -- and that nobody downgraded the negotiated parameters mid-handshake. Each mechanism defends a specific attack:
| Mechanism | Defends against |
|---|---|
| Keys stored hashed (SHA-256), raw shown once | DB/record theft doesn't reveal a usable bearer key (no preimage) |
| Constant-time hash + proof comparison | Timing side-channels that leak the secret byte-by-byte |
| Expiry on every key | A leaked key is useful only until it expires |
| Independent revocation (per key, either side) | Compromise of one peer's key doesn't force a full re-key of the other |
| Key rotation (two active keys overlap) | Rolling keys without a hard cutover / dropped connections |
| CSPRNG single-use nonce | Replay of a captured proof (nonce is one-shot; a burned nonce can't be reused) |
| Nonce time-boxing | Indefinite challenge validity; narrows the replay window |
| Direction proof = HMAC(transcript, keySecret) | Proving possession without putting the key on the wire; binds the proof to one prover role |
| Role-absolute transcript (initiatorHello+responderHello fixed order, proverRole enum) | Both ends compute byte-identical transcripts with zero perspective juggling -- no my/peer swap footgun |
| Transcript binds hashes of both hello frames + the negotiated cross-layer tuple {version, features, and every negotiation slot's chosen name} | Downgrade attacks across every layer -- tampering with any negotiated parameter (transport or app-level: encoding, feature set, auth method) changes the transcript, so the proof no longer verifies (SSH exchange-hash / TLS-Finished property) |
| transcriptHash bound (hash over both raw advertised hello frames) | MITM edits to the advertised capabilities before negotiation -- authenticates the full transcript, not just the negotiated outcome |
| connId bound | Cross-connection replay of a captured proof onto a different connection |
| Length-framed, fixed-arity transcript | Concatenation-collision attacks + add/remove-field tampering across field boundaries |
| Domain-separation label + format tag in the transcript | Reuse of the proof HMAC as a general signing oracle |
| Scope -> verb matrix check | Over-broad authorization; a key does only what its scopes grant |
| Redacting logger | Accidentally logging a raw key, hash, or proof |
| Fail-closed handshake on malformed input (non-string / oversized nonce or proof -> malformed-auth-message; the crypto layer's own rejections are caught) | A remote crash: a throw inside the carrier's auth exchange terminates the process, so a peer that could make prove() throw with a short nonce could kill a listener before authenticating |
Known tradeoff (read this)
The proof is a symmetric HMAC keyed by the key's at-rest secret
(SHA-256(rawKey) == record.hash). The prover derives it from its raw key; the
verifier already holds it in the record; the raw key never travels. The
tradeoff: an attacker who steals the stored record can forge direction proofs
(though not pass raw-key verification -- that still needs a preimage). This is
inherent to symmetric HMAC with hashed-at-rest storage. Mitigation: short expiry
- fast independent revocation, both built in. If you need proofs to survive
at-rest compromise, use asymmetric signatures instead -- see
DESIGN-FEEDBACK.md.
Usage
Mint, store hashed, verify
import { createAuthProvider } from "@claudewerk/mutual-key-auth";
const auth = createAuthProvider({ logger: "silent", prefix: "brg" });
// Listener mints a key for a peer. Show `key` ONCE; persist only `record`.
const { key, record } = auth.mintKey({
peerId: "dialer-1",
scopes: ["read", "write"],
expiresAt: Date.now() + 86_400_000, // 24h
});
// key -> "brg_Xf9...b64url..." (give to the peer, never stored/logged)
// record -> { id, peerId, scopes, expiresAt, status:"active", hash, ... }
// Later, verify a presented key against the stored record:
const r = auth.verifyKey(presentedKey, record);
// { ok: true, peerId, keyId } | { ok: false, reason: "expired"|"revoked"|"key_mismatch"|... }Mutual handshake (asAuthProvider -- drop-in for a reliable-connection lib)
asAuthProvider conforms exactly to the AuthProvider contract a reliable-
WebSocket library (bun-reliable-ws-shaped) drives -- no
adapter, no glue. It returns { method, start(ctx), step(ctx, incoming) } and
runs the three-message mutual exchange over the proof primitives. Wire it as a
factory (auth: () => asAuthProvider({...})) -- one handshake per connection.
import { asAuthProvider } from "@claudewerk/mutual-key-auth";
// Mutual keys: each side holds its OWN raw key; each holds the OTHER's record.
const dialer = asAuthProvider({ myKey: myRawKey, peerRecord: peerRecordForListener });
const listener = asAuthProvider({ myKey: myRawKey2, peerRecord: peerRecordForDialer });The connection library hands each start/step call a fresh AuthContext
(it rebuilds it every time), so all handshake state lives in the provider
closure, never on ctx. You derive nothing -- the library supplies it:
AuthContext = { role: "dialer"|"listener", negotiated, connId,
localCapabilities, remoteCapabilities, transcriptHash, localPeerId }
AuthStep = { done:false, send } | { done:true, ok:true, send? }
| { done:true, ok:false, reason, send? }// The library drives it -- shown here manually with a fresh ctx per call:
const d0 = dialer.start(dialerCtx()); // -> { done:false, send:{type:"challenge"} }
listener.start(listenerCtx()); // -> null (listener waits)
const l1 = listener.step(listenerCtx(), d0.send); // -> challenge-response
const d1 = dialer.step(dialerCtx(), l1.send); // -> { done:true, ok:true, send:response }
const l2 = listener.step(listenerCtx(), d1.send); // -> { done:true, ok:true }
// d1.ok && l2.ok === mutual success. A tampered negotiated/connId/transcriptHash/
// capability aborts it with { ok:false, reason:"bad-proof" }.The proof binds, per role: H(initiatorHello) + H(responderHello) (from
local/remoteCapabilities), the negotiated tuple (version, features, and
every negotiation slot's chosen name),
connId, and transcriptHash -- so any cross-layer downgrade or MITM
capability edit breaks the handshake.
Direction proof primitive (lower-level, downgrade-bound)
The handshake is built on this. The verifier issues a nonce; the prover returns an HMAC over the shared role-absolute transcript. Both ends pass the SAME parts -- no perspective juggling.
import { PROVER_ROLE } from "@claudewerk/mutual-key-auth";
const parts = {
proverRole: PROVER_ROLE.INITIATOR, // enum: who is proving (not a direction string)
initiatorHello, // FIXED order: initiator first,
responderHello, // then responder -- same on both ends
tuple: { version: 1, features: ["ordered"], auth: "mutual-key", e2eSeal: "none", e2eSign: "none", codec: "json" },
connId, // binds connection identity (optional)
transcriptHash, // binds the full advertised-capability transcript (optional)
};
const nonce = auth.newNonce(); // verifier issues
const proof = auth.makeProof(proverRawKey, { nonce, transcriptParts: parts }); // prover (key never leaves)
const ok = auth.verifyProof(proof, { // verifier: consumes nonce, checks
nonce, transcriptParts: parts, keyRecordForProver: proverRecord // revocation/expiry, then HMAC
});
// ok === true only if the key is valid AND no bound parameter (any layer) was tamperedOne nonce = one attempt.
verifyProofconsumes the nonce before the crypto check, so a failed proof burns it -- retry the challenge with a freshnewNonce(), never resend the same nonce. (Flagged inDESIGN-FEEDBACK.md #3.)
Scope -> verb check (pure)
import { scopeAllows } from "@claudewerk/mutual-key-auth";
const matrix = { read: ["get", "list"], write: ["put", "delete"], admin: ["*"] };
scopeAllows(["read"], "get", matrix); // true
scopeAllows(["read"], "delete", matrix); // false
scopeAllows(["admin"], "anything", matrix); // true ("*" grants all)Revocation & rotation
const revoked = auth.revokeKey(record); // returns a revoked copy; verify* now rejects it
// Rotation: mint a second key before expiring the first -- both verify while active.API
// The handshake AuthProvider a reliable-connection lib drives (challenge/response):
asAuthProvider({ crypto?, myKey, peerRecord, method?, domainSepLabel?, now? })
-> { method, start(ctx) -> AuthStep|null, step(ctx, incoming) -> AuthStep }
ctx = { role: "dialer"|"listener", negotiated, connId,
localCapabilities, remoteCapabilities, transcriptHash } // READ-ONLY, rebuilt each call
AuthStep = { done:false, send } | { done:true, ok:true, send? }
| { done:true, ok:false, reason, send? }
// State lives in the closure, NOT on ctx -- create one provider per connection.
// The stateful key/nonce/proof provider (crypto operations, not a state machine):
createAuthProvider({ logger?, prefix?, nonceTtlMs?, nonceBytes?, now? }) -> {
mintKey({ peerId, scopes?, expiresAt?, prefix?, rateLimit?, allowlist? }) -> { key, record }
// recordForKey(key, { peerId, ... }) -> record (top-level export; for a key you already hold)
verifyKey(presented, record) -> { ok, reason?, peerId?, keyId? }
newNonce() -> string
makeProof(myIssuedRawKey, { nonce, transcriptParts }) -> string
verifyProof(proof, { nonce, transcriptParts, keyRecordForProver }) -> boolean
revokeKey(record) -> record
scopeAllows(scopes, verb, matrix) -> boolean
}
// transcriptParts (role-absolute; tuple is any canonicalizable object):
{ proverRole: PROVER_ROLE.INITIATOR|RESPONDER, initiatorHello, responderHello,
tuple, // e.g. WS Negotiated { version, features, auth, e2eSeal, e2eSign, codec }
connId?, transcriptHash?, domainSepLabel? }Pure functions are also exported directly (asAuthProvider, mintKey,
recordForKey, deriveKey, verifyKey, makeProof, verifyProof, buildTranscript,
PROVER_ROLE, newNonce, NonceStore, scopeAllows, createLogger,
redact) for use without the stateful provider.
Keys provisioned out of band
mintKey generates a key and hands back its record together. When the key
already exists -- it came from a config file, a secret store, or a fixture two
processes share -- derive the record from it instead:
import { recordForKey } from "@claudewerk/mutual-key-auth";
const peerRecord = recordForKey(process.env.PEER_KEY, { peerId: "rust-client" });mintKey is generate + recordForKey, so the two produce identical records for
the same key. That is what lets a TS host and a separate client process agree on
each other's records without either shipping the other a record: each side reads
the peer's raw key once at startup, derives the record, and keeps only the hash.
Derived (delegated) keys
One long-lived parent key can stand behind many short-lived peers with no minting service and no key storage. The parent's holder derives a child per subject and hands it to the process it spawns; the verifying side, which also holds the parent, re-derives the same child from the subject the peer claims:
import { deriveKey, recordForKey } from "@claudewerk/mutual-key-auth";
// spawner: one child key per conversation, passed in the child's env
const childKey = deriveKey(parentKey, conversationId);
// verifier: pick the record by the peer id claimed in the hello
const peerRecordFor = (peerId) =>
peerId.startsWith("host/") ? recordForKey(deriveKey(parentKey, peerId.slice(5)), { peerId }) : undefined;deriveKey is HMAC-SHA256(parent, "mutual-key-auth/derive/v1" || 0x00 || subject)
rendered as <prefix>_<base64url>. A child reveals nothing about its parent or
its siblings; revoking (rotating) the parent revokes every child. Whoever holds
the parent can derive every child, so the parent is the secret of the family.
KeyRecord
{ id, peerId, scopes[], rateLimit?, allowlist?, expiresAt, status: "active"|"revoked",
hash, prefix, createdAt }id is a short, safe-to-log handle (first 12 hex of the hash). hash is the
full SHA-256(rawKey) -- treat it as sensitive at rest (see tradeoff above). The
raw key appears only in mintKey's return and is never persisted or logged.
Logging
Injectable, structured, and redacting. Pass logger:
"silent" (default), "console", "pino" (lazy optional peer -- only required
if you ask for it), or any duck-typed { info, warn, error, debug } object. Every
backend is wrapped so key/secret/proof/token are dropped and hash is
masked to a prefix -- a raw key can't reach the logs even if a caller passes one.
Security guarantees & non-goals
- CSPRNG only --
node:cryptorandomBytes/getRandomValues. A test asserts no source file callsMath.random(. - Constant-time comparison for both key and proof verification.
- The proof HMAC is single-purpose -- domain-separated and format-tagged, not a general signing oracle. Don't reuse the key material for anything else.
- Not a transport, session manager, or rate limiter (
rateLimit/allowlistare carried on the record for the caller to enforce). Not asymmetric -- see the tradeoff andDESIGN-FEEDBACK.md.
License
MIT.
