From 69673e7727a4d0322b1375975972c330a8925d55 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 11:11:13 +0200 Subject: [PATCH] feat(offline): PIN lock primitives + Apple-style keypad Groundwork for offline pass-and-play (hotseat) seat/host PIN locks: - lib/pin.ts: salted SHA-256 PinLock (newSalt/hashPin/newLock/verifyPin); a social lock for a shared device (documented, not cryptography). - components/PinPad.svelte: 4-digit keypad (set/verify/change), auto-submit on the 4th digit, shake on mismatch, hardware-keyboard support. - i18n: pin.* keys (en + ru). Engine/source/UI wiring follows. --- ui/src/components/PinPad.svelte | 262 ++++++++++++++++++++++++++++++++ ui/src/lib/i18n/en.ts | 9 ++ ui/src/lib/i18n/ru.ts | 9 ++ ui/src/lib/pin.test.ts | 49 ++++++ ui/src/lib/pin.ts | 45 ++++++ 5 files changed, 374 insertions(+) create mode 100644 ui/src/components/PinPad.svelte create mode 100644 ui/src/lib/pin.test.ts create mode 100644 ui/src/lib/pin.ts diff --git a/ui/src/components/PinPad.svelte b/ui/src/components/PinPad.svelte new file mode 100644 index 0000000..ff2f125 --- /dev/null +++ b/ui/src/components/PinPad.svelte @@ -0,0 +1,262 @@ + + + + + +
+ {#if phase === 'menu'} + + {:else} +

{promptText()}

+ {#key shakeSeq} + + {/key} +
+ {#each keys as k} + + {/each} + + + +
+ {/if} +
+
+ + diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 1383a6d..7e4b2e5 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -389,6 +389,15 @@ export const en = { 'error.game_active': 'Available only after the game is finished.', 'error.invalid_profile': 'Some profile fields are invalid.', 'error.already_confirmed': 'This email is already confirmed.', + + 'pin.enter': 'Enter PIN', + 'pin.create': 'Create a PIN', + 'pin.repeat': 'Repeat the PIN', + 'pin.enterCurrent': 'Enter current PIN', + 'pin.mismatch': "PINs don't match", + 'pin.wrong': 'Wrong PIN', + 'pin.setNew': 'Set a new PIN', + 'pin.remove': 'Remove PIN', } as const; export type MessageKey = keyof typeof en; diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index e40e1b9..e752d41 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -389,4 +389,13 @@ export const ru: Record = { 'error.game_active': 'Доступно только после завершения игры.', 'error.invalid_profile': 'Некоторые поля профиля некорректны.', 'error.already_confirmed': 'Эта почта уже подтверждена.', + + 'pin.enter': 'Введите PIN', + 'pin.create': 'Придумайте PIN', + 'pin.repeat': 'Повторите PIN', + 'pin.enterCurrent': 'Введите текущий PIN', + 'pin.mismatch': 'PIN не совпадает', + 'pin.wrong': 'Неверный PIN', + 'pin.setNew': 'Задать новый PIN', + 'pin.remove': 'Удалить пароль', }; diff --git a/ui/src/lib/pin.test.ts b/ui/src/lib/pin.test.ts new file mode 100644 index 0000000..af497b3 --- /dev/null +++ b/ui/src/lib/pin.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { hashPin, newLock, newSalt, verifyPin } from './pin'; + +describe('hashPin', () => { + it('is deterministic for the same pin and salt', async () => { + const salt = 'fixedsalt'; + expect(await hashPin('1234', salt)).toBe(await hashPin('1234', salt)); + }); + + it('differs when the salt differs', async () => { + expect(await hashPin('1234', 'saltA')).not.toBe(await hashPin('1234', 'saltB')); + }); + + it('differs when the pin differs', async () => { + const salt = 'fixedsalt'; + expect(await hashPin('1234', salt)).not.toBe(await hashPin('4321', salt)); + }); +}); + +describe('newSalt', () => { + it('returns a non-empty value', () => { + expect(newSalt().length).toBeGreaterThan(0); + }); + + it('returns a fresh value each call', () => { + expect(newSalt()).not.toBe(newSalt()); + }); +}); + +describe('newLock / verifyPin', () => { + it('produces a lock that verifies its own pin', async () => { + const lock = await newLock('1234'); + expect(lock.hash.length).toBeGreaterThan(0); + expect(lock.salt.length).toBeGreaterThan(0); + expect(await verifyPin('1234', lock)).toBe(true); + }); + + it('rejects a wrong pin', async () => { + const lock = await newLock('1234'); + expect(await verifyPin('0000', lock)).toBe(false); + }); + + it('salts each lock independently (same pin, different hashes)', async () => { + const a = await newLock('1234'); + const b = await newLock('1234'); + expect(a.salt).not.toBe(b.salt); + expect(a.hash).not.toBe(b.hash); + }); +}); diff --git a/ui/src/lib/pin.ts b/ui/src/lib/pin.ts new file mode 100644 index 0000000..6232b79 --- /dev/null +++ b/ui/src/lib/pin.ts @@ -0,0 +1,45 @@ +// 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; +}