mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(stalker): add one-time challenge registry
This commit is contained in:
1 parent
13f05d0a7e
commit
2a88060fae
2 files changed
+465
No files matched your search
+212
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user