diff --git a/apps/api/src/app/__tests__/ticket.test.ts b/apps/api/src/app/__tests__/ticket.test.ts new file mode 100644 index 0000000..e8eed5c --- /dev/null +++ b/apps/api/src/app/__tests__/ticket.test.ts @@ -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...;到期时刻 = 签发 + 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(); + }); +}); diff --git a/apps/api/src/app/ticket.ts b/apps/api/src/app/ticket.ts new file mode 100644 index 0000000..c04d141 --- /dev/null +++ b/apps/api/src/app/ticket.ts @@ -0,0 +1,43 @@ +/** + * 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路: + * `EventSource('/api/stream')` 与 ``。 + * + * 形状 `v1...`;密钥由 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 }; + }, + }; +}