@yixinshuke/node-auth 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { type OpenSecretBundle } from "./openSecret.js";
3
+
4
+ /**
5
+ * 服务调用签名(appId + secret,HMAC-SHA256)。
6
+ *
7
+ * 适用通道:`/open`(对外开放接口)、`/callback`(回调)——区别于 `/api` 的人用登录态(JWT)。
8
+ *
9
+ * ## 签名四要素在「提交数据」里,不在 HTTP 头里
10
+ * 四项要素 `appId / timestamp / nonce / sign` 与业务参数**同处一个 JSON body**(见 `SIGN_FIELDS`)。
11
+ * 理由:① 开放通道刻意不带 `Authorization` / `Cookie`(那是 `/api` 的人用登录态,不该出现在机器通道上,
12
+ * 否则同一份报文里混着两套身份,语义混乱且容易误判);② body 才是被签名覆盖的载体,要素与报文绑在一起
13
+ * 不会被代理 / 网关掐头;③ 调用方只需拼一个 JSON,不必再操心头部。
14
+ * 摘要计算时由 `buildSignBase` **统一剔除**这四项(见 `stripSignFields`),故报文中带不带它们、
15
+ * 带的是旧值还是新值都不影响签名——两端永远算同一份「纯业务报文」摘要。
16
+ *
17
+ * ## 为什么用 HMAC-SHA256 而不是裸 sha1(secret + 串)
18
+ * - 拼接式哈希存在「长度扩展攻击」隐患,HMAC 天生免疫;
19
+ * - SHA1 碰撞已被攻破,不再适合做签名基元;
20
+ * - HMAC-SHA256 与 SHA1 同量级开销,node:crypto 一行可得。
21
+ *
22
+ * ## 对称密钥的现实代价(使用者必须知道)
23
+ * 校验方同时握有所有调用方的 secret ⇒ **校验方一旦被攻破,攻击者可伪装成任意一个调用方**。
24
+ * 因此必须坚持「一个 appId 一把 secret」,才能做单独吊销与轮换,避免一处泄露全盘失效。
25
+ *
26
+ * ## 关于重放(replay)
27
+ * 本模块**不内置** nonce 去重,只提供 `checkNonce` 供宿主按需注入(回调类通道建议业务侧幂等兜住,
28
+ * 因为「平台正常重投」与「攻击者重放」在字节流上无法区分,最终都只能靠幂等收敛)。
29
+ */
30
+
31
+ /** 签名四要素在**提交数据(JSON body)**里的字段名 */
32
+ export const SIGN_FIELDS = {
33
+ appId: "appId",
34
+ timestamp: "timestamp",
35
+ nonce: "nonce",
36
+ sign: "sign",
37
+ } as const;
38
+
39
+ /** 四项要素的键集合(用于从报文里剔除它们后再算摘要) */
40
+ const SIGN_FIELD_KEYS: ReadonlySet<string> = new Set([
41
+ SIGN_FIELDS.appId,
42
+ SIGN_FIELDS.timestamp,
43
+ SIGN_FIELDS.nonce,
44
+ SIGN_FIELDS.sign,
45
+ ]);
46
+
47
+ /**
48
+ * 剔除报文里的四项签名要素,剩下的才是参与摘要的**纯业务报文**。
49
+ *
50
+ * 必要性:要素要跟业务参数同处一个 body,但 `sign` 自身不可能被自己覆盖(鸡生蛋问题),
51
+ * 且 `timestamp` / `nonce` 已在待签串里单独成行。统一在本函数剥掉,调用方无需自己过滤。
52
+ */
53
+ export function stripSignFields(body: unknown): unknown {
54
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
55
+ const o = body as Record<string, unknown>;
56
+ const out: Record<string, unknown> = {};
57
+ for (const k of Object.keys(o)) {
58
+ if (!SIGN_FIELD_KEYS.has(k)) out[k] = o[k];
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** 默认允许的时钟偏差(毫秒):±5 分钟 */
64
+ export const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
65
+
66
+ /** 签名所需的原始要素 */
67
+ export interface SignatureParts {
68
+ method: string;
69
+ /** 路径(含前缀,不含 query) */
70
+ path: string;
71
+ /** unix 秒 */
72
+ timestamp: number;
73
+ nonce: string;
74
+ /** 已解析的请求体对象 */
75
+ body: unknown;
76
+ }
77
+
78
+ /**
79
+ * 规范化 JSON:对象键按字典序**递归**排序,紧凑输出(无空格)。
80
+ *
81
+ * 这是签名能否验通的关键 —— 若两端各自 `JSON.stringify`,键顺序或空格差异都会导致签名对不上,
82
+ * 因此双方**必须**都用本函数生成待摘要体。
83
+ */
84
+ export function canonicalJson(value: unknown): string {
85
+ return canonicalize(value);
86
+ }
87
+
88
+ function canonicalize(v: unknown): string {
89
+ if (v === undefined) return "null";
90
+ if (v === null) return "null";
91
+ const t = typeof v;
92
+ if (t === "number" || t === "boolean") return String(v);
93
+ if (t === "string") return JSON.stringify(v);
94
+ if (Array.isArray(v)) return `[${v.map(canonicalize).join(",")}]`;
95
+ if (t === "object") {
96
+ const obj = v as Record<string, unknown>;
97
+ const body = Object.keys(obj)
98
+ .sort()
99
+ .map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`)
100
+ .join(",");
101
+ return `{${body}}`;
102
+ }
103
+ // bigint / symbol / function 等:统一退化成字符串,保证两端一致
104
+ return JSON.stringify(String(v));
105
+ }
106
+
107
+ /**
108
+ * 空请求体规范:`undefined` / `null` 一律视为 `{}`。
109
+ *
110
+ * 必要性(踩过的坑):调用方「不传 body」时自己签名用的是 undefined,
111
+ * 而服务端 body parser 解析出来的往往是 `{}` —— 若两边各自原样序列化,
112
+ * `null` 与 `{}` 会算出完全不同的摘要,导致**空 body 的调用永远验签失败**。
113
+ * 因此必须在一个地方统一。
114
+ */
115
+ export function normalizeBody(v: unknown): unknown {
116
+ return v === undefined || v === null ? {} : v;
117
+ }
118
+
119
+ /** 组装待签串:`METHOD\npath\ntimestamp\nnonce\nsha256hex(canonicalJson(纯业务报文))` */
120
+ export function buildSignBase(parts: SignatureParts): string {
121
+ return [
122
+ String(parts.method ?? "").toUpperCase(),
123
+ String(parts.path ?? ""),
124
+ String(parts.timestamp ?? ""),
125
+ String(parts.nonce ?? ""),
126
+ // 剔除四项签名要素后再摘要:报文里带不带它们、带的什么值都不影响结果
127
+ sha256Hex(canonicalJson(normalizeBody(stripSignFields(parts.body)))),
128
+ ].join("\n");
129
+ }
130
+
131
+ /** 计算签名:base64(HMAC-SHA256(待签串, secret)) */
132
+ export function signRequest(secret: string, parts: SignatureParts): string {
133
+ return createHmac("sha256", String(secret ?? "")).update(buildSignBase(parts), "utf8").digest("base64");
134
+ }
135
+
136
+ /** 从提交数据里解析出的四项凭证(method/path/body 需由调用方按当前请求补齐后才可用于验签) */
137
+ export interface SignedFields extends SignatureParts {
138
+ appId: string;
139
+ sign: string;
140
+ }
141
+
142
+ /** 从提交数据(JSON body)里取出四项凭证(缺失/非法返回 null) */
143
+ export function readSignedBody(body: unknown): SignedFields | null {
144
+ const o = (body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>;
145
+ const appId = String(o[SIGN_FIELDS.appId] ?? "").trim();
146
+ const tsRaw = o[SIGN_FIELDS.timestamp];
147
+ const nonce = String(o[SIGN_FIELDS.nonce] ?? "").trim();
148
+ const sign = String(o[SIGN_FIELDS.sign] ?? "").trim();
149
+ if (!appId || !sign) return null;
150
+ const timestamp = Number(tsRaw);
151
+ if (!Number.isFinite(timestamp)) return null;
152
+ return { appId, timestamp, nonce, sign, method: "", path: "", body: undefined };
153
+ }
154
+
155
+ /**
156
+ * 把四项签名要素写进提交数据(调用方 / 调试面板 / 自检脚本的统一出口)。
157
+ *
158
+ * 返回的是「业务报文 + appId/timestamp/nonce/sign」的新对象(不改原对象)。
159
+ * 摘要只覆盖业务报文部分,故先算签名、再把要素并进去,顺序不能反。
160
+ */
161
+ export function attachSignature(
162
+ secret: string,
163
+ parts: SignatureParts & { appId: string },
164
+ ): Record<string, unknown> {
165
+ const payload = (normalizeBody(stripSignFields(parts.body)) ?? {}) as Record<string, unknown>;
166
+ const sign = signRequest(secret, parts);
167
+ return {
168
+ ...payload,
169
+ [SIGN_FIELDS.appId]: String(parts.appId ?? ""),
170
+ [SIGN_FIELDS.timestamp]: Number(parts.timestamp),
171
+ [SIGN_FIELDS.nonce]: String(parts.nonce ?? ""),
172
+ [SIGN_FIELDS.sign]: sign,
173
+ };
174
+ }
175
+
176
+ export interface VerifyInput {
177
+ /** 宿主指定此入口允许的调用方;不配置时按该模块原有协议授权。 */
178
+ allowedAppIds?: readonly string[];
179
+ /** 宿主可要求 nonce 必传,用于内部请求防重放。 */
180
+ requireNonce?: boolean;
181
+ method: string;
182
+ path: string;
183
+ /**
184
+ * 客户端提交的完整 JSON(**含**四项签名要素)。
185
+ * 摘要计算由 `buildSignBase` 内部剔除要素后完成,调用方直接传原样报文即可。
186
+ */
187
+ body: unknown;
188
+ /**
189
+ * 按 appId 取密钥。可返回明文 secret 字符串,或直接返回 OpenSecretBundle(含 secret + 命令白/黑名单);
190
+ * 返回 null 视为未知 appId。推荐返回 bundle,以便一并做命令放通校验。
191
+ */
192
+ resolveSecret: (appId: string) => string | OpenSecretBundle | null | Promise<string | OpenSecretBundle | null>;
193
+ /** 允许的时钟偏差,默认 5 分钟 */
194
+ toleranceMs?: number;
195
+ /**
196
+ * 可选的 nonce 去重:返回 true 表示「这个 nonce 已经用过了」→ 判定为重放。
197
+ * 不传则不防重放(一期默认如此)。
198
+ */
199
+ seenNonce?: (nonce: string) => boolean | Promise<boolean>;
200
+ /** 当前时间(便于测试注入),默认 Date.now() */
201
+ now?: number;
202
+ /**
203
+ * 是否参与签名的 body 必须存在:关闭后允许空 body 调用(默认 false,即允许空 body)。
204
+ * 打开可用于要求调用方必须传 body 的严格场景。
205
+ */
206
+ requireBody?: boolean;
207
+ }
208
+
209
+ export type VerifyResult =
210
+ | { ok: true; appId: string }
211
+ | { ok: false; code: 401 | 403; reason: string };
212
+
213
+ /**
214
+ * 校验一次签名请求。
215
+ *
216
+ * 校验顺序(每一步失败都**立即返回**,不做后续计算):
217
+ * 1. 提交数据里四项要素齐全且 timestamp 是数字
218
+ * 2. 时间窗:|now - timestamp| <= toleranceMs(挡住过期报文)
219
+ * 3. appId 能查到 secret(未知 appId 与「签名错」返回同一文案,避免暴露哪些 appId 存在)
220
+ * 4. nonce 去重(可选)
221
+ * 5. HMAC 比对(定长时间安全比较,防时序侧信道)
222
+ */
223
+ export async function verifySignedRequest(input: VerifyInput): Promise<VerifyResult> {
224
+ const sig = readSignedBody(input.body);
225
+ if (!sig) {
226
+ return { ok: false, code: 401, reason: "缺少签名要素(提交数据需带 appId / timestamp / sign)" };
227
+ }
228
+ if (input.requireNonce && !sig.nonce) return { ok: false, code: 401, reason: "缺少签名要素 nonce" };
229
+ if (input.requireBody && input.body == null) {
230
+ return { ok: false, code: 401, reason: "缺少请求体" };
231
+ }
232
+
233
+ const now = Number.isFinite(input.now) ? Number(input.now) : Date.now();
234
+ const toleranceMs = input.toleranceMs ?? DEFAULT_TOLERANCE_MS;
235
+ if (Math.abs(now - sig.timestamp * 1000) > toleranceMs) {
236
+ return { ok: false, code: 401, reason: "签名已过期或时间戳偏差过大" };
237
+ }
238
+
239
+ let resolved: string | OpenSecretBundle | null = null;
240
+ try {
241
+ resolved = await input.resolveSecret(sig.appId);
242
+ } catch {
243
+ resolved = null;
244
+ }
245
+ const secret = typeof resolved === "string" ? resolved : (resolved?.secret ?? null);
246
+ const whiteList = typeof resolved === "string" ? null : (resolved?.whiteList ?? null);
247
+ const blackList = typeof resolved === "string" ? null : (resolved?.blackList ?? null);
248
+ if (!secret) {
249
+ return { ok: false, code: 401, reason: "签名校验失败:无效的调用凭据" };
250
+ }
251
+
252
+ if (input.seenNonce && sig.nonce) {
253
+ try {
254
+ if (await input.seenNonce(sig.nonce)) {
255
+ return { ok: false, code: 403, reason: "请求已被处理过(重放)" };
256
+ }
257
+ } catch {
258
+ // nonce 存储故障:按「没有记录」处理,宁可放过也不因中间件故障误杀合法调用
259
+ }
260
+ }
261
+
262
+ // 传原样报文即可:buildSignBase 内部会剔除四项要素,与调用方签名时的口径逐字节一致
263
+ const expect = signRequest(secret, {
264
+ method: input.method,
265
+ path: input.path,
266
+ timestamp: sig.timestamp,
267
+ nonce: sig.nonce,
268
+ body: input.body,
269
+ });
270
+
271
+ if (!safeEqual(expect, sig.sign)) {
272
+ return { ok: false, code: 401, reason: "签名校验失败:无效的调用凭据" };
273
+ }
274
+ if (input.allowedAppIds && !input.allowedAppIds.includes(sig.appId)) {
275
+ return { ok: false, code: 403, reason: `调用方未获准访问此入口:appId=${sig.appId}` };
276
+ }
277
+ // 命令白/黑名单放通:合法调用方进一步受限于其白/黑名单。
278
+ const cmd = extractCmd(input.body);
279
+ if (!isCommandAllowed(cmd, whiteList, blackList)) {
280
+ return { ok: false, code: 403, reason: "调用方无权执行该命令(被白/黑名单拒绝)" };
281
+ }
282
+ return { ok: true, appId: sig.appId };
283
+ }
284
+
285
+ /** 定长时间安全比较:避免通过响应耗时逐字节猜签名 */
286
+ function safeEqual(a: string, b: string): boolean {
287
+ const ba = Buffer.from(String(a ?? ""), "utf8");
288
+ const bb = Buffer.from(String(b ?? ""), "utf8");
289
+ if (ba.length === 0 || bb.length === 0 || ba.length !== bb.length) return false;
290
+ try {
291
+ return timingSafeEqual(ba, bb);
292
+ } catch {
293
+ return false;
294
+ }
295
+ }
296
+
297
+ /** 从请求体取出 cmd(开放通道的调用命令,如 "order.list");取不到返回空串 */
298
+ function extractCmd(body: unknown): string {
299
+ if (body && typeof body === "object" && !Array.isArray(body)) {
300
+ const c = (body as Record<string, unknown>).cmd;
301
+ if (typeof c === "string") return c;
302
+ if (typeof c === "number") return String(c);
303
+ }
304
+ return "";
305
+ }
306
+
307
+ /**
308
+ * 命令放通判定(基于开放应用表的 J_白名单 / J_黑名单)。
309
+ * 规则:
310
+ * - 黑名单优先:命中即拒绝(黑名单 "*" 表示全部拒绝);
311
+ * - 白名单为空 / NULL:不限制(全部允许);
312
+ * - 白名单命中(含 "*"):允许;否则拒绝。
313
+ */
314
+ export function isCommandAllowed(
315
+ cmd: string,
316
+ whiteList?: string[] | null,
317
+ blackList?: string[] | null,
318
+ ): boolean {
319
+ if (matchAny(cmd, blackList)) return false; // 黑名单命中即拒绝
320
+ if (!whiteList || whiteList.length === 0) return true; // 白名单空 = 不限制
321
+ return matchAny(cmd, whiteList);
322
+ }
323
+
324
+ function matchAny(cmd: string, list?: string[] | null): boolean {
325
+ if (!list || list.length === 0) return false;
326
+ return list.some((p) => matchPattern(cmd, p));
327
+ }
328
+
329
+ /** 单条模式匹配:支持 "*"(全部)、"prefix.*"(前缀)、精确串 */
330
+ function matchPattern(cmd: string, pattern: string): boolean {
331
+ const p = String(pattern ?? "").trim();
332
+ if (!p) return false;
333
+ if (p === "*") return true;
334
+ if (p.endsWith(".*")) return cmd.startsWith(p.slice(0, -2));
335
+ return cmd === p;
336
+ }
337
+
338
+ /** body 摘要:SHA-256(不是 HMAC —— 摘要不需要密钥,用标准 hash 才能让其它语言原样复现) */
339
+ function sha256Hex(s: string): string {
340
+ return createHash("sha256").update(s, "utf8").digest("hex");
341
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.