双键原子
相互、可独立撤销 无记名钥匙认证 与
挑战反应方向证明 财务报告和审定财务报表 下级绑定独立和
无依赖 - node:crypto 仅Bun WebCrypto, pino 一个可选的对等点.
它对WebSockets,队列或任何更大的系统一无所知. 它薄荷和
验证密钥,制作并检查方向证明. 传送器插入
作为 AuthProvider (duck-typed -- 两种方式都不导入).
bun add @claudewerk/mutual-key-auth # or: npm i @claudewerk/mutual-key-auth
bun test # 58 tests, all attack cases included
为什么存在(威胁模式)
连接上的两个同伴 都想不断独立地证明, 对方仍然持有有效的证书 -- 没有人降低 谈判参数 中手摇。 每个机制都为特定攻击辩护:
| 机制 | 防御 |
|---|---|
| 存储的密钥 散开 (SHA-256),一次显示原始 | DB/唱片被窃并不能揭示出原始的无记名密钥(没有预想). 它不会停止握手作假:所储存的散列是防向的HMAC密钥,因此被窃记录可以冒充同位素. 警卫记录,如私钥;固定为不对称证明(RFC 0004§9.1/§10) |
| 常时 hash + 证明比较 | 泄露秘密字节的时空侧道 |
| 过期 每个键 | 泄露的密钥仅在到期前有用 |
| 独立撤销 (按键,任意一边) | 折中一个对等方的密钥不会迫使另一个完全重键 |
| 按键旋转 (两个活键重叠) | 在不硬切换/ 丢弃连接的情况下滚动密钥 |
| 加拿大 单用途非ce | 重放被捕获的证明(nonce为一发;被烧毁的nonce不能被再用) |
| 诺斯 计时器 | 无限挑战有效性; 缩小重播窗口 |
方向证明 =: HMAC(transcript, keySecret) | 证明拥有 不把钥匙放电线上; 将证据绑定在一个证明角色上 |
角色绝对 记录initiatorHello+ 键responderHello 固定顺序, proverRole 页:1 | 两端都用零视角杂耍来计算字节-同声记录 - 没有我的/同声互换的脚枪 |
缩写绑定 两个相框的花序 + 谈判的跨层拖轮 {version, features, and every negotiation slot's chosen name} | 每层都有降级攻击 - 篡改任何谈判参数(运输) 或者说 app-level:编码,功能集,认证方法)更改了笔录,因此证明不再被验证(SSH exchange-hash / TLS-Fined property) |
transcriptHash 绑定 (用两个原始的广告打招呼框) | MITM在谈判前根据公布的能力进行编辑 -- -- 认证全文,而不仅仅是谈判结果 |
connId 绑定 | 交叉连接重放抓取的证据到不同的连接 |
| 长度,固定数量记录 | 凝聚-凝聚攻击+ 添加/移动-场 篡改地界 |
| 域分隔标签 + 记录中的格式标签 | 重新使用证据 HMAC 作为通用签名预告 |
| 范围 - > 动词 矩阵检查 | 过于宽泛的授权;一个关键只是它所赋予的范围 |
| 编辑日志 | 意外地记录原始密钥、散列或证据 |
ed25519-sig 方法:记录持有公用钥匙,证据是同一笔录上的Ed25519签名(RFC0004§10) | 校验-存储盗窃:被窃记录验证了证据但不能制作一个,所以不能冒充对等者-关闭下方的HMAC取舍 |
签名配对票 (单位:千美元)brgt1_..., Ed25519,载在标有标记的罐装有效载荷上,过期,为4KiB盖) | URL 或 公钥在主机之间复制票时被交换 -- -- 编辑后卡失败 bad-signature |
对格式错误的输入进行失败的闭合握手 (非字符串 / 过大的非ce 或 证明 - > malformed-auth-message; 加密层本身的拒绝被捕获) | 远程相撞:在载体的认证交换中抛出一个会终止这一过程,所以一个可以制造的对等点 prove() 用一个短的非ce来扔, 在认证前会杀死一个听众 |
已知取舍( 读此)
证据是 对称的 HMAC 由密钥在后端的密钥键入
(单位:千美元)SHA-256(rawKey) == record.hash) (中文(简体) ). 证明者从它的生钥匙中得来
核查者已将其记录在案; 原始密钥从不移动编辑
权衡:偷取所存储记录的攻击者可以 伪造方向证明
(虽然没有通过生钥匙核查 -- -- 这仍然需要一个预想). 这是
内在的对称 HMAC 有散列存储。 缓解:短期限
- 快速独立撤销,两者都建在. 如果你需要证据才能活下来
恢复妥协,使用
ed25519-sig方法(下一节):相同 握手,同样的下级绑定,记录只持有公用钥匙.
使用量
签字方法ed25519-sig) -- 更喜欢这个你可以
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 实值 brg-ed25519_<base64url 32-byte seed>: 储存像任何
私钥. 握手、发出信息以及拒绝的原因是:
asAuthProvider;只有原始证据不同. 一个听众从
mutual-key 两种方法同时提供(bun-liable-ws) authByMethod) (中文(简体) ).
配对入场券——每对对等者可复制一个字符串
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
票是自签的联络卡: peerId,Ed25519 公用钥匙,拨打URL,
发行时间和可选过期。 签名在中途捕获任何编辑
不证明密钥属于你认为的人 -- -- 信任是频道
你收到它过来。 配对是一张票 从来没有秘密
移动。 范围是VERIFIER的设定(parseTicket(..., { scopes })从不
一张卡片上写的 拒绝: malformed, (中文(简体) ), unsupported-version, (中文(简体) ),
bad-signature, (中文(简体) ), expired。 。 。 .
薄荷, 储存散列, 校验
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"|... }
相互握手(asAuthProvider ——接通可靠接通 lib)
asAuthProvider 符合 没错 页:1 AuthProvider 约一个可靠的 -
WebSocket 库( R)bun-liable-ws (可信赖的bun-liable-ws) (英语)-形状)驱动器 - 不
适配器,无胶. 它回来了 { method, start(ctx), step(ctx, incoming) } 财务报告和审定财务报表
运行三个消息的相互交换 在证明原始。 把它作为电线
工厂auth: () => asAuthProvider({...}))——每次接通一次握手.
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 });
连接库各递 start编号 :step 调用 a 新鲜 AuthContext
(它每次重建它),所以 所有握手状态 住在提供者
关闭, 永远不要在 ctx 上。你什么也得不到 -- -- 图书馆提供:
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" }.
每个角色的证明都具有约束力: H(initiatorHello) + 键 H(responderHello) (从)
local编号 :remoteCapabilities页:1 negotiated tuple(版本、特征和
每个谈判时间段的选定名称,
connId,以及 transcriptHash - 所以任何跨层降级或MITM
能力编辑断开握手.
方向验证原始( 下等, 下等)
握手就是建立在这个基础上的. 这个 核查者 签发非ce 验证 返回共享上的 HMAC 角色绝对 记录 两端都通过 相同的元素——没有视角杂耍.
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
一个非ce=一次尝试.
verifyProof在加密前消耗非ce 检查,所以一个失败的证据会烧掉它 -- 用新鲜的来重试挑战newNonce(),从不重发相同的非ce。 (悬挂在DESIGN-FEEDBACK.md #3. ) (中文(简体) )
范围 - > 动词检查( 纯)
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)
撤销旋转( R)
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? }
纯函数也直接导出( N)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)用而无国供.
带外提供密钥
mintKey 生成一个密钥,并手拿回记录。 当钥匙
已经存在 - 它来自一个配置文件, 一个秘密商店, 或者一个固定的二
进程共享 -- -- 从中获取记录:
import { recordForKey } from "@claudewerk/mutual-key-auth";
const peerRecord = recordForKey(process.env.PEER_KEY, { peerId: "rust-client" });
mintKey 实值 generate + recordForKey,因此两者生成相同的记录用于
同样的钥匙。 这就是让一个TS主机和一个单独的客户端程序达成一致的原因
对方的记录没有相互传递记录:
启动时对等方的生密钥,获得记录,只保留散列.
衍生(委托)密钥
一个长寿的父键可以支持许多短寿的同龄人,没有 薄荷服务,没有密钥存储. 父母的持有者每个子女 把它交给它产生的过程; 核查的一面,它也是 持有父母,从同龄人声称的科目中重新选定同一个孩子:
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 实值 HMAC-SHA256(parent, "mutual-key-auth/derive/v1" || 0x00 || subject)
作为 <prefix>_<base64url>儿童不透露其父母或
(a) 父母的兄弟姐妹; 谁拿着
因此,父母是家庭的秘密.
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 是一个短而安全的到日志的手柄(散列的前12个六分轴). hash 这是
满 SHA-256(rawKey) - 在休息时视之为敏感(见上文权衡)。 这个
显示原始密钥 仅限 内 mintKey返回,从未坚持或记录.
日志
可注入、结构化和 编辑。通过 logger数字 :
"silent" (违约), "console", (中文(简体) ), "pino" (懒惰的可选对等点 -- -- 仅需要)
或任何鸭子型的 { info, warn, error, debug } 对象。 每个
后端是包着的 key编号 :secret编号 :proof编号 :token 被删除 hash 实值
被蒙到前缀上——一个生键即使呼叫者通过一个也不能到达日志.
安全保证和非目标
- 仅限CSPRNG - 说吧
node:cryptorandomBytes编号 :getRandomValues。测试 声称没有源文件调用Math.random(。 。 。 . - 常时 比较关键和证据核查.
- HMAC的证据是 单一用途 -- 域分隔和格式标记, 不 一个通用签名神谕。 不得再用关键材料作别用.
- 没有 运输、会话管理或费率限制(
rateLimit编号 :allowlist被记录在记录上,供打电话者强制执行. 没有 不对称 -- 来看权衡和DESIGN-FEEDBACK.md。 。 。 .
许可证
麻省理工学院.