feat: simplify HTTP request signing and publish revised client guide
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-15 14:58:31 +08:00
parent bcb278be29
commit 18ecf8045f
12 changed files with 1006 additions and 851 deletions
+2 -1
View File
@@ -58,7 +58,8 @@ export function renderHttpGuide(markdown: string, origin: string) {
continue;
}
closeTable();
if (line.trim().endsWith('') && line.trim().length < 70) sampleTitle = line.trim().replace(/$/, '');
const caption = line.trim().replace(/^\*\*(.+)\*\*$/, '$1');
if (caption.endsWith('') && caption.length < 70) sampleTitle = caption.replace(/$/, '');
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
}
closeTable();
+2 -3
View File
@@ -15,7 +15,7 @@ import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types';
import { openApiBodyHash, openApiSignature, publicOpenApiFailure } from './open-api.protocol';
import { openApiSignature, publicOpenApiFailure } from './open-api.protocol';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
@@ -97,14 +97,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
}
const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
const bodyHash = openApiBodyHash(request.rawBody, request.body);
const expected = openApiSignature(
decryptSecret(credential.secretEncrypted),
request.method,
path,
timestampText,
nonce,
bodyHash,
request.rawBody,
);
const expectedBuffer = Buffer.from(expected, 'hex');
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature)
+8 -22
View File
@@ -20,37 +20,23 @@ describe('HTTP API remediation boundaries', () => {
'/api/openapi/v1/sms/uplinks',
'1789344000',
'550e8400-e29b-41d4-a716-446655440000',
openApiBodyHash(undefined, undefined),
undefined,
),
).toBe('f551ad48ea2a16762b0144f0f0d6e9110c1732adc003fcb94658e5333116eb65');
).toBe('3db9c015c2b1c5365a0ef296a79b419653b0087daed2792cdb5717b0802eec51');
});
it('keeps GET absent-body compatibility and signs exact POST UTF8 bytes', () => {
it('preserves internal idempotency hashes but signs exact POST UTF8 bytes', () => {
expect(openApiBodyHash(undefined, undefined)).toBe(
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a',
);
const raw = Buffer.from('{ "content": "中文\\n正文" }');
expect(openApiBodyHash(raw, {})).toBe(createHash('sha256').update(raw).digest('hex'));
const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', openApiBodyHash(raw, {})].join('\n');
const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw.toString('utf8')].join('\n');
expect(
openApiSignature(
'offline-secret',
'post',
'/api/openapi/v1/sms/messages?ignored=1',
'123',
'nonce-0001',
openApiBodyHash(raw, {}),
),
openApiSignature('offline-secret', 'post', '/api/openapi/v1/sms/messages?ignored=1', '123', 'nonce-0001', raw),
).toBe(createHmac('sha256', 'offline-secret').update(source).digest('hex'));
for (const separator of ['\r\n', '\\n'])
expect(createHmac('sha256', 'offline-secret').update(source.split('\n').join(separator)).digest('hex')).not.toBe(
openApiSignature(
'offline-secret',
'POST',
'/api/openapi/v1/sms/messages',
'123',
'nonce-0001',
openApiBodyHash(raw, {}),
),
openApiSignature('offline-secret', 'POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw),
);
});
@@ -169,11 +155,11 @@ describe('HTTP API remediation boundaries', () => {
});
it('keeps named code examples beside their source paragraphs', () => {
const html = renderHttpGuide(
'**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n五行原文:\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```',
'**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n**签名原文:**\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```',
'',
);
expect(html.indexOf('sample-1')).toBeLessThan(html.indexOf('后续说明'));
expect(html).toContain('五行原文 · text');
expect(html).toContain('签名原文 · text');
expect(html).not.toContain('data-show-sample');
expect(html).not.toContain('<aside');
expect(html).not.toMatch(/>示例 \d+</);
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { runInNewContext } from 'node:vm';
import { createHash, createHmac } from 'node:crypto';
import { openApiSignature } from './open-api.protocol';
describe('raw-body request signing contract', () => {
const path = '/api/openapi/v1/sms/messages';
const nonce = '7921b5d1-3b99-48d4-a068-ea7cf0c998db';
const body = Buffer.from(
'{"mobile":"13800138000","content":"【示例签名】您的验证码是1234565分钟内有效。","clientMessageId":"doc-example-20260914-0001"}',
);
const secret = 'DEMO_SECRET_NOT_A_REAL_CREDENTIAL';
const sign = (raw: Buffer) => openApiSignature(secret, 'POST', path, '1789355443', nonce, raw);
it('matches the independently computed published POST vector', () => {
expect(sign(body)).toBe('a951451624d37d3e9df24045dc65d94557551dcbeada26988e25b6d49e945124');
});
it('does not accept legacy body digests or changed body bytes', () => {
const legacy = createHmac('sha256', secret)
.update(['POST', path, '1789355443', nonce, createHash('sha256').update(body).digest('hex')].join('\n'))
.digest('hex');
expect(sign(body)).not.toBe(legacy);
for (const changed of [
Buffer.concat([body, Buffer.from('\n')]),
Buffer.from(JSON.stringify(JSON.parse(body.toString()), null, 2)),
Buffer.from(body.toString().replace('123456', '654321')),
]) {
expect(sign(changed)).not.toBe(sign(body));
}
});
it('rejects missing POST raw bytes instead of reconstructing JSON', () => {
expect(() => openApiSignature(secret, 'POST', path, '123', nonce)).toThrow('缺少原始请求体');
});
it('rejects nonempty GET bodies and distinguishes a trailing LF', () => {
const fields = ['GET', '/api/openapi/v1/sms/uplinks', '123', nonce];
const actual = openApiSignature(secret, fields[0], fields[1], fields[2], fields[3]);
expect(actual).toBe(createHmac('sha256', secret).update(fields.join('\n')).digest('hex'));
expect(actual).not.toBe(
createHmac('sha256', secret)
.update(fields.join('\n') + '\n')
.digest('hex'),
);
expect(() => openApiSignature(secret, 'GET', path, '123', nonce, Buffer.from('{}'))).toThrow('GET请求不得携带正文');
});
it('executes both handbook examples and verifies every complete request packet', () => {
const guide = readFileSync(resolve(__dirname, '../../../docs/client-http-api-guide.md'), 'utf8').replace(
/\r\n/g,
'\n',
);
expect(guide).toContain('### 1.4 怎样使用后面的 cURL 示例');
expect(guide).not.toContain('### 2.4');
const scripts = [...guide.matchAll(/```javascript\n([\s\S]*?)\n```/g)];
expect(scripts).toHaveLength(2);
for (const script of scripts) {
const outputs: string[] = [];
runInNewContext(script[1], {
Buffer,
require: () => ({ createHmac }),
console: { log: (value: string) => outputs.push(value) },
});
expect(outputs).toHaveLength(1);
expect(guide).toContain(outputs[0]);
}
const packets = [...guide.matchAll(/```http\n((?:GET|POST) \/api\/openapi\/[\s\S]*?)\n```/g)];
expect(packets).toHaveLength(5);
for (const [, packet] of packets) {
const split = packet.indexOf('\n\n');
const headers = packet.slice(0, split);
const [method, url] = headers.split('\n')[0].split(' ');
const header = (name: string) => headers.match(new RegExp('^' + name + ': (.+)$', 'm'))![1];
const raw = method === 'POST' ? Buffer.from(packet.slice(split + 2)) : undefined;
if (raw) expect(raw.length).toBe(Number(header('Content-Length')));
expect(openApiSignature(secret, method, url, header('X-Timestamp'), header('X-Nonce'), raw)).toBe(
header('X-Signature'),
);
}
});
});
+6 -1
View File
@@ -37,7 +37,12 @@ import {
@ApiHeader({ name: 'X-App-Key', required: true })
@ApiHeader({ name: 'X-Timestamp', required: true })
@ApiHeader({ name: 'X-Nonce', required: true })
@ApiHeader({ name: 'X-Signature', required: true })
@ApiHeader({
name: 'X-Signature',
required: true,
description:
'HMAC-SHA256小写十六进制。方法、路径(不含query)、时间戳、nonce以LF分隔;GET末尾无LFPOST追加LF及原始UTF-8正文,不计算正文摘要。',
})
@ApiResponse({ status: 400, type: OpenApiProblemDto })
@ApiResponse({ status: 401, type: OpenApiProblemDto })
@ApiResponse({ status: 403, type: OpenApiProblemDto })
+15 -5
View File
@@ -64,7 +64,7 @@ export function sendOpenApiProblem(
});
}
/** v1 compatibility: an absent parsed body hashes as {}, never try alternate hashes. */
/** Internal idempotency fingerprint; this digest is not part of request authentication. */
export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) {
return createHash('sha256')
.update(rawBody ?? Buffer.from(JSON.stringify(body ?? {})))
@@ -77,11 +77,21 @@ export function openApiSignature(
path: string,
timestamp: string,
nonce: string,
bodyHash: string,
rawBody?: Buffer,
) {
return createHmac('sha256', secret)
.update([method.toUpperCase(), path.split('?')[0], timestamp, nonce, bodyHash].join('\n'))
.digest('hex');
const verb = method.toUpperCase();
if (verb === 'GET' && rawBody?.length) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'GET请求不得携带正文' });
}
if (verb !== 'GET' && !rawBody) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '缺少原始请求体' });
}
const signature = createHmac('sha256', secret).update(
[verb, path.split('?')[0], timestamp, nonce].join('\n'),
'utf8',
);
if (verb !== 'GET') signature.update('\n').update(rawBody!);
return signature.digest('hex');
}
export function publicOpenApiFailure(error: unknown) {