From c2c9657b4ddc2aa2aa31073c01c2eefff6e20b4d Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 22 May 2026 08:59:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(mp):=20S3-1=20API=20=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E5=B7=A5=E5=85=B7=EF=BC=88=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=EF=BC=8C=E5=BE=85=E5=90=8E=E7=AB=AF=E9=9B=86=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 request-signer.ts:HMAC-SHA256 签名 + nonce + timestamp - 使用 @noble/hashes v1 纯 JS 实现(小程序无 crypto.subtle) - 签名密钥仅存内存(setSigningKey/clearSigningKey) - 8 个单元测试覆盖签名生成 + nonce + HMAC - 集成到 request.ts 待后端 signing_key 支持后启用 --- .../__tests__/utils/request-signer.test.ts | 66 +++++++++++++++++++ apps/miniprogram/package.json | 23 +++---- apps/miniprogram/pnpm-lock.yaml | 9 +++ apps/miniprogram/src/utils/request-signer.ts | 65 ++++++++++++++++++ 4 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 apps/miniprogram/__tests__/utils/request-signer.test.ts create mode 100644 apps/miniprogram/src/utils/request-signer.ts diff --git a/apps/miniprogram/__tests__/utils/request-signer.test.ts b/apps/miniprogram/__tests__/utils/request-signer.test.ts new file mode 100644 index 0000000..5030196 --- /dev/null +++ b/apps/miniprogram/__tests__/utils/request-signer.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { signRequest, generateNonce, hmacSha256Sync } from '@/utils/request-signer'; + +describe('generateNonce', () => { + it('生成 16 字符十六进制字符串', () => { + const nonce = generateNonce(); + expect(nonce).toHaveLength(16); + expect(nonce).toMatch(/^[0-9a-f]{16}$/); + }); + + it('连续调用生成不同值', () => { + const n1 = generateNonce(); + const n2 = generateNonce(); + expect(n1).not.toBe(n2); + }); +}); + +describe('hmacSha256Sync', () => { + it('相同输入产生相同输出', () => { + const key = 'test-key'; + const msg = 'hello world'; + const r1 = hmacSha256Sync(key, msg); + const r2 = hmacSha256Sync(key, msg); + expect(r1).toBe(r2); + expect(r1).toHaveLength(64); // SHA-256 = 32 bytes = 64 hex chars + }); + + it('不同输入产生不同输出', () => { + const r1 = hmacSha256Sync('key', 'msg1'); + const r2 = hmacSha256Sync('key', 'msg2'); + expect(r1).not.toBe(r2); + }); +}); + +describe('signRequest', () => { + const signingKey = 'test-signing-key-256-bit!!!!!!!!!!!'; + + it('GET 请求生成正确的签名头', () => { + const headers = signRequest('GET', '/health/patients', undefined, signingKey); + + expect(headers).toHaveProperty('X-Signature'); + expect(headers).toHaveProperty('X-Timestamp'); + expect(headers).toHaveProperty('X-Nonce'); + expect(headers['X-Nonce']).toHaveLength(16); + expect(headers['X-Signature']).toHaveLength(64); + }); + + it('POST 请求包含 body hash', () => { + const headers = signRequest('POST', '/health/vital-signs', { value: 120 }, signingKey); + + expect(headers).toHaveProperty('X-Signature'); + expect(headers['X-Signature']).toHaveLength(64); + }); + + it('无 body 时也能正常签名', () => { + const headers = signRequest('GET', '/test', undefined, signingKey); + expect(headers['X-Signature']).toBeTruthy(); + }); + + it('相同参数不同 nonce 产生不同签名', () => { + const h1 = signRequest('GET', '/test', undefined, signingKey); + // 由于 nonce 不同,签名也不同 + const h2 = signRequest('GET', '/test', undefined, signingKey); + expect(h1['X-Signature']).not.toBe(h2['X-Signature']); + }); +}); diff --git a/apps/miniprogram/package.json b/apps/miniprogram/package.json index 2406556..0e84891 100644 --- a/apps/miniprogram/package.json +++ b/apps/miniprogram/package.json @@ -22,6 +22,8 @@ "ios >= 8" ], "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.8.0", "@tarojs/components": "4.2.0", "@tarojs/helper": "4.2.0", "@tarojs/plugin-framework-react": "4.2.0", @@ -32,34 +34,33 @@ "@tarojs/taro": "4.2.0", "react": "^18.3.0", "react-dom": "18.3.1", - "zustand": "^5.0.0", - "@noble/ciphers": "^1.0.0" + "zustand": "^5.0.0" }, "devDependencies": { "@babel/preset-env": "^7.29.2", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@babel/runtime": "^7.27.0", + "@eslint/js": "^9.0.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", "@tarojs/cli": "4.2.0", "@tarojs/plugin-platform-h5": "^4.2.0", "@tarojs/webpack5-runner": "4.2.0", + "@types/node": "^22.0.0", "@types/react": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "babel-preset-taro": "^4.2.0", "dotenv-cli": "^11.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^10.0.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.0", "miniprogram-automator": "^0.12.1", + "prettier": "^3.0.0", "react-refresh": "^0.14.0", "sass": "^1.87.0", "typescript": "^5.8.0", - "@types/node": "^22.0.0", - "eslint": "^9.0.0", - "@eslint/js": "^9.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.0", - "prettier": "^3.0.0", - "eslint-config-prettier": "^10.0.0", "vite": "^8.0.10", "vitest": "^4.1.5", "webpack": "~5.95.0" diff --git a/apps/miniprogram/pnpm-lock.yaml b/apps/miniprogram/pnpm-lock.yaml index 9a0ee9d..19bcf03 100644 --- a/apps/miniprogram/pnpm-lock.yaml +++ b/apps/miniprogram/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@noble/ciphers': specifier: ^1.0.0 version: 1.3.0 + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 '@tarojs/components': specifier: 4.2.0 version: 4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)) @@ -1323,6 +1326,10 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -7558,6 +7565,8 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/apps/miniprogram/src/utils/request-signer.ts b/apps/miniprogram/src/utils/request-signer.ts new file mode 100644 index 0000000..09a3e18 --- /dev/null +++ b/apps/miniprogram/src/utils/request-signer.ts @@ -0,0 +1,65 @@ +import { hmac } from '@noble/hashes/hmac'; +import { sha256 } from '@noble/hashes/sha256'; + +/** HMAC-SHA256 同步签名,返回十六进制字符串 */ +export function hmacSha256Sync(key: string, message: string): string { + const encoder = new TextEncoder(); + const mac = hmac(sha256, encoder.encode(key), encoder.encode(message)); + return Array.from(mac) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** 生成 16 字符随机 nonce(十六进制) */ +export function generateNonce(): string { + const arr = new Uint8Array(8); + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(arr); + } else { + for (let i = 0; i < arr.length; i++) { + arr[i] = Math.floor(Math.random() * 256); + } + } + return Array.from(arr) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** 为 API 请求生成 HMAC-SHA256 签名头 */ +export function signRequest( + method: string, + path: string, + body: unknown, + signingKey: string, +): Record { + const timestamp = String(Math.floor(Date.now() / 1000)); + const nonce = generateNonce(); + + const bodyStr = body !== undefined ? JSON.stringify(body) : ''; + const bodyHash = bodyStr ? hmacSha256Sync(signingKey, bodyStr) : ''; + + const message = `${method.toUpperCase()}${path}${bodyHash}${timestamp}${nonce}`; + const signature = hmacSha256Sync(signingKey, message); + + return { + 'X-Signature': signature, + 'X-Timestamp': timestamp, + 'X-Nonce': nonce, + }; +} + +// ─── 签名密钥管理(仅内存,不持久化) ─── + +let _signingKey = ''; + +export function setSigningKey(key: string): void { + _signingKey = key; +} + +export function clearSigningKey(): void { + _signingKey = ''; +} + +export function getSigningKey(): string { + return _signingKey; +}