65726702f3
Co-Authored-By: Claude Code <noreply@anthropic.com>
80 lines
3.2 KiB
TypeScript
80 lines
3.2 KiB
TypeScript
// packages/contracts/src/internal-sync.ts —— /internal/** 服务间同步接口的
|
||
// HMAC 鉴权单一实现(P2 Task 3)。签名规则(openapi.yaml [internal] tag 描述):
|
||
// X-Sync-Signature = hex(HMAC-SHA256(原始请求体字节, SYNC_INTERNAL_KEY))
|
||
// X-Sync-Timestamp = Unix 秒,与服务端时差 ±300s 内有效(防重放)
|
||
// 发送方(services/sync sender)与接收方(cn bootstrap/reconcile、ov
|
||
// content-batches 的 InternalSyncGuard)共用本模块,杜绝两侧规则漂移。
|
||
// 注意签名覆盖的是**原始请求体字节**(rawBody):接收端必须经 body-parser 的
|
||
// verify 钩子(NestFactory rawBody: true)在 JSON 解析前捕获原始字节,重序列
|
||
// 化的 body 与签名不一致会 401。
|
||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||
|
||
/** 内部同步 HMAC 密钥的 env 键名(仅 env 注入,不入库不入码)。 */
|
||
export const SYNC_INTERNAL_KEY_ENV = 'SYNC_INTERNAL_KEY';
|
||
|
||
/** HMAC 签名/时间戳请求头(小写;Node req.headers 已归一小写)。 */
|
||
export const SYNC_SIGNATURE_HEADER = 'x-sync-signature';
|
||
export const SYNC_TIMESTAMP_HEADER = 'x-sync-timestamp';
|
||
|
||
/** 时间戳容忍窗(秒,防重放;契约 [internal] tag 描述 ±300s)。 */
|
||
export const SYNC_TIMESTAMP_TOLERANCE_SECONDS = 300;
|
||
|
||
/**
|
||
* 计算请求体签名:hex(HMAC-SHA256(rawBody, key))。
|
||
* rawBody 必须是与线上传输完全一致的原始字节(发送侧:JSON.stringify 的产物
|
||
* 原样作为 fetch body;接收侧:body-parser verify 捕获的 Buffer)。
|
||
*/
|
||
export function signSyncBody(
|
||
key: string,
|
||
rawBody: string | Buffer,
|
||
): string {
|
||
return createHmac('sha256', key).update(rawBody).digest('hex');
|
||
}
|
||
|
||
/** 验签输入:头取值原样传入(数组/缺失由本函数判 false,不抛错)。 */
|
||
export interface VerifySyncSignatureInput {
|
||
key: string;
|
||
rawBody: Buffer | string;
|
||
timestampHeader: unknown;
|
||
signatureHeader: unknown;
|
||
/** 可注入时钟(测试用);缺省 Date.now()。 */
|
||
nowMs?: number;
|
||
}
|
||
|
||
/**
|
||
* 接收端验签:时间戳在窗内 + 签名 hex(64) 形状 + 常时比较全过才 true。
|
||
* key 未配置(空串)恒 false——密钥缺失时内部端点整体不可用而非裸奔。
|
||
*/
|
||
export function verifySyncSignature(input: VerifySyncSignatureInput): boolean {
|
||
const { key, rawBody, timestampHeader, signatureHeader } = input;
|
||
if (key === '') {
|
||
return false;
|
||
}
|
||
const timestamp =
|
||
typeof timestampHeader === 'string'
|
||
? Number(timestampHeader.trim())
|
||
: Number.NaN;
|
||
if (!Number.isFinite(timestamp)) {
|
||
return false;
|
||
}
|
||
const nowSeconds = Math.floor((input.nowMs ?? Date.now()) / 1000);
|
||
if (
|
||
Math.abs(nowSeconds - timestamp) > SYNC_TIMESTAMP_TOLERANCE_SECONDS
|
||
) {
|
||
return false;
|
||
}
|
||
if (
|
||
typeof signatureHeader !== 'string' ||
|
||
!/^[0-9a-f]{64}$/.test(signatureHeader.trim())
|
||
) {
|
||
return false;
|
||
}
|
||
const expected = signSyncBody(key, rawBody);
|
||
const provided = signatureHeader.trim();
|
||
// 两串均为 64 hex 字符(上方正则保证),等长 Buffer,timingSafeEqual 安全。
|
||
return timingSafeEqual(
|
||
Buffer.from(expected, 'hex'),
|
||
Buffer.from(provided, 'hex'),
|
||
);
|
||
}
|