feat(mp): S3-1 API 请求签名工具(前端,待后端集成)
- 新增 request-signer.ts:HMAC-SHA256 签名 + nonce + timestamp - 使用 @noble/hashes v1 纯 JS 实现(小程序无 crypto.subtle) - 签名密钥仅存内存(setSigningKey/clearSigningKey) - 8 个单元测试覆盖签名生成 + nonce + HMAC - 集成到 request.ts 待后端 signing_key 支持后启用
This commit is contained in:
66
apps/miniprogram/__tests__/utils/request-signer.test.ts
Normal file
66
apps/miniprogram/__tests__/utils/request-signer.test.ts
Normal file
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
"ios >= 8"
|
"ios >= 8"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "^1.0.0",
|
||||||
|
"@noble/hashes": "^1.8.0",
|
||||||
"@tarojs/components": "4.2.0",
|
"@tarojs/components": "4.2.0",
|
||||||
"@tarojs/helper": "4.2.0",
|
"@tarojs/helper": "4.2.0",
|
||||||
"@tarojs/plugin-framework-react": "4.2.0",
|
"@tarojs/plugin-framework-react": "4.2.0",
|
||||||
@@ -32,34 +34,33 @@
|
|||||||
"@tarojs/taro": "4.2.0",
|
"@tarojs/taro": "4.2.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"zustand": "^5.0.0",
|
"zustand": "^5.0.0"
|
||||||
"@noble/ciphers": "^1.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-env": "^7.29.2",
|
"@babel/preset-env": "^7.29.2",
|
||||||
"@babel/preset-react": "^7.28.5",
|
"@babel/preset-react": "^7.28.5",
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
"@babel/runtime": "^7.27.0",
|
"@babel/runtime": "^7.27.0",
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||||
"@tarojs/cli": "4.2.0",
|
"@tarojs/cli": "4.2.0",
|
||||||
"@tarojs/plugin-platform-h5": "^4.2.0",
|
"@tarojs/plugin-platform-h5": "^4.2.0",
|
||||||
"@tarojs/webpack5-runner": "4.2.0",
|
"@tarojs/webpack5-runner": "4.2.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^18.3.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",
|
"babel-preset-taro": "^4.2.0",
|
||||||
"dotenv-cli": "^11.0.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",
|
"miniprogram-automator": "^0.12.1",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
"react-refresh": "^0.14.0",
|
"react-refresh": "^0.14.0",
|
||||||
"sass": "^1.87.0",
|
"sass": "^1.87.0",
|
||||||
"typescript": "^5.8.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",
|
"vite": "^8.0.10",
|
||||||
"vitest": "^4.1.5",
|
"vitest": "^4.1.5",
|
||||||
"webpack": "~5.95.0"
|
"webpack": "~5.95.0"
|
||||||
|
|||||||
9
apps/miniprogram/pnpm-lock.yaml
generated
9
apps/miniprogram/pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@noble/ciphers':
|
'@noble/ciphers':
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.3.0
|
version: 1.3.0
|
||||||
|
'@noble/hashes':
|
||||||
|
specifier: ^1.8.0
|
||||||
|
version: 1.8.0
|
||||||
'@tarojs/components':
|
'@tarojs/components':
|
||||||
specifier: 4.2.0
|
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))
|
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==}
|
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
|
||||||
engines: {node: ^14.21.3 || >=16}
|
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':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -7558,6 +7565,8 @@ snapshots:
|
|||||||
|
|
||||||
'@noble/ciphers@1.3.0': {}
|
'@noble/ciphers@1.3.0': {}
|
||||||
|
|
||||||
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
|
|||||||
65
apps/miniprogram/src/utils/request-signer.ts
Normal file
65
apps/miniprogram/src/utils/request-signer.ts
Normal file
@@ -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<string, string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user