npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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

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