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.
This commit is contained in:
Ilia Denisov
2026-07-07 11:11:13 +02:00
parent d7f3d93c6c
commit 69673e7727
5 changed files with 374 additions and 0 deletions
+9
View File
@@ -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;
+9
View File
@@ -389,4 +389,13 @@ export const ru: Record<MessageKey, string> = {
'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': 'Удалить пароль',
};
+49
View File
@@ -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);
});
});
+45
View File
@@ -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<string> {
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<PinLock> {
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<boolean> {
return (await hashPin(pin, lock.salt)) === lock.hash;
}