rand-vec-sig
v1.0.0
Published
Rand-Vec-Sig: self-developed signature + request payload encryption system with dual-mode traffic obfuscation (zero runtime dependencies).
Maintainers
Readme
Rand-Vec-Sig
自研密码学 + 请求报文加密 + 流量混淆的 npm 包。零运行时依赖,不依赖任何第三方密码学库。
实现了《Rand-Vec-Sig 全网混淆签名体系架构设计》的四层结构:
┌──────────────────────────────────────────────────────────────────────┐
│ 业务代码(零改造) │
│ axios.get('/api/x') fetch('/api/y') │
└──────────────┬──────────────────────────────┬────────────────────────┘
│ 工程拦截层 client │
│ 白/黑名单 · 410 刷新重试 · 响应解密 · 会话续期 │
┌──────────────▼──────────────────────────────▼────────────────────────┐
│ 流量伪装层 traffic-obfuscation │
│ 种子 → 字段别名表 (_xxxx) · 值可逆掩码 (ChaCha20+IV) · 确定性诱饵 │
├──────────────────────────────────────────────────────────────────────┤
│ 密码学层 core │
│ 2048bit MODP G14 DH → HKDF → encKey/macKey/sigKey/iv │
│ RVF 自研 Feistel(16 轮, 动态 S 盒) · RVF-CTR + HMAC-SHA256 (AEAD) │
│ 签名 = RVF(sigKey, sha256(RVS1|v|mode|tag|sid|ts|nonce|cpk|alg|..)) │
└──────────────────────────────────────────────────────────────────────┘1. 快速开始
npm install rand-vec-sig1.1 生成服务端密钥对
服务端只有一个长期私钥 Rsk,客户端只持有公钥 Rpk。
npx rand-vec-sig-keygen --bits 512 --out ./secrets/rvs
# → ./secrets/rvs.rsk.env RVS_RSK=... 仅服务端,切勿提交或下发
# → ./secrets/rvs.rpk.env VITE_RVS_RPK=... 可安全打进前端包Rsk 必须来自 KMS / Vault / 环境变量。库不会生成、记录或持久化它;createRandVecSigServer 缺少该值时直接抛 CONFIG_INVALID。
1.2 前端
import axios from 'axios';
import { createRandVecSigClient } from 'rand-vec-sig/client';
const rvs = createRandVecSigClient({
mode: 'static',
versionSeed: import.meta.env.VITE_RVS_VERSION_SEED, // 每次发版轮换
serverPublicKey: import.meta.env.VITE_RVS_RPK, // hex / base64url / BigInt
skipUrls: [/third-party/, '/api/oss-upload'], // 三方与直传必须放行
});
rvs.wrapAxios(axios); // 或 rvs.installFetch() 全局接管 fetch之后业务代码不需要任何改动:命中白名单的请求自动加密载荷、混淆字段名、附加签名。失败即抛错(默认 failOpen: false,不会静默降级成明文请求)。
1.3 服务端
import express from 'express';
import { createRandVecSigServer, createRedisReplayStore } from 'rand-vec-sig/server';
const rvs = createRandVecSigServer({
mode: 'static',
versionSeed: process.env.RVS_VERSION_SEED, // 与前端一致
serverPrivateKey: process.env.RVS_RSK, // 仅来自环境变量
replayStore: createRedisReplayStore(redis), // 生产必须换成 Redis
responseEncryption: true,
});
const app = express();
app.use(express.json());
app.use('/api', rvs.middleware({
skipPaths: ['/api/health', '/api/oss-callback'],
}));
app.post('/api/order/create', (req, res) => {
// req.body 已经是验签 + 解密后的业务 JSON
// req.rvs = { mode, payloadMode, versionTag, sessionId, nonceHex, durationMs, ... }
res.json({ code: 0, data: { received: req.body } });
});Web 标准运行时(Next.js Route Handler、Cloudflare Workers、Deno、Bun)用 rvs.handler(),由 respond 接管响应体,它拿到的 payload 已经验签解密:
// app/api/order/create/route.ts
import { rvs } from '@/lib/rvs'; // 进程内单例
export const POST = rvs.handler({
skipPaths: ['/api/health'],
onReject: (result, request) => metrics.increment(`rvs.reject.${result.code}`, { url: request.url }),
respond: (context, payload) => ({
code: 0,
data: { received: payload, traceId: context.nonceHex.slice(0, 8) },
}),
});不传 respond 时 handler() 会把验签后的请求体原样回显,只适合自测端点。需要自己接框架就直接用 rvs.verify():它从不抛异常,拒绝也走 return { ok: false, code, status }。
2. 双模式
| | static | dynamic |
|---|---|---|
| 种子来源 | 前端构建期注入的 versionSeed | 服务端下发 sessSeed(TTL 900s) |
| 别名表粒度 | 同一版本所有用户共享 | 每个会话独立 |
| 握手开销 | 0 次 | 0 次(响应头捎带)或 1 次 |
| 抗静态逆向 | 轮换 seed 即全体抓包脚本失效 | 单次会话样本无复用价值 |
| 适用 | 主体业务、延迟敏感 | 高价值接口、强对抗场景 |
const rvs = createRandVecSigClient({
mode: 'dynamic',
versionSeed: import.meta.env.VITE_RVS_VERSION_SEED,
serverPublicKey: import.meta.env.VITE_RVS_RPK,
session: {
provider: async () => (await api.get('/rvs/session')).data, // SSR / 原生桥接
// 或 handshake: { url: '/rvs/session' }
// 或依赖 captureFromResponse(默认开启)从任意响应头自动捕获并续期
},
});服务端配 session: { ttlMs: 900_000, issue: 'header' },会话过 TTL 一半即自动续期,新种子通过 x-ky-seed / x-ky-ttl / x-ky-iat 响应头下发,客户端无感切换。
动态模式下 sessSeed 只存在于进程内存(架构红线:不落库)。多实例部署请开启 sticky routing,或依赖「一次 410 重试」自愈。
3. 子路径导出
| 入口 | 内容 | 用途 |
|---|---|---|
| rand-vec-sig | 全部四层 | 小体量接入 |
| rand-vec-sig/core | SHA-256 / HKDF / ChaCha20 / DH / RVF / AEAD / 签名 | 自定义协议、CLI、一致性测试 |
| rand-vec-sig/obfuscation | 别名表 / 掩码 / 诱饵 / 混淆引擎 | 已有签名方案,只想隐藏字段名 |
| rand-vec-sig/client | Axios / Fetch 拦截、会话、白黑名单 | 前端 |
| rand-vec-sig/server | 验签、防重放、中间件、会话存储 | 服务端 |
sideEffects: false,ESM / CJS 双产物,支持 tree-shaking。
4. 报文格式
一次 payloadMode: 'encrypt'(默认)请求的线格式:
{
"_9k3fa0xz2": "A8f2...", // 协议版本 (alias of `v`)
"_p2m1qq87g": "Qz...", // 时间戳 (alias of `t`)
"_zz81n0ka4": "Lm...", // 16B nonce (alias of `n`)
"_a0vv9s2k7": "U3bc...", // 客户端公钥 (alias of `p`)
"_q7ww2m11c": "5Hd...", // 密文 (alias of `d`)
"_k3ll90zm5": "0Pq...", // MAC (alias of `m`)
"_w8ee33rts": "b9...", // 算法标记 (alias of `a`)
"_y1uu55nmb": "R2f...", // 签名 (alias of `s`)
"_c4zz77plo": "...", // ↓ 确定性诱饵,服务端可复现并剥离
"_d6xx22qwe": "...",
"_e9vv11rty": "..."
}- 别名由种子派生(base62 片段 + 防冲突扩展),与业务字段冲突时自动让位。
- 值用 ChaCha20 + 随机 12B IV 掩码后 base64url 输出,同一明文每次上线都不同:时间戳的十进制形状、nonce 的 hex 形状、协议版本的低熵都被抹掉。
- 诱饵由
seed + nonce确定性派生,客户端生成、服务端复现,sign模式下也能精确剥离。 - 头部只带标签,从不带种子:
x-ky-v: <versionTag>、x-ky-s: <sessionId>。
payloadMode: 'sign' 时业务字段保持可读,仅附加签名与诱饵,服务端返回的 payload 仍是纯业务数据。
5. 配置参考
客户端 createRandVecSigClient
| 字段 | 默认 | 说明 |
|---|---|---|
| mode | — | 'static' / 'dynamic' |
| versionSeed | — | 与服务端一致,且必须是高熵随机串(见 §7) |
| serverPublicKey | — | Rpk,hex / base64url / Uint8Array / BigInt |
| protocolVersion | 'rvs1' | 变更即为不兼容升级 |
| identifyHeaders | x-ky-v/x-ky-s/x-ky-n | 可整体轮换以对抗指纹识别 |
| includeUrls / skipUrls | [] | skip 优先;include 非空时只签命中项 |
| signMethods | POST/PUT/PATCH/DELETE | 参与签名的方法 |
| signContentTypes | application/json, +json | 参与签名的 Content-Type |
| profile | { aliasPrefix:'_', aliasLength:9, decoyCount:3, maskValues:true } | 必须与服务端一致 |
| payloadMode | 'encrypt' | 或 'sign'(只签不加密) |
| deploymentId | — | 部署标识(如 prod-cn)。会混入 HKDF info、AAD 与签名串,用于封堵跨部署重放。前后端必须配同一个值。不配置时派生与签名串与旧版逐位一致(升级不会让既有部署失效,但也不受此保护)。长度 ≤ 64 |
| privateKeyBits | 256 | DH 指数位数,红线最低 256 |
| ephemeralKey | { strategy:'window', rotateMs:300000, maxRequests:200 } | 或 'request'(每请求新密钥对) |
| session | — | provider / handshake / captureFromResponse / issueHeaders / refreshAheadRatio / maxRetries |
| retry | { enabled:true, seedExpiredStatus:[410,419], maxRetries:1 } | maxRetries > 1 直接报错 |
| responseEncryption | false | 需服务端同开 |
| failOpen | false | 默认关闭:加密失败即抛错,不降级明文 |
| hooks | — | onSign / onSkip / onError / onSessionChange / onRetry |
| now | Date.now | 可注入时钟,便于测试 |
| trace | — | 结构化诊断事件,不含密钥与载荷。种子过弱时收到 seed.weak |
服务端 createRandVecSigServer
| 字段 | 默认 | 说明 |
|---|---|---|
| mode | — | 与客户端一致 |
| serverPrivateKey | — | Rsk,hex / base64url / BigInt;必填 |
| versionSeed / versionSeeds | — | 静态模式必填;传数组可做无缝轮换窗口。同样是高熵随机串(见 §7) |
| versionTagOf | 内置 | 自定义 versionTag 派生,须与客户端一致。内置派生是种子的公开可验证函数,故种子必须不可枚举;也可在此返回与种子无关的随机 tag |
| profile | 同客户端默认 | 必须与客户端一致 |
| allowedPayloadModes | ['encrypt','sign'] | 可只允许 encrypt |
| timestampToleranceMs | 120000 | 时间窗,建议先做 NTP 对齐 |
| nonceTtlMs | 300000 | 重放记录 TTL |
| replayStore | 内存 LRU | 生产必须换 Redis。内置 MemoryReplayStore 满容量时默认拒绝新请求(fail-closed,抛 REPLAY_STORE_FULL → 503)而非静默淘汰——淘汰活 nonce 等于给重放开门。单进程部署若更看重可用性可显式传 { capacityPolicy: 'evict' },并用 onCapacityExceeded 接告警 |
| deploymentId | — | 客户端配了,服务端必须配同一个值,否则全部验签失败 |
| session | { ttlMs:900000, issue:'header' } | 动态模式必填 |
| identifyHeaders | x-ky-v/x-ky-s/x-ky-n | 与客户端一致(含 nonce,回显头用它) |
| strictSubgroupCheck | false | 开启后每请求多一次模幂(子群校验) |
| responseEncryption | false | 响应体加密 |
| logger | — | (event, detail) => void,永不接收密钥。种子过弱时收到 seed.weak |
中间件 / handler 选项
rvs.middleware() 与 rvs.handler() 共用以下选项:
| 字段 | 默认 | 说明 |
|---|---|---|
| skipPaths | [] | 完全跳过校验的路径(字符串前缀或正则):健康检查、三方回调、OSS 直传 |
| contextProperty | 'rvs' | 验签结果挂到 req 的哪个属性上(日志安全的元信息,不含密钥) |
| replaceBody | true | 用解密后的业务数据覆盖 req.body |
| onReject | — | 每次拒绝回调,接监控。status === 403 才需要告警,410 是正常自愈信号 |
| rejectBody | — | 自定义拒绝响应体(默认 { code, message }) |
| respond | — | 仅 handler():接管响应体,(context, payload, request) => unknown |
middleware() 靠 next() 组合,不需要 respond;handler() 是终结式的,所以需要。
6. 错误码
| 状态 | 错误码 | 含义 |
|---|---|---|
| 410 | SEED_EXPIRED / SESSION_EXPIRED / SESSION_UNKNOWN | 种子过期、未知,或请求在途时种子刚好轮换 → 客户端刷新后重试一次 |
| 403 | SIGNATURE_INVALID / MAC_INVALID / NONCE_REPLAYED / TIMESTAMP_OUT_OF_WINDOW / MISSING_FIELDS / KEY_INVALID | 密码学校验失败 |
| 400 | PAYLOAD_MALFORMED | body 非法 JSON 对象 / 字段格式错误 |
| 503 | REPLAY_STORE_FULL | 重放存储达容量上限。这是可用性信号而非安全问题:存储拒绝记录而不是淘汰活 nonce。运维动作:调大 maxEntries、缩短 nonceTtlMs、或换 Redis。响应文案保持模糊,详情只进日志与 onCapacityExceeded |
| 500 | CONFIG_INVALID / INTERNAL_ERROR / CRYPTO_UNAVAILABLE | 配置或运行时故障(响应体不含内部错误详情,只进日志) |
客户端遇到 410 会刷新种子并只重试一次(单飞,避免惊群),这是协议自愈的唯一路径。
动态模式下的种子轮换不会打断在途请求:续期会给同一个 sessionId 换一把新种子,而别名表与掩码密钥都随种子变化。服务端会把上一代种子保留一个宽限窗口(SESSION_PREVIOUS_GRACE_MS,默认 300s)并依次尝试两代;两者都认不出时返回 410 SEED_EXPIRED 而不是 403,客户端刷新一次即可恢复。若实现了自定义 SessionStore,请一并实现可选的 getPrevious() —— 不实现也能跑,只是并发突发跨越续期点时退化成「410 重试一次」。
7. 部署红线
- 禁止降低 DH 位数:
generateDhKeyPair对 <256bit 的指数直接抛错。 - 禁止关闭流量混淆裸传:
profile.maskValues与字段别名始终生效,sign模式也保留诱饵与签名。 - 禁止客户端自编
seed:动态模式下种子只能来自服务端下发的SessionSeed。 - 禁止
seed/ 会话密钥落盘或落库:MemorySessionStore仅进程内存,TTL 到期即销毁;重放记录只写 nonce 的哈希。 - 禁止
Rsk写死在代码或前端产物中:服务端启动时从环境变量 / KMS 读取。 - 同一份
versionSeed不要跨部署复用。若确实要复用(测试 / 预发 / 生产),必须在客户端与服务端同时配置相同的deploymentId,否则一个部署的请求可以在另一个部署上验签通过。未配置deploymentId时行为与旧版逐位一致 —— 兼容,但不受保护。 - 重放存储不要开
capacityPolicy: 'evict',除非明确接受「攻击者可以刷掉受害者的 nonce 从而重放被捕获请求」这一后果。默认的 fail-closed 会把容量问题暴露成 503 与onCapacityExceeded回调,这是应该看到的信号。生产环境请直接用createRedisReplayStore。 - 禁止
retry.maxRetries > 1:过期种子必须刷新,而不是重试循环。 - 禁止关闭认证:
failOpen默认false,这是刻意设计。
版本种子必须高熵
versionTag 是种子的确定性、公开可验证的函数:
versionTag = HMAC( sha256('rvs/v1/tag/version/rvs1'), versionSeed )[0..8]HMAC 的密钥是写死在代码里的公开常量,所以这个式子等价于「种子的公开指纹」—— 抓到一次请求就能在本地对候选种子做字典爆破并逐字节比对,无需与服务器交互。实测以 web-prod-v2.0.0 为真实种子、6480 个候选串穷举,13.4ms 即还原出种子。
低熵种子不会让「加密」失守(报文仍由逐请求 DH 派生密钥保护,签名也伪造不了),但会让「伪装」这一整层归零:别名表、值掩码、诱饵全部可复现,payloadMode: 'sign' 下业务明文直接裸奔。
正确做法(推荐同时做):
- 种子用真随机值
openssl rand -hex 24,随发版轮换,轮换后旧抓包脚本即刻失效; - 用动态模式,别名表按会话生成、TTL 900s,攻击者收集不到稳定样本;
- 若必须用可读种子,用
versionTagOf返回一个与种子无关的随机不透明 tag,客户端构建期注入同样的 tag。
库在构造时会通过 logger / trace 发 seed.weak 事件提示(默认不打印到 console)。估算口径是 字符串长度 × log2(字符集大小),低于 100bit 即告警。它只能看形状,不能替你确认你是否真的掷了骰子,请当 linter 用。
8. 性能
参考机型 Intel i5-8257U(低功耗移动 CPU,数值偏保守),node scripts/bench.mjs,300 次迭代。下表是同机多次运行的实测区间(该机 p50 对负载敏感,单次数字不可当真):
| 操作 | p50 | p95 |
|---|---|---|
| client.sign 1kB(共享临时密钥) | 1.43 ~ 1.77 ms | 2.82 ~ 4.93 ms |
| client.sign 1kB(每请求新密钥对) | 4.89 ~ 6.81 ms | 5.84 ~ 12.07 ms |
| client.sign 16kB | 3.59 ~ 5.09 ms | 5.69 ~ 11.04 ms |
| client.sign 1kB(payloadMode:'sign') | 0.74 ~ 1.20 ms | 1.58 ~ 5.37 ms |
| server.verify 1kB(DH+KDF+签名+AEAD) | 3.12 ~ 4.87 ms | 4.54 ~ 25.55 ms |
| 全链路 sign + verify 1kB | 4.55 ~ 7.29 ms | 6.18 ~ 15.52 ms |
| RVF-CTR + HMAC 1kB(seal) | 0.35 ~ 0.45 ms | 0.66 ~ 3.48 ms |
在文档设定的 5~12ms 客户端开销预算内(中位数口径)。基准脚本以中位数为门禁,RVS_BENCH_STRICT=1 可同时约束 p95。
首包不是稳态:模块 import 约 5ms,第一次 client.sign 实测 49~75ms,其中约 45ms 是 V8 首次编译 BigInt 模幂与 RVF 位运算路径的 JIT 成本。它每个进程只发生一次,之后按 ephemeralKey 窗口复用共享秘密,不要算进单请求延迟预算。
报文越长,逐字节加密的成本越显眼:RVF-CTR 约 0.44 µs/字节。大 body 场景优先用 payloadMode:'sign'(只签不加密)。单请求明文上限是 MAX_PAYLOAD_BYTES = 4MiB,sign 与 encrypt 共用同一上限。
代码混淆的实测代价
混淆档位不是免费的,而这一层的代价在本库上特别极端 —— 它全是位运算热点循环(RVF 轮函数、S-box 生成、SHA-256、BigInt 模幂),正好是 controlFlowFlattening 与 deadCodeInjection 惩罚最重的代码形态。实测(同机型,client.sign 1kB,p50):
| 档位 | client.sign p50 | 基准总耗时 | dist 体积 | 结论 |
|---|---|---|---|---|
| 不混淆 | 4.36 ms | 7.1 s | 817 kB | 基线 |
| light(默认) | 4.49 ms | 8.6 s | 611 kB | +3% 延迟,体积反而 -25% |
| balanced | 493 ms | 94 s | 1991 kB | 慢 113 倍,体积 3.3 倍,不可用 |
| hardened | 同 balanced | 同 balanced | 同 balanced | 不可用 |
light 已经去掉了可读的协议结构(无字面量字符串表、标识符全部改名),分析者无法再从产物里读出签名串布局,而这几乎不消耗运行时。
balanced / hardened 只在「接口非延迟敏感 + 流量低 + 威胁模型里确实有愿意手工逆向的人」时才值得开启,且开完必须重跑 npm run bench —— 它会直接撞穿 12ms 门禁,判 FAILED 是预期行为。这条红线针对代码混淆;流量伪装层(字段别名、值掩码、诱饵)是强制项,没有开关可以关掉。
密码学强度与风险边界见 SECURITY.md。
9. 验证与构建
npm run typecheck # tsc --noEmit,0 error
npm test # 单元 + 全链路测试
npm run test:vectors # 固定字节向量校验(重构密码热路径的护栏)
npm run test:guards # 变异测试:把每个修复还原成缺陷,断言指定测试变红
npm run build # ESM + CJS + .d.ts
npm run rebuild # clean + build,改了 entry 映射后才需要
npm run build:obf # 追加代码混淆(默认 light,types 不动)
npm run test:dist # 对构建产物做端到端冒烟(CJS + ESM 双通道)
npm run test:examples # 跑 examples/ 里自带断言的示例
npm run bench # 性能基准 + 预算门禁
npm run verify:all # typecheck → test → test:vectors → test:guards → build → test:dist → test:examples
npm run verify:release # verify:all + benchprepublishOnly = verify:all + 混淆 + test:dist,所以 npm publish 不可能发出未混淆或未验证的产物。
test:vectors 存了 21 项固定字节输出(PRNG 流、S 盒、分组加解密、签名变换、CTR keystream、modPow)。性质测试(往返、置换性、雪崩)在实现静默改变时依然会通过,只有固定向量能把「我做了等价重构」变成可证伪的陈述。仅在有意改变密码行为时才跑 test:vectors:update,并须在 CHANGELOG 说明原因。
test:guards 把每个修复还原成缺陷形态,断言指定的那条测试变红,再用 finally 还原源文件。锚点必须恰好匹配一次(重构后代码移位会报 STALE,而不是静默地什么都没测),并且必须由指定的那条测试失败(否则报 RED-BUT-WRONG-TEST)。
npm run build 不删除任何文件:它走 scripts/build.mjs 调 tsup 编程式 API 并传 config: false,跳过配置文件发现环节,从而不再生成(也就不再 unlink)tsup 自己的临时配置。这是刻意的 —— 在开启批量删除守卫的加固 shell / CI 沙箱里,unlink 会被拒绝并直接判定构建失败,而删除那个临时文件对构建结果毫无影响。代价是旧产物不会自动清理,改了 scripts/tsup.options.mjs 的 entry 映射后请跑 npm run rebuild。
scripts/ 与 tests/ 不随包发布,因此上面这些命令只在源码仓库内可用。使用者只需要 rand-vec-sig-keygen 这一个随包发布的 CLI。
可运行的示例在 examples/,见 examples/README.md。其中 01 / 03 / 04 自带安全断言(网线无明文、重放被拒、篡改被拒、410 恰好重试一次),失败即以非 0 退出码结束,可以直接当冒烟测试用。
10. 常见问题
为什么 sign 模式也需要服务端?
签名要覆盖「含诱饵的完整报文体」,而签名字段本身又在报文里。协议的处理方式是:客户端对不含签名字段的报文求 sha256 作为 binding;诱饵名从 seed + nonce 确定性派生,服务端解出 nonce 后复现同一列表并剥离,因此业务字段与噪音可以精确分离。
解密后的 body 字段顺序为什么变了?
载荷使用 stableStringify 规范化后加密(键排序),服务端解出的对象是规范序。语义完全等价,但不要对 JSON 字符串做逐字节比较。
三方接口 / OSS 直传被拦截了?
放进 skipUrls(字符串前缀、子串、正则或对象均可)。空正则 /(?:)/ 会被拒绝 —— 它会一次性禁用全部签名。
我的 Node 环境没有 WebCrypto?
randomBytes 依次尝试 globalThis.crypto.getRandomValues → 注入源 → process.getBuiltinModule('node:crypto'),全部不可用时抛 CRYPTO_UNAVAILABLE。可用 setRandomSource(fn) 注入密码学安全的随机源(切勿传 Math.random())。
rvs.handler() 为什么把请求体回显回来了?
因为没传 respond。handler() 需要知道「验签通过之后回什么」,不传时它只能把验签后的请求体原样回显。传 respond: (context, payload) => ... 即可接管。
如何轮换?
版本种子:前端发版时改 versionSeed,服务端 versionSeeds: [newSeed, oldSeed] 保留旧值一个排空窗口,再移除 —— 那一刻所有旧抓包脚本同时失效。Rsk:需要重新生成并同步客户端 Rpk,属于客户端不兼容变更,按发布流程处理。
11. 许可
MIT。
部署前请阅读 SECURITY.md,特别是关于自研密码学风险的部分。
