feat(stalker): add one-time challenge registry

This commit is contained in:
4gray committed 2026-07-27 09:49:41 +02:00
1 parent 13f05d0a7e
commit 2a88060fae
2 files changed
+465

No files matched your search

@@ -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([]);
});
});
@@ -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<string, StalkerChallengeRecord>();
readonly #attempts = new Map<number, Map<string, Set<string>>>();
readonly #senderRefs = new Map<number, Set<string>>();
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<number, Map<string, Set<string>>>,
key: number
): Map<string, Set<string>> {
const existing = map.get(key);
if (existing) {
return existing;
}
const created = new Map<string, Set<string>>();
map.set(key, created);
return created;
}
function getOrCreateSet<Key>(
map: Map<Key, Set<string>>,
key: Key
): Set<string> {
const existing = map.get(key);
if (existing) {
return existing;
}
const created = new Set<string>();
map.set(key, created);
return created;
}
function addReference(refs: Set<string>, ref: string): void {
refs.add(ref);
}