From 0bf33c55edfe3ff98da15513bf016ab4919f8e2e Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Tue, 15 Sep 2026 14:27:31 +0530 Subject: [PATCH] Use crypto RNG for ticket IDs so harvestable display IDs resist enumeration --- .../shared/src/__tests__/utils.test.ts | 35 +++++++++++++++++++ packages/outpost/shared/src/utils.ts | 7 +++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/outpost/shared/src/__tests__/utils.test.ts b/packages/outpost/shared/src/__tests__/utils.test.ts index 65893345..24ca229f 100644 --- a/packages/outpost/shared/src/__tests__/utils.test.ts +++ b/packages/outpost/shared/src/__tests__/utils.test.ts @@ -31,6 +31,41 @@ describe('generateTicketId', () => { } expect(ids.size).toBe(100); }); + + it('does not depend on Math.random (uses a CSPRNG)', () => { + // Pin Math.random to a constant: a Math.random-based implementation + // would then emit the same ID every time. + vi.spyOn(Math, 'random').mockReturnValue(0.5); + try { + const ids = new Set(); + for (let i = 0; i < 50; i++) { + ids.add(generateTicketId()); + } + expect(ids.size).toBeGreaterThan(1); + } finally { + vi.restoreAllMocks(); + } + }); + + it('produces unique IDs across 2000 calls', () => { + const ids = new Set(); + for (let i = 0; i < 2000; i++) { + ids.add(generateTicketId()); + } + // 32^8 keyspace: collisions at 2000 draws are ~1 in a million; + // a failure here almost certainly means broken randomness. + expect(ids.size).toBe(2000); + }); + + it('covers the full alphabet over many draws', () => { + const seen = new Set(); + for (let i = 0; i < 2000; i++) { + for (const ch of generateTicketId().replace('TKT-', '')) { + seen.add(ch); + } + } + expect(seen.size).toBe(32); + }); }); describe('formatDuration', () => { diff --git a/packages/outpost/shared/src/utils.ts b/packages/outpost/shared/src/utils.ts index cd8b56a6..b2cbdcea 100644 --- a/packages/outpost/shared/src/utils.ts +++ b/packages/outpost/shared/src/utils.ts @@ -1,15 +1,20 @@ +import { randomInt } from 'node:crypto'; import { TICKET_ID_PREFIX, BACKOFF_BASE_MS, BACKOFF_MAX_MS } from './constants.js'; /** * Generate a unique ticket ID in the format TKT-XXXXXXXX. * Uses 8 random characters from a 32-char alphabet (~1.1 trillion keyspace) * to make collisions negligible at scale. + * + * Drawn from `crypto.randomInt` (CSPRNG): display IDs are pasted into public + * threads and are therefore harvestable, so a non-crypto RNG would let an + * attacker shrink the search space for ID enumeration. */ export function generateTicketId(): string { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Omit ambiguous chars let id = ''; for (let i = 0; i < 8; i++) { - id += chars[Math.floor(Math.random() * chars.length)]; + id += chars[randomInt(chars.length)]; } return `${TICKET_ID_PREFIX}-${id}`; }