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, 会话管理, 安全组件, 自动化攻击, 防重放攻击