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:
@@ -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;
|
||||
|
||||
@@ -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': 'Удалить пароль',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user