chrisch88dev/keybound

GitHub: chrisch88dev/keybound

Keybound 是一个 Node.js 框架无关的会话绑定安全组件,通过浏览器持有的 P-256 私钥对服务器 challenge 进行签名,防止被盗的 session cookie 被重放利用。

Stars: 1 | Forks: 0

# 按键绑定 用于 Node.js 的设备密钥会话凭证。Keybound 通过要求使用浏览器持有的私钥来执行敏感的服务器操作,从而增加了重放复制的 session cookie 的难度。 Keybound 源于一个实际问题:cookie 转储使得普通的 session cookie 可以被转移。其目标是提供一个 Node.js 安全基础组件,让独立开发者能够理解、测试并将其接入实际应用,而无需绑定特定的框架或平台。 Keybound 在已认证的会话中增加了一个凭证步骤。浏览器持有的 P-256 密钥会对服务器签发的新鲜 challenge 进行签名,该 challenge 绑定到特定的会话、设备、服务器用途以及已注册的公钥。被复制的 cookie 中并不包含该私钥。 Keybound 是框架无关的。它不会替换你的身份验证库、session 存储或数据库。它提供了一个供这些系统调用的轻量级服务器端核心。 ## 安装 ``` npm install keybound ``` 要求 Node.js 20 或更高版本。 ## 设置 使用随机的服务器密钥。将其排除在版本控制之外,并通过你常规的密钥管理流程进行轮换。 ``` import { randomBytes } from "node:crypto"; import { createKeybound } from "keybound"; const keybound = createKeybound({ secret: process.env.KEYBOUND_SECRET ?? randomBytes(32), preset: "default" }); ``` `keybound.config.cookie` 是针对设备标识 cookie 的强化配置。默认值为设置了 `Secure`、`HttpOnly` 和 `Path=/` 的 `__Host-` cookie。设备标识不是机密信息。浏览器私钥才是凭证材料,绝对不能存储在 cookie 中。 Keybound 的设备 cookie 与你的登录 cookie 是相互独立的: ``` session cookie -> who is logged in Keybound device cookie -> which enrolled device record to load browser private key -> proof that copied cookies are not enough ``` ## 流程 1. 浏览器使用 Web Crypto 创建一个 ECDSA P-256 密钥对。将私钥创建为不可提取的,并将其持久化存储在 cookie 之外,例如存储在 IndexedDB 中。 2. 在设备注册期间,将公钥 JWK 发送到服务器。将其与服务器生成的设备 ID 一并存储。 3. 将设备 ID 设置为配置好的安全 cookie。在服务器端存储公钥。 4. 当需要凭证时,为特定的服务器用途签发一个短时效的 challenge,并将其 ID 和值返回给浏览器。 5. 浏览器使用 ECDSA SHA-256 对 base64url 解码后的 challenge 进行签名,然后将签名发回。 6. 从服务器加载已注册的公钥,验证凭证,并原子性地消费该 challenge。 传递给 Keybound 的公钥必须来自已注册的设备记录,而不是来自凭证请求体。Keybound 还会将该密钥绑定到 challenge 记录中,因此被替换的密钥无法通过之前签发的 challenge 的验证。 使用 `purpose` 将 challenge 绑定到请求它的服务器操作。示例:`session:renew`、`device:replace`、`mfa:step-up`、`payment:create`。为 `session:renew` 签发的凭证即使在同一会话和设备内,也不能作为 `payment:create` 通过验证。 设备注册和替换是安全边界。在添加或替换密钥之前,必须要求提供现有设备凭证或进行升级认证。一个仅凭 cookie 即可访问的注册接口会让 cookie 窃贼注册他们自己的设备。 ## 服务器示例 ``` const issued = keybound.issueChallenge({ sessionId, deviceId, publicKey: enrolledDevice.publicKey, purpose: "session:renew" }); await challengeStore.insert(issued.record); return { challengeId: issued.id, challenge: issued.challenge, expiresAt: issued.expiresAt }; ``` ``` const result = await keybound.verifyAndConsumeProof({ store: challengeStore, sessionId, deviceId, challengeId: request.body.challengeId, challenge: request.body.challenge, signature: request.body.signature, publicKey: enrolledDevice.publicKey, purpose: "session:renew" }); if (!result.ok) { // Deny, require step-up, or end the session according to your application policy. } ``` `challengeStore` 有两个操作: ``` interface KeyboundChallengeStore { get(challengeId: string): Promise; consume(challengeId: string, expectedDigest: string): Promise; } ``` `consume` 必须是原子性的。对于匹配的 challenge ID 和摘要,它必须返回一次 `true`,然后对之后的每一次调用都返回 `false`。SQL 实现可以使用条件更新或删除。Redis 实现应使用单个原子命令或脚本。 构建后即可使用包含的可运行示例流程: ``` npm run build node examples/node-proof.mjs ``` 浏览器登录演示展示了完整的会话和设备流程: ``` npm run demo:login ``` 打开 `http://localhost:4173`。 ## 配置 | 预设 | Challenge 生命周期 | 设备 cookie | | --- | ---: | --- | | `relaxed` | 120 秒 | `SameSite=Lax`, 365 天 | | `default` | 60 秒 | `SameSite=Lax`, 180 天 | | `strict` | 30 秒 | `SameSite=Strict`, 90 天 | 所有预设都保持启用 `Secure`、`HttpOnly` 和 `Path=/`。你可以选择一个预设并覆盖安全的 cookie 字段: ``` const keybound = createKeybound({ secret: process.env.KEYBOUND_SECRET!, preset: "strict", cookie: { name: "__Host-keybound-device", maxAgeSeconds: 60 * 60 * 24 * 30, partitioned: true } }); ``` Challenge 生命周期特意限制在 5 秒到 5 分钟之间。较短的生命周期可以缩小重放窗口。设备 cookie 是 host-only 的,因为 Keybound 没有暴露 `Domain` 选项。 `purpose` 是可选的,默认为 `session`。对于敏感路由,请从服务器代码中传递一个明确的稳定值。不要信任请求体来选择用途。 关于 cookie 辅助方法: ``` import { readKeyboundCookie, serializeKeyboundCookie, clearKeyboundCookie } from "keybound/http"; ``` ## 浏览器密钥 浏览器端应使用 Web Crypto: ``` const keyPair = await crypto.subtle.generateKey( { name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"] ); const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey); ``` `false` 值使得私钥变得不可提取。浏览器 JavaScript 仍然可以请求该密钥进行签名,但无法通过 Web Crypto 导出私钥字节。请将私钥以 `CryptoKey` 的形式存储在 IndexedDB 中,而不是 cookie 或 localStorage 中。 在对 challenge 进行签名时,请先解码 base64url 格式的 challenge: ``` const signature = await crypto.subtle.sign( { name: "ECDSA", hash: "SHA-256" }, privateKey, decodedChallenge ); ``` Web Crypto 返回原始的 P-256 签名格式。Keybound 直接验证该格式,因此浏览器不需要进行 DER 转换。 在注册或验证凭证期间,预计可能会出现以下浏览器异常: - `NotAllowedError`:该密钥不能用于请求的操作。 - `InvalidAccessError`:密钥类型、曲线或用途不匹配。 - `DataError`:导入的密钥数据格式错误。 - `OperationError`:浏览器无法完成加密操作。 将这些情况视为凭证验证失败,并根据你的应用策略采取相应措施:重试一次、要求进行升级认证、在升级认证后替换设备,或者终止会话。 ## 技术与性能 - 严格的 TypeScript 和 ESM。 - 仅使用 Node.js 内置模块,没有运行时依赖。 - 使用 `randomBytes` 生成 challenge 和设备 ID。 - 使用 HMAC-SHA-256 将 challenge、会话、设备、用途、公钥和过期时间绑定到存储记录中。 - 使用 ECDSA P-256 和 SHA-256 进行浏览器凭证验证。 签发一个 challenge 会执行一次随机生成步骤和一次 HMAC。验证凭证会执行一次 HMAC 和一次 P-256 签名验证。核心部分不进行任何网络或数据库 I/O。存储延迟和浏览器往返时间仍然是应用程序需要关注的责任,因此请将凭证验证应用于会话延续、会话续期和高风险操作,而不是静态资源请求。 ## 安全边界 当攻击者复制了 cookie 但无法使用已注册的浏览器密钥时,Keybound 能提供有效的防护。当你将 challenge 绑定到服务器用途时,它还限制了全新凭证的使用场景。它不能保护已被入侵的服务器、在活动浏览器内部运行的 XSS 或恶意软件、通过钓鱼获取用于相同目的的全新凭证的情况,或者应用程序接受由攻击者控制的公钥作为已注册状态的情况。 它是对安全会话 cookie、会话轮换、CSRF 防护、XSS 防御、MFA 和事件响应的补充。它不是一个 HTTP 安全头标包,也不能替代 Helmet。 ## 文档 - [工作原理](docs/how-it-works.md) - [配置](docs/configuration.md) - [浏览器密钥与异常](docs/browser-key.md) - [框架接入](docs/frameworks.md) - [登录演示](docs/demo.md) ## 测试 ``` npm run check ``` 测试套件涵盖了有效凭证、会话、设备和用途不匹配、被篡改的 challenge、密钥替换、过期、格式错误的输入以及原子重放处理。 安全问题请参见 [SECURITY.md](SECURITY.md)。贡献和审查期望请参见 [CONTRIBUTING.md](CONTRIBUTING.md)。 ## 许可证 MIT
标签:GNU通用公共许可证, MITM代理, Node.js, Web Crypto, 会话管理, 安全组件, 自动化攻击, 防重放攻击