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:
@@ -0,0 +1,262 @@
|
||||
<script lang="ts">
|
||||
// Apple-lock-screen-style 4-digit PIN pad for offline pass-and-play (hotseat) games.
|
||||
// Modes:
|
||||
// set — enter a PIN, then repeat it to confirm; emits { kind: 'set', lock }.
|
||||
// verify — enter a PIN; auto-checks against `lock` on the 4th digit; emits
|
||||
// { kind: 'verified' } or shakes + clears on a wrong PIN.
|
||||
// change — verify the current PIN (against `lock`), then offer "set a new PIN"
|
||||
// (the set flow) or, when allowRemove, "remove PIN" ({ kind: 'removed' }).
|
||||
// The pad never shows an OK button: entering the 4th digit is the action. PIN storage
|
||||
// is a social lock, not cryptography — see lib/pin.ts.
|
||||
import Modal from './Modal.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { newLock, verifyPin, type PinLock } from '../lib/pin';
|
||||
|
||||
/** PinResult is the outcome handed to onresult when the pad completes. */
|
||||
export type PinResult =
|
||||
| { kind: 'set'; lock: PinLock }
|
||||
| { kind: 'verified' }
|
||||
| { kind: 'removed' };
|
||||
|
||||
let {
|
||||
mode,
|
||||
title = '',
|
||||
lock,
|
||||
allowRemove = false,
|
||||
onclose,
|
||||
onresult,
|
||||
}: {
|
||||
mode: 'set' | 'verify' | 'change';
|
||||
title?: string;
|
||||
lock?: PinLock;
|
||||
allowRemove?: boolean;
|
||||
onclose?: () => void;
|
||||
onresult: (r: PinResult) => void;
|
||||
} = $props();
|
||||
|
||||
const LEN = 4;
|
||||
|
||||
// 'old' verifies the existing PIN (change); 'menu' offers set-new / remove (change);
|
||||
// 'enter' collects a PIN; 'confirm' re-enters it (set / change→new). `mode` is fixed
|
||||
// for the pad's lifetime (callers mount a fresh pad per operation), so capturing its
|
||||
// initial value here is intentional.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let phase = $state<'old' | 'menu' | 'enter' | 'confirm'>(mode === 'change' ? 'old' : 'enter');
|
||||
let buffer = $state('');
|
||||
let first = $state(''); // the first entry, awaiting confirmation
|
||||
let error = $state(false); // drives the error prompt + shake
|
||||
let shakeSeq = $state(0); // bumped on each error to replay the shake animation
|
||||
let busy = $state(false); // guards the async check between the 4th digit and the outcome
|
||||
|
||||
function promptText(): string {
|
||||
if (error) return phase === 'confirm' ? t('pin.mismatch') : t('pin.wrong');
|
||||
switch (phase) {
|
||||
case 'old':
|
||||
return t('pin.enterCurrent');
|
||||
case 'confirm':
|
||||
return t('pin.repeat');
|
||||
default:
|
||||
return mode === 'verify' ? t('pin.enter') : t('pin.create');
|
||||
}
|
||||
}
|
||||
|
||||
function fail(): void {
|
||||
buffer = '';
|
||||
error = true;
|
||||
shakeSeq += 1;
|
||||
}
|
||||
|
||||
async function commit(): Promise<void> {
|
||||
busy = true;
|
||||
try {
|
||||
if (phase === 'old') {
|
||||
if (lock && (await verifyPin(buffer, lock))) {
|
||||
buffer = '';
|
||||
error = false;
|
||||
phase = 'menu';
|
||||
} else fail();
|
||||
} else if (phase === 'enter' && mode === 'verify') {
|
||||
if (lock && (await verifyPin(buffer, lock))) onresult({ kind: 'verified' });
|
||||
else fail();
|
||||
} else if (phase === 'enter') {
|
||||
first = buffer;
|
||||
buffer = '';
|
||||
error = false;
|
||||
phase = 'confirm';
|
||||
} else if (phase === 'confirm') {
|
||||
if (buffer === first) onresult({ kind: 'set', lock: await newLock(buffer) });
|
||||
else {
|
||||
first = '';
|
||||
phase = 'enter';
|
||||
fail();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function press(d: string): void {
|
||||
if (busy || phase === 'menu' || buffer.length >= LEN) return;
|
||||
error = false;
|
||||
buffer += d;
|
||||
if (buffer.length === LEN) void commit();
|
||||
}
|
||||
|
||||
function back(): void {
|
||||
if (busy || phase === 'menu') return;
|
||||
buffer = buffer.slice(0, -1);
|
||||
}
|
||||
|
||||
function startNew(): void {
|
||||
error = false;
|
||||
first = '';
|
||||
buffer = '';
|
||||
phase = 'enter';
|
||||
}
|
||||
|
||||
function onkey(e: KeyboardEvent): void {
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
press(e.key);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Backspace') {
|
||||
back();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Escape') {
|
||||
onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||
const slots = Array.from({ length: LEN }, (_, i) => i);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onkey} />
|
||||
|
||||
<Modal {title} {onclose} overlayKeyboard>
|
||||
<div class="pad">
|
||||
{#if phase === 'menu'}
|
||||
<div class="menu">
|
||||
<button class="opt" onclick={startNew}>{t('pin.setNew')}</button>
|
||||
{#if allowRemove}
|
||||
<button class="opt danger" onclick={() => onresult({ kind: 'removed' })}>
|
||||
{t('pin.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="prompt" class:err={error}>{promptText()}</p>
|
||||
{#key shakeSeq}
|
||||
<div class="dots" class:shake={error} aria-hidden="true">
|
||||
{#each slots as i}
|
||||
<span class="dot" class:on={i < buffer.length}></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/key}
|
||||
<div class="grid">
|
||||
{#each keys as k}
|
||||
<button class="key" onclick={() => press(k)} aria-label={k}>{k}</button>
|
||||
{/each}
|
||||
<span class="key spacer"></span>
|
||||
<button class="key" onclick={() => press('0')} aria-label="0">0</button>
|
||||
<button class="key back" onclick={back} aria-label="⌫" disabled={buffer.length === 0}>⌫</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.pad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted, var(--text));
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.prompt.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dot.on {
|
||||
background: var(--text);
|
||||
}
|
||||
.shake {
|
||||
animation: shake 0.32s ease;
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-7px); }
|
||||
40% { transform: translateX(7px); }
|
||||
60% { transform: translateX(-5px); }
|
||||
80% { transform: translateX(5px); }
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.key {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.key:active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.key.back {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.key.back:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.key.spacer {
|
||||
border: none;
|
||||
background: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.opt {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
}
|
||||
.opt.danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -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