diff --git a/frontend/src/lib/auth.test.ts b/frontend/src/lib/auth.test.ts index 0e01732..d8a1344 100644 --- a/frontend/src/lib/auth.test.ts +++ b/frontend/src/lib/auth.test.ts @@ -19,7 +19,9 @@ import { getRole, getUserId, clearAuth, - clearPin + clearPin, + getPinOwner, + getDisplayName } from './auth'; /** Build a JWT-shaped string (header.payload.sig) with the given claims. */ @@ -115,3 +117,35 @@ describe('auth — JWT claim decode', () => { expect(getRole()).toBeNull(); }); }); + +describe('auth — the cached PIN knows whose it is', () => { + // A host PIN reset also revokes the guest's sessions, so the guest reaches /recover only AFTER + // clearAuth has run. /recover clears a rejected cached PIN only when the submitted name matches + // this device's — so if the owner name did not survive clearAuth, the dead PIN could never be + // cleared and would keep pre-filling the field. + it('the PIN owner survives clearAuth, exactly as the PIN itself does', () => { + setAuth('a.b.c', '1234', 'uid-1', 'Alice'); + expect(getPinOwner()).toBe('Alice'); + + clearAuth(); + + expect(getPin(), 'the PIN is deliberately kept so the guest can recover').toBe('1234'); + expect(getDisplayName(), 'the display name is wiped for shared-device privacy').toBeNull(); + expect(getPinOwner(), 'so the PIN owner must be stored separately, or the pair is broken').toBe( + 'Alice' + ); + }); + + it('clearPin drops the owner too — no orphan pointing at a PIN that is gone', () => { + setAuth('a.b.c', '1234', 'uid-1', 'Alice'); + clearPin(); + expect(getPin()).toBeNull(); + expect(getPinOwner()).toBeNull(); + }); + + it('no cached PIN means no owner, even if a display name is present', () => { + setAuth('a.b.c', null, 'uid-1', 'Alice'); + expect(getPin()).toBeNull(); + expect(getPinOwner()).toBeNull(); + }); +}); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index e5a478b..ebbf065 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -3,6 +3,23 @@ import { browser } from '$app/environment'; const TOKEN_KEY = 'eventsnap_jwt'; const PIN_KEY = 'eventsnap_pin'; +/** + * Whose PIN `PIN_KEY` holds — and it is a SEPARATE key from `DISPLAY_NAME_KEY` on purpose. + * + * `/recover` only clears a rejected cached PIN when the name submitted is the one this device + * belongs to, so that a guest who mistypes their own name does not lose the only copy of their PIN + * (the server keeps just the bcrypt). That check read `DISPLAY_NAME_KEY` — which `clearAuth` + * deletes, for shared-device privacy, one step BEFORE the guest ever reaches `/recover`: + * + * host taps "PIN zurücksetzen" → the backend also revokes every session for that user + * (`host.rs`, `Session::delete_all_for_user`) → the guest's next request 401s → `clearAuth` + * → redirect to /join → the guest goes to /recover, where the field is PRE-FILLED with the + * dead PIN and can never be cleared, because the name it would be compared against is gone + * + * Since `clearAuth` deliberately keeps the PIN so the guest can recover, it must keep the PIN's + * owner too, or the pair is inconsistent and the guard is unreachable exactly when it is needed. + */ +const PIN_OWNER_KEY = 'eventsnap_pin_owner'; const USER_ID_KEY = 'eventsnap_user_id'; const DISPLAY_NAME_KEY = 'eventsnap_display_name'; @@ -43,9 +60,22 @@ export function getPin(): string | null { export function clearPin(): void { if (!browser) return; localStorage.removeItem(PIN_KEY); + localStorage.removeItem(PIN_OWNER_KEY); currentPin.set(null); } +/** + * The display name the cached PIN belongs to, or `null` if there is no cached PIN. + * + * Survives `clearAuth` alongside the PIN itself — see [`PIN_OWNER_KEY`]. Falls back to the auth + * display name for devices that cached a PIN before this key existed. + */ +export function getPinOwner(): string | null { + if (!browser) return null; + if (localStorage.getItem(PIN_KEY) === null) return null; + return localStorage.getItem(PIN_OWNER_KEY) ?? readAuth(DISPLAY_NAME_KEY); +} + export function getUserId(): string | null { return readAuth(USER_ID_KEY); } @@ -81,6 +111,8 @@ export function setAuth( localStorage.setItem(TOKEN_KEY, jwt); if (pin) { localStorage.setItem(PIN_KEY, pin); + // Stored with the PIN, not derived from it later — see `PIN_OWNER_KEY`. + if (displayName) localStorage.setItem(PIN_OWNER_KEY, displayName); currentPin.set(pin); } localStorage.setItem(USER_ID_KEY, userId); diff --git a/frontend/src/lib/upload-queue.ts b/frontend/src/lib/upload-queue.ts index 4bd6b4f..db0be38 100644 --- a/frontend/src/lib/upload-queue.ts +++ b/frontend/src/lib/upload-queue.ts @@ -1,4 +1,5 @@ import { openDB, type IDBPDatabase } from 'idb'; +import { uuid } from '$lib/uuid'; import { writable, get } from 'svelte/store'; import { getToken, getUserId, clearAuth, onClearAuth, onSetAuth } from './auth'; import { onSseEvent } from './sse'; @@ -885,7 +886,7 @@ export async function addToQueue( // This id is also the server-side idempotency key (`client_upload_id`), so it is minted // exactly ONCE per file here and reused by every retry — see uploadItem. - const id = crypto.randomUUID(); + const id = uuid(); const entry: QueueEntry = { id, userId, diff --git a/frontend/src/lib/uuid.test.ts b/frontend/src/lib/uuid.test.ts new file mode 100644 index 0000000..f0598cb --- /dev/null +++ b/frontend/src/lib/uuid.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { uuid } from './uuid'; + +const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('uuid', () => { + it('uses crypto.randomUUID when it is available', () => { + const spy = vi.spyOn(crypto, 'randomUUID'); + expect(uuid()).toMatch(V4); + expect(spy).toHaveBeenCalled(); + }); + + // The case that matters: an idempotency key is now minted on the JOIN path, and a TypeError + // there surfaces as a generic error the guest cannot get past — no account yet, so /recover is + // no help either. `crypto.randomUUID` needs a secure context and Safari >= 15.4. + it('falls back to getRandomValues when randomUUID is missing', () => { + const real = crypto.getRandomValues.bind(crypto); + vi.stubGlobal('crypto', { + getRandomValues: real + // randomUUID deliberately absent + }); + + const id = uuid(); + expect(id, 'the fallback must still produce a well-formed v4 UUID').toMatch(V4); + }); + + it('the fallback sets the version and variant bits, not just random hex', () => { + // Every byte 0x00 — so version/variant nibbles can only be right if they are set explicitly. + vi.stubGlobal('crypto', { + getRandomValues: (a: Uint8Array) => a.fill(0) + }); + expect(uuid()).toBe('00000000-0000-4000-8000-000000000000'); + }); + + it('produces distinct values through the fallback', () => { + const real = crypto.getRandomValues.bind(crypto); + vi.stubGlobal('crypto', { getRandomValues: real }); + const ids = new Set(Array.from({ length: 200 }, () => uuid())); + expect(ids.size, 'a collision would replay one guest join or upload onto another').toBe(200); + }); +}); diff --git a/frontend/src/lib/uuid.ts b/frontend/src/lib/uuid.ts new file mode 100644 index 0000000..441a7c4 --- /dev/null +++ b/frontend/src/lib/uuid.ts @@ -0,0 +1,37 @@ +/** + * A v4 UUID, with a fallback for environments where `crypto.randomUUID` is missing. + * + * `crypto.randomUUID` needs Safari ≥ 15.4 / Chrome ≥ 92 **and a secure context**. Production is + * HTTPS (Caddy terminates TLS for `{$DOMAIN}`), so the realistic gap is an old phone — but the + * consequence changed when idempotency keys moved onto the join path. Previously such a device + * joined and browsed fine and only failed at upload, a degraded but survivable experience. Now the + * `TypeError` is thrown inside `handleJoin`'s `try` and surfaces as the generic + * "Ein Fehler ist aufgetreten.", identically on every retry: the guest cannot join, cannot browse, + * and cannot use `/recover` either, because they have no account yet. It is the one screen in the + * app where a hard failure leaves no way out at all. + * + * The fallback is `crypto.getRandomValues` (universally available, no secure-context requirement) + * with the version and variant bits set per RFC 4122 §4.4. `Math.random` is deliberately NOT a + * further fallback: these values are idempotency keys, and a collision between two guests would + * replay one guest's join or upload to another. If there is no CSPRNG at all, throwing is correct. + */ +export function uuid(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx + + const hex: string[] = []; + for (const b of bytes) hex.push(b.toString(16).padStart(2, '0')); + return [ + hex.slice(0, 4).join(''), + hex.slice(4, 6).join(''), + hex.slice(6, 8).join(''), + hex.slice(8, 10).join(''), + hex.slice(10, 16).join('') + ].join('-'); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 229dca7..d03716a 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -126,21 +126,39 @@ // and, outside it, every SSE listener registration — still has to run. const worthRetrying = !(err instanceof ApiError && err.status === 401); if (worthRetrying) { - try { - await new Promise((r) => setTimeout(r, 2000)); - const ctx = await api.get('/me/context'); - isBanned.set(ctx.is_banned); - eventState.set({ - uploadsLocked: ctx.uploads_locked, - galleryReleased: ctx.gallery_released - }); - void releaseResolvedParks({ - banned: ctx.is_banned, - uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released - }); - } catch { - // Still down. The "Erneut" button on the parked row remains the way back. - } + // DETACHED, not awaited. Everything below — including every SSE listener + // registered outside this block — used to sit behind it, and the worst case is + // a 20 s request timeout + 2 s backoff + a second 20 s timeout: ~42 s during + // which `pin-reset`, `user-hidden`/`user-shown`, `event-closed`/`event-opened` + // and `event-updated` are dispatched to an empty handler list. On `main` the + // exposure was one timeout; awaiting the retry doubled it, on exactly the wifi + // the retry exists for. + // + // Five of the six self-heal (`/feed`'s mount re-reads them, and the upload + // queue binds `event-opened`/`user-shown` at module scope, so parked photos + // still release). `pin-reset` does NOT: nothing else clears the cached + // plaintext PIN, so a missed one leaves a dead PIN displayed in "Mein Konto" + // and pre-filling /recover. + // + // Detaching costs nothing — the retry only writes stores and releases parks, + // and no code below reads its result. + void (async () => { + try { + await new Promise((r) => setTimeout(r, 2000)); + const ctx = await api.get('/me/context'); + isBanned.set(ctx.is_banned); + eventState.set({ + uploadsLocked: ctx.uploads_locked, + galleryReleased: ctx.gallery_released + }); + void releaseResolvedParks({ + banned: ctx.is_banned, + uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released + }); + } catch { + // Still down. The "Erneut" button on the parked row remains the way back. + } + })(); } } void refreshQuota(); diff --git a/frontend/src/routes/join/+page.svelte b/frontend/src/routes/join/+page.svelte index b5fae6d..344e2f6 100644 --- a/frontend/src/routes/join/+page.svelte +++ b/frontend/src/routes/join/+page.svelte @@ -3,6 +3,7 @@ import { goto } from '$app/navigation'; import { api, ApiError } from '$lib/api'; import { setAuth } from '$lib/auth'; + import { uuid } from '$lib/uuid'; import { markGuideSeen } from '$lib/onboarding'; import { focusTrap } from '$lib/actions/focus-trap'; @@ -61,7 +62,7 @@ // a retry within this page view idempotent, which is the common case. } if (!id) { - id = crypto.randomUUID(); + id = uuid(); try { localStorage.setItem(JOIN_KEY_STORAGE, id); } catch { diff --git a/frontend/src/routes/recover/+page.svelte b/frontend/src/routes/recover/+page.svelte index 7de4988..1392404 100644 --- a/frontend/src/routes/recover/+page.svelte +++ b/frontend/src/routes/recover/+page.svelte @@ -1,7 +1,7 @@