Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/outpost/shared/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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<string>();
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<string>();
for (let i = 0; i < 2000; i++) {
for (const ch of generateTicketId().replace('TKT-', '')) {
seen.add(ch);
}
}
expect(seen.size).toBe(32);
});
});

describe('formatDuration', () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/outpost/shared/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Expand Down