@claudewerk/mutual-key-auth

中文文档
0.2.1发布于 2026/10/27MITTypeScriptES Module零依赖近 30 天下载 1910

Mutual, independently-revocable bearer-key authentication with challenge-response direction proofs and downgrade binding.

原始使用文档
文档目录17 节
  1. Why it exists (threat model)
  2. Known tradeoff (read this)
  3. Usage
  4. Signature method ( ed25519-sig ) -- prefer this where you c…
  5. Pairing tickets -- one copyable string per peer
  6. Mint, store hashed, verify
  7. Mutual handshake ( asAuthProvider -- drop-in for a reliable…
  8. Direction proof primitive (lower-level, downgrade-bound)
  9. Scope -> verb check (pure)
  10. Revocation & rotation
  11. API
  12. Keys provisioned out of band
  13. Derived (delegated) keys
  14. KeyRecord
  15. Logging
  16. Security guarantees & non-goals
  17. License

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 included

Why 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:

MechanismDefends against
Keys stored hashed (SHA-256), raw shown onceDB/record theft doesn't reveal the raw bearer key (no preimage). It does NOT stop handshake forgery: the stored hash is the direction-proof HMAC key, so a stolen record can impersonate that peer. Guard records like private keys; the fix is asymmetric proofs (RFC 0004 §9.1 / §10)
Constant-time hash + proof comparisonTiming side-channels that leak the secret byte-by-byte
Expiry on every keyA 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 nonceReplay of a captured proof (nonce is one-shot; a burned nonce can't be reused)
Nonce time-boxingIndefinite 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 boundCross-connection replay of a captured proof onto a different connection
Length-framed, fixed-arity transcriptConcatenation-collision attacks + add/remove-field tampering across field boundaries
Domain-separation label + format tag in the transcriptReuse of the proof HMAC as a general signing oracle
Scope -> verb matrix checkOver-broad authorization; a key does only what its scopes grant
Redacting loggerAccidentally logging a raw key, hash, or proof
ed25519-sig method: the record holds a PUBLIC key, the proof is an Ed25519 signature over the same transcript (RFC 0004 §10)Verifier-store theft: a stolen record verifies proofs but cannot make one, so it cannot impersonate the peer -- closes the HMAC tradeoff below
Signed pairing ticket (brgt1_..., Ed25519 over a labelled canonical payload, expiry, 4 KiB cap)A URL or public key swapped while the ticket is copied between hosts -- an edited card fails bad-signature
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 the ed25519-sig method (next section): same handshake, same downgrade binding, and the record holds only a public key.

Usage

Signature method (ed25519-sig) -- prefer this where you can

import { mintSigningKey, recordForPublicKey, publicKeyOf, asSigAuthProvider } from "@claudewerk/mutual-key-auth";

const me = mintSigningKey({ peerId: "hostA" });   // { secretKey, record }
// ship me.record (or just publicKeyOf(me.secretKey)) to the peer; it holds no secret
const peerRecord = recordForPublicKey(peerPublicKey, { peerId: "hostB", scopes: ["send"] });

const provider = asSigAuthProvider({ mySecretKey: me.secretKey, peerRecord }); // method "ed25519-sig"

secretKey is brg-ed25519_<base64url 32-byte seed>: store it like any private key. The handshake, its messages and its rejection reasons are those of asAuthProvider; only the proof primitive differs. A listener migrating from mutual-key offers both methods at once (bun-reliable-ws authByMethod).

Pairing tickets -- one copyable string per peer

import { mintTicket, parseTicket } from "@claudewerk/mutual-key-auth";

// host B prints its card; host A pastes it
const card = mintTicket(B.secretKey, { peerId: "hostB", urls: ["wss://b.example/bridge"], expiresAt });
const got = parseTicket(card, { scopes: ["send"] }); // { ok, ticket: { peerId, publicKey, urls, ... }, record }
// got.record is A's key record for B; got.ticket.urls is where to dial

A ticket is a self-signed contact card: peerId, Ed25519 public key, dial URLs, issue time and optional expiry. The signature catches any edit in transit; it does not prove the key belongs to who you think -- that trust is the channel you received it over. Pairing is one ticket each way, and no secret ever moves. Scopes are the VERIFIER's to set (parseTicket(..., { scopes })), never something a card claims. Refusals: malformed, unsupported-version, bad-signature, expired.

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 tampered

One nonce = one attempt. verifyProof consumes the nonce before the crypto check, so a failed proof burns it -- retry the challenge with a fresh newNonce(), never resend the same nonce. (Flagged in DESIGN-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 same handshake with Ed25519 signature proofs (RFC 0004 §10):
asSigAuthProvider({ mySecretKey, peerRecord, method?, domainSepLabel?, now? })  // method "ed25519-sig"
mintSigningKey({ peerId, scopes?, expiresAt?, prefix?, rateLimit?, allowlist? }) -> { secretKey, record }
recordForPublicKey(publicKey, { peerId, scopes?, expiresAt?, ... }) -> record
publicKeyOf(secretKey) -> string   // base64url raw public key
makeSigProof(secretKey, { nonce, transcriptParts }) -> string
verifySigProof(proof, { nonce, transcriptParts, keyRecordForProver }) -> boolean

// Pairing tickets (self-signed contact cards for ed25519-sig peers):
mintTicket(secretKey, { peerId, urls, expiresAt?, now? }) -> "brgt1_..."
parseTicket(text, { scopes?, now? }) -> { ok: true, ticket, record } | { ok: false, reason }

// 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, mintSigningKey, recordForPublicKey, publicKeyOf, makeSigProof, verifySigProof, asSigAuthProvider, SIG_METHOD, mintTicket, parseTicket, 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

// mutual-key
{ id, peerId, scopes[], rateLimit?, allowlist?, expiresAt, status: "active"|"revoked",
  hash, prefix, createdAt }
// ed25519-sig: no secret at all
{ id, peerId, scopes[], rateLimit?, allowlist?, expiresAt, status: "active"|"revoked",
  alg: "ed25519", publicKey, 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:crypto randomBytes / getRandomValues. A test asserts no source file calls Math.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/allowlist are carried on the record for the caller to enforce). Not asymmetric -- see the tradeoff and DESIGN-FEEDBACK.md.

License

MIT.

版本与发布记录

@claudewerk/mutual-key-auth 共发布 3 个版本,首次发布于 2026/09/28;过去 12 个月发布了 3 个版本。

版本发布日期距今
0.2.1latest2026/10/2713 天前
0.2.02026/09/281 个月前
0.1.02026/09/281 个月前

关于 @claudewerk/mutual-key-auth 的常见问题

@claudewerk/mutual-key-auth 是什么?

Mutual, independently-revocable bearer-key authentication with challenge-response direction proofs and downgrade binding.

如何安装 @claudewerk/mutual-key-auth?

在项目目录运行 npm install @claudewerk/mutual-key-auth,或使用 pnpm add / yarn add / bun add。

@claudewerk/mutual-key-auth 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@claudewerk/mutual-key-auth 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@claudewerk/mutual-key-auth 可以商用吗?

@claudewerk/mutual-key-auth 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@claudewerk/mutual-key-auth 的下载量有多少?

根据 npm 官方下载统计,@claudewerk/mutual-key-auth 在 2026/10/10 – 2026/11/08 这 30 天内被下载 1,910 次,日均约 64 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@claudewerk/mutual-key-auth 还在维护吗?

最新版本 0.2.1 发布于 2026/10/27(13 天前);至今共发布 3 个版本,其中过去 12 个月发布了 3 个;首次发布于 2026/09/28。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。