diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-challenge-registry.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-challenge-registry.spec.ts new file mode 100644 index 000000000..bfa6764b7 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-challenge-registry.spec.ts @@ -0,0 +1,212 @@ +import { StalkerChallengeRegistry } from './stalker-challenge-registry'; + +describe('StalkerChallengeRegistry', () => { + let now: number; + let registry: StalkerChallengeRegistry; + + beforeEach(() => { + now = Date.parse('2026-07-27T12:00:00.000Z'); + registry = new StalkerChallengeRegistry({ now: () => now }); + }); + + it('creates cryptographically opaque origin and credential references', () => { + const originRef = registry.issue(41, 'attempt-secret', { + finalOrigin: 'https://redirect.example', + kind: 'origin-approval', + landingPath: '/customer/c/', + sourceOrigin: 'https://source.example', + }); + const credentialRef = registry.issue(41, 'attempt-secret', { + attemptNumber: 1, + constrainedUsername: 'account-secret', + kind: 'credentials', + }); + + expect(originRef).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(credentialRef).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(originRef).not.toBe(credentialRef); + for (const fragment of [ + '41', + 'attempt', + 'redirect', + 'customer', + 'account', + ]) { + expect(originRef).not.toContain(fragment); + expect(credentialRef).not.toContain(fragment); + } + }); + + it('binds a challenge to its renderer without letting a foreign renderer consume it', () => { + const ref = registry.issue(41, 'attempt-a', { + attemptNumber: 1, + kind: 'credentials', + }); + + expect( + registry.consume({ + attemptRef: 'attempt-a', + challengeRef: ref, + responseKind: 'credentials', + senderId: 42, + }) + ).toEqual({ kind: 'invalid' }); + expect( + registry.consume({ + attemptRef: 'attempt-a', + challengeRef: ref, + responseKind: 'credentials', + senderId: 41, + }) + ).toEqual({ + challenge: { + attemptNumber: 1, + kind: 'credentials', + }, + kind: 'consumed', + }); + }); + + it('binds a challenge to its exact connection attempt', () => { + const ref = registry.issue(41, 'attempt-a', { + attemptNumber: 1, + kind: 'credentials', + }); + + expect( + registry.consume({ + attemptRef: 'attempt-b', + challengeRef: ref, + responseKind: 'credentials', + senderId: 41, + }) + ).toEqual({ kind: 'invalid' }); + expect( + registry.consume({ + attemptRef: 'attempt-a', + challengeRef: ref, + responseKind: 'credentials', + senderId: 41, + }).kind + ).toBe('consumed'); + }); + + it('requires the response kind issued for the challenge', () => { + const ref = registry.issue(41, 'attempt-a', { + finalOrigin: 'https://redirect.example', + kind: 'origin-approval', + landingPath: '/c/', + sourceOrigin: 'https://source.example', + }); + + expect( + registry.consume({ + attemptRef: 'attempt-a', + challengeRef: ref, + responseKind: 'credentials', + senderId: 41, + }) + ).toEqual({ kind: 'invalid' }); + expect( + registry.consume({ + attemptRef: 'attempt-a', + challengeRef: ref, + responseKind: 'origin-approval', + senderId: 41, + }).kind + ).toBe('consumed'); + }); + + it('consumes a challenge exactly once', () => { + const ref = registry.issue(41, 'attempt-a', { + attemptNumber: 1, + kind: 'credentials', + }); + const request = { + attemptRef: 'attempt-a', + challengeRef: ref, + responseKind: 'credentials' as const, + senderId: 41, + }; + + expect(registry.consume(request).kind).toBe('consumed'); + expect(registry.consume(request)).toEqual({ kind: 'invalid' }); + }); + + it('expires a challenge after two minutes using the injected clock', () => { + const ref = registry.issue(41, 'attempt-a', { + attemptNumber: 1, + kind: 'credentials', + }); + + now += 120_000; + + expect( + registry.consume({ + attemptRef: 'attempt-a', + challengeRef: ref, + responseKind: 'credentials', + senderId: 41, + }) + ).toEqual({ kind: 'invalid' }); + expect(registry.size).toBe(0); + }); + + it('invalidates every challenge when an attempt terminates', () => { + const first = registry.issue(41, 'attempt-a', { + attemptNumber: 1, + kind: 'credentials', + }); + registry.issue(41, 'attempt-a', { + finalOrigin: 'https://redirect.example', + kind: 'origin-approval', + landingPath: '/c/', + sourceOrigin: 'https://source.example', + }); + registry.issue(41, 'attempt-b', { + attemptNumber: 1, + kind: 'credentials', + }); + + registry.invalidateAttempt(41, 'attempt-a'); + + expect( + registry.consume({ + attemptRef: 'attempt-a', + challengeRef: first, + responseKind: 'credentials', + senderId: 41, + }) + ).toEqual({ kind: 'invalid' }); + expect(registry.size).toBe(1); + }); + + it('invalidates all challenges owned by a destroyed renderer', () => { + registry.issue(41, 'attempt-a', { + attemptNumber: 1, + kind: 'credentials', + }); + registry.issue(41, 'attempt-b', { + attemptNumber: 2, + kind: 'credentials', + }); + registry.issue(42, 'attempt-c', { + attemptNumber: 1, + kind: 'credentials', + }); + + registry.invalidateSender(41); + + expect(registry.size).toBe(1); + }); + + it('does not expose enumerable challenge state', () => { + registry.issue(41, 'attempt-a', { + attemptNumber: 1, + kind: 'credentials', + }); + + expect(JSON.stringify(registry)).toBe('{}'); + expect(Object.keys(registry)).toEqual([]); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-challenge-registry.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-challenge-registry.ts new file mode 100644 index 000000000..c482200c0 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-challenge-registry.ts @@ -0,0 +1,253 @@ +import { randomBytes } from 'node:crypto'; + +const CHALLENGE_TTL_MS = 120_000; +const CHALLENGE_RANDOM_BYTES = 32; +const MAX_ACTIVE_CHALLENGES = 4_096; +const MAX_REFERENCE_GENERATION_ATTEMPTS = 4; + +export interface StalkerOriginApprovalChallenge { + kind: 'origin-approval'; + sourceOrigin: string; + finalOrigin: string; + landingPath: string; +} + +export interface StalkerCredentialsChallenge { + kind: 'credentials'; + attemptNumber: number; + constrainedUsername?: string; + savedCredentialsRejected?: boolean; +} + +export type StalkerChallenge = + | StalkerOriginApprovalChallenge + | StalkerCredentialsChallenge; + +export interface StalkerChallengeConsumeRequest { + senderId: number; + attemptRef: string; + challengeRef: string; + responseKind: StalkerChallenge['kind']; +} + +export type StalkerChallengeConsumeResult = + | { + kind: 'consumed'; + challenge: StalkerChallenge; + } + | { kind: 'invalid' }; + +interface StalkerChallengeRecord { + senderId: number; + attemptRef: string; + expiresAt: number; + challenge: StalkerChallenge; +} + +export interface StalkerChallengeRegistryOptions { + now?: () => number; + random?: (size: number) => Buffer; +} + +/** + * Owns opaque one-shot continuation capabilities in Electron main. + * + * No reference or challenge payload is enumerable or serializable. Callers + * should return only the generated reference through the typed IPC boundary. + */ +export class StalkerChallengeRegistry { + readonly #records = new Map(); + readonly #attempts = new Map>>(); + readonly #senderRefs = new Map>(); + readonly #now: () => number; + readonly #random: (size: number) => Buffer; + + constructor(options: StalkerChallengeRegistryOptions = {}) { + this.#now = options.now ?? Date.now; + this.#random = options.random ?? randomBytes; + } + + get size(): number { + this.#pruneExpired(); + return this.#records.size; + } + + issue( + senderId: number, + attemptRef: string, + challenge: StalkerChallenge + ): string { + validateOwner(senderId, attemptRef); + this.#pruneExpired(); + if (this.#records.size >= MAX_ACTIVE_CHALLENGES) { + throw new Error('stalker-challenge-capacity-exceeded'); + } + + const challengeRef = this.#createUniqueReference(); + this.#records.set(challengeRef, { + attemptRef, + challenge: cloneChallenge(challenge), + expiresAt: this.#now() + CHALLENGE_TTL_MS, + senderId, + }); + addReference( + getOrCreateSet( + getOrCreateMap(this.#attempts, senderId), + attemptRef + ), + challengeRef + ); + addReference( + getOrCreateSet(this.#senderRefs, senderId), + challengeRef + ); + return challengeRef; + } + + consume( + request: StalkerChallengeConsumeRequest + ): StalkerChallengeConsumeResult { + const record = this.#records.get(request.challengeRef); + if (!record) { + return { kind: 'invalid' }; + } + if (record.expiresAt <= this.#now()) { + this.#deleteReference(request.challengeRef, record); + return { kind: 'invalid' }; + } + if ( + record.senderId !== request.senderId || + record.attemptRef !== request.attemptRef || + record.challenge.kind !== request.responseKind + ) { + return { kind: 'invalid' }; + } + + this.#deleteReference(request.challengeRef, record); + return { + challenge: cloneChallenge(record.challenge), + kind: 'consumed', + }; + } + + invalidateAttempt(senderId: number, attemptRef: string): void { + const senderAttempts = this.#attempts.get(senderId); + const refs = senderAttempts?.get(attemptRef); + if (!refs) { + return; + } + + for (const ref of [...refs]) { + const record = this.#records.get(ref); + if (record) { + this.#deleteReference(ref, record); + } + } + } + + invalidateSender(senderId: number): void { + const refs = this.#senderRefs.get(senderId); + if (!refs) { + return; + } + + for (const ref of [...refs]) { + const record = this.#records.get(ref); + if (record) { + this.#deleteReference(ref, record); + } + } + } + + #createUniqueReference(): string { + for ( + let attempt = 0; + attempt < MAX_REFERENCE_GENERATION_ATTEMPTS; + attempt += 1 + ) { + const bytes = this.#random(CHALLENGE_RANDOM_BYTES); + if (bytes.byteLength !== CHALLENGE_RANDOM_BYTES) { + break; + } + const ref = bytes.toString('base64url'); + if (!this.#records.has(ref)) { + return ref; + } + } + throw new Error('stalker-challenge-reference-generation-failed'); + } + + #pruneExpired(): void { + const now = this.#now(); + for (const [ref, record] of this.#records) { + if (record.expiresAt <= now) { + this.#deleteReference(ref, record); + } + } + } + + #deleteReference(ref: string, record: StalkerChallengeRecord): void { + this.#records.delete(ref); + + const senderRefs = this.#senderRefs.get(record.senderId); + senderRefs?.delete(ref); + if (senderRefs?.size === 0) { + this.#senderRefs.delete(record.senderId); + } + + const senderAttempts = this.#attempts.get(record.senderId); + const attemptRefs = senderAttempts?.get(record.attemptRef); + attemptRefs?.delete(ref); + if (attemptRefs?.size === 0) { + senderAttempts?.delete(record.attemptRef); + } + if (senderAttempts?.size === 0) { + this.#attempts.delete(record.senderId); + } + } +} + +function validateOwner(senderId: number, attemptRef: string): void { + if ( + !Number.isSafeInteger(senderId) || + senderId < 0 || + attemptRef.length === 0 || + attemptRef.length > 256 + ) { + throw new Error('invalid-stalker-challenge-owner'); + } +} + +function cloneChallenge(challenge: StalkerChallenge): StalkerChallenge { + return Object.freeze({ ...challenge }); +} + +function getOrCreateMap( + map: Map>>, + key: number +): Map> { + const existing = map.get(key); + if (existing) { + return existing; + } + const created = new Map>(); + map.set(key, created); + return created; +} + +function getOrCreateSet( + map: Map>, + key: Key +): Set { + const existing = map.get(key); + if (existing) { + return existing; + } + const created = new Set(); + map.set(key, created); + return created; +} + +function addReference(refs: Set, ref: string): void { + refs.add(ref); +}