feat: simplify HTTP request signing and publish revised client guide
CSS quality / css-quality (push) Has been cancelled
CSS quality / css-quality (push) Has been cancelled
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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":"【示例签名】您的验证码是123456,5分钟内有效。","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'),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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末尾无LF,POST追加LF及原始UTF-8正文,不计算正文摘要。',
|
||||
})
|
||||
@ApiResponse({ status: 400, type: OpenApiProblemDto })
|
||||
@ApiResponse({ status: 401, type: OpenApiProblemDto })
|
||||
@ApiResponse({ status: 403, type: OpenApiProblemDto })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user