// Seat / host PIN locks for offline pass-and-play (hotseat) games. // // A PinLock is a salted SHA-256 digest of a 4-digit PIN. This is a SOCIAL lock for a // shared device — not cryptographic protection. A 4-digit PIN has only 10^4 // combinations and the whole game runs on the client, so a determined peek reads the // racks straight from memory regardless. The salt only keeps the PIN itself out of a // casual devtools glance and defeats precomputed tables; it does not make the PIN // brute-force resistant. /** PinLock is a stored, salted SHA-256 digest of a PIN (see the file header caveat). */ export interface PinLock { hash: string; salt: string; } // b64url encodes bytes as base64url without padding (matches the idiom in vkid.ts). function b64url(bytes: Uint8Array): string { let s = ''; for (const b of bytes) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } /** newSalt returns a fresh random base64url salt for a PinLock. */ export function newSalt(): string { const a = new Uint8Array(16); crypto.getRandomValues(a); return b64url(a); } /** hashPin returns the base64url SHA-256 digest binding salt to pin. */ export async function hashPin(pin: string, salt: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${salt}:${pin}`)); return b64url(new Uint8Array(digest)); } /** newLock builds a PinLock for pin with a fresh salt. */ export async function newLock(pin: string): Promise { const salt = newSalt(); return { salt, hash: await hashPin(pin, salt) }; } /** verifyPin reports whether pin matches the digest stored in lock. */ export async function verifyPin(pin: string, lock: PinLock): Promise { return (await hashPin(pin, lock.salt)) === lock.hash; }