Archived
app:60 秒 HMAC 票据 —— 给 EventSource 与 <a href> 下载这两条带不了头的路(ADR-012)
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TICKET_TTL_MS, createTicketIssuer } from '../ticket.ts';
|
||||
|
||||
const issuer = createTicketIssuer(Buffer.alloc(32, 7));
|
||||
const t0 = new Date('2026-08-25T09:00:00Z');
|
||||
|
||||
describe('60 秒票据', () => {
|
||||
it('签出来能验回 sub;形状 v1.<sub>.<exp>.<mac>;到期时刻 = 签发 + 60s', () => {
|
||||
const { ticket, expiresAt } = issuer.issue('sub-1', t0);
|
||||
expect(ticket.split('.')).toHaveLength(4);
|
||||
expect(ticket.startsWith('v1.')).toBe(true);
|
||||
expect(expiresAt.getTime() - t0.getTime()).toBe(TICKET_TTL_MS);
|
||||
expect(issuer.verify(ticket, new Date(t0.getTime() + 30_000))).toEqual({ staffId: 'sub-1' });
|
||||
});
|
||||
|
||||
it('到期即拒(61 秒后)', () => {
|
||||
const { ticket } = issuer.issue('sub-1', t0);
|
||||
expect(issuer.verify(ticket, new Date(t0.getTime() + TICKET_TTL_MS + 1000))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('改一个字就拒:mac / sub / exp', () => {
|
||||
const { ticket } = issuer.issue('sub-1', t0);
|
||||
const [v, id, exp, mac] = ticket.split('.') as [string, string, string, string];
|
||||
expect(issuer.verify(`${v}.${id}.${exp}.${mac.slice(0, -1)}${mac.endsWith('A') ? 'B' : 'A'}`, t0)).toBeUndefined();
|
||||
expect(issuer.verify(`${v}.${Buffer.from('sub-2').toString('base64url')}.${exp}.${mac}`, t0)).toBeUndefined();
|
||||
expect(issuer.verify(`${v}.${id}.${Number(exp) + 600}.${mac}`, t0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('别的密钥签的不认', () => {
|
||||
const other = createTicketIssuer(Buffer.alloc(32, 8));
|
||||
expect(issuer.verify(other.issue('sub-1', t0).ticket, t0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('垃圾输入不抛只拒', () => {
|
||||
expect(issuer.verify('', t0)).toBeUndefined();
|
||||
expect(issuer.verify('v2.a.b.c', t0)).toBeUndefined();
|
||||
expect(issuer.verify('a.b', t0)).toBeUndefined();
|
||||
expect(issuer.verify('v1...', t0)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路:
|
||||
* `EventSource('/api/stream')` 与 `<a href="…&raw=1" download>`。
|
||||
*
|
||||
* 形状 `v1.<sub-b64url>.<expUnix>.<hmac-b64url>`;密钥由 JCAI_SECRET_KEY 经 HKDF 派生(不直接拿主密钥签)。
|
||||
* 票据里**没有权限**:中间件拿 sub 回库现算 principal,停用 / 改角色即时生效。
|
||||
* 无状态、不单次 —— 60 秒可重放窗换零状态表(记 debt);只在 GET 的 ?ticket= 上认,POST 带了也当没带。
|
||||
*/
|
||||
import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export const TICKET_TTL_MS = 60_000;
|
||||
|
||||
export type TicketIssuer = {
|
||||
issue(staffId: string, now: Date): { ticket: string; expiresAt: Date };
|
||||
verify(ticket: string, now: Date): { staffId: string } | undefined;
|
||||
};
|
||||
|
||||
export function createTicketIssuer(masterKey: Buffer): TicketIssuer {
|
||||
const key = Buffer.from(hkdfSync('sha256', masterKey, '', 'jcai-ticket', 32));
|
||||
const sign = (body: string): string => createHmac('sha256', key).update(body).digest('base64url');
|
||||
|
||||
return {
|
||||
issue(staffId, now) {
|
||||
const expUnix = Math.floor((now.getTime() + TICKET_TTL_MS) / 1000);
|
||||
const body = `v1.${Buffer.from(staffId, 'utf8').toString('base64url')}.${expUnix}`;
|
||||
return { ticket: `${body}.${sign(body)}`, expiresAt: new Date(expUnix * 1000) };
|
||||
},
|
||||
verify(ticket, now) {
|
||||
const parts = ticket.split('.');
|
||||
if (parts.length !== 4) return undefined;
|
||||
const [version, idRaw, expRaw, mac] = parts;
|
||||
if (version !== 'v1' || idRaw === undefined || idRaw === '' || expRaw === undefined || mac === undefined) return undefined;
|
||||
const expected = sign(`${version}.${idRaw}.${expRaw}`);
|
||||
const given = Buffer.from(mac, 'utf8');
|
||||
const wanted = Buffer.from(expected, 'utf8');
|
||||
if (given.length !== wanted.length || !timingSafeEqual(given, wanted)) return undefined;
|
||||
const expUnix = Number(expRaw);
|
||||
if (!Number.isInteger(expUnix) || expUnix * 1000 <= now.getTime()) return undefined;
|
||||
const staffId = Buffer.from(idRaw, 'base64url').toString('utf8');
|
||||
return staffId === '' ? undefined : { staffId };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user