# Encryption UX Simplification — Design **Date:** 2026-05-15 **Scope:** Desktop (Electron/Tauri) first. Mobile follows in a subsequent spec. **Status:** Approved by user (sections 1–5). ## Problem The current end-to-end-encryption flow is too complex for non-technical users: 1. **Re-login locks them out.** Each device has its own X25519 keypair, and conversation keys (`conversation_keys`) are wrapped per-device. When a user signs in on a fresh install (or after clearing app data), they get a new device row → no peer has wrapped the conv-key for that new device → they can neither read old chats nor send new messages until another online peer of theirs (or another member of the conversation) re-wraps the conv-key for the new device. This frequently strands users. 2. **Backup restore must be done twice.** Restoring from `BackupRestoreDialog` in Settings reseeds the local vault, but on a clean re-login the device-registration screen still requires another restore — most often because the secret-store backend probe (`setSecretStoreUser`) and the restore write race, or because users lose the cached `localStorage` device-id between sessions. 3. **Backup string + passphrase + recovery code is too much.** Users are asked to manage a long base64 blob, a passphrase, and a 24-char recovery code. Most never export a backup at all. ## Goals - Re-login on a fresh device must be a single-step action ("enter PIN") that restores full read+write access immediately, with no dependency on peers being online. - The user manages **one** secret (a 6-digit PIN) plus an optional one-time recovery code. - No more raw "backup strings" to copy around. - Existing chat history remains readable after the migration. - Compromise of a single device still requires user action to fully revoke (rotate user key) — but per-device fine-grained revocation is intentionally dropped in favor of UX. ## Non-Goals - Mobile rollout. Tracked in a follow-up spec; the schema and shared crypto primitives are designed to be reusable on React Native. - Forward secrecy / Double Ratchet. Out of scope; we keep the Sender-Key model. - Multi-account on a single OS user. - A web-only (no Electron/Tauri) variant. We assume an OS-keychain-backed Stronghold/safeStorage is available; the localStorage fallback path remains as today's escape hatch. ## Architecture ### Identity model: per-user instead of per-device Today: one X25519 keypair per `(user_id, device_id)`; `conversation_keys` rows are keyed by `recipient_device_id`. New: **one X25519 keypair per `user_id`**, stored as a PIN-sealed blob in a new `user_keys` table. Devices remain only for telemetry / push / last-seen — they no longer carry cryptographic identity. Each device unlocks the same user key with the PIN and caches the cleartext private key in the OS keychain (Stronghold / safeStorage). `conversation_keys` is rekeyed: `recipient_device_id` → `recipient_user_id`, `sender_device_id` → `sender_user_id`. Sender-key (symmetric XSalsa20-Poly1305 conv-key) and the wrapping primitive (`crypto_box`) stay unchanged. ### Trade-offs - **Lost:** clean per-device revocation. If a single device is compromised, the user must rotate the whole user key (re-wrap conv-keys for every member of every conversation). For a friends-chat app with no enterprise revocation requirement, acceptable. - **Lost:** "device fingerprint per session" trust signal. - **Gained:** zero-friction re-login, zero peer dependency for new installs, single secret to remember, no manual backup string. ### Threat model - Server (Supabase) is honest-but-curious. It must never see plaintext private keys or conv-keys. - A 6-digit PIN is **not** brute-force resistant on its own. It is protected by: - Argon2id KDF (`moderate` preset) raises per-attempt cost to ~hundreds of ms. - **Server-side lockout counter** (`failed_attempts`, `locked_until` in `user_keys`) gates ciphertext delivery: after 10 failed attempts a 24h lock is applied. The client cannot brute-force locally because it must request the ciphertext from the server, which the server rate-limits. - Recovery code (~120 bits) is the strong factor; PIN is the convenience factor. - A network attacker with a stolen Supabase session token cannot decrypt anything without the PIN/recovery code. - A local attacker with full disk access can read the cached cleartext key from Stronghold (same threat as today's per-device key model). ## Components ### `packages/shared` | File | Change | |------|--------| | `crypto/userKey.ts` | NEW. `generateUserKeyPair()`, `sealUserKey({privateKey, pin, salt})`, `openUserKey({sealed, pin, salt})`. Argon2id (moderate preset) + XSalsa20-Poly1305. | | `auth/userKey.ts` | NEW. DB wrappers: `fetchUserKeyBlob(client, userId)`, `uploadUserKeyBlob(...)`, `recordPinAttempt(...)`, `tryUnlockUserKey(...)` (calls SECURITY DEFINER RPC), `resetUserKeyWithRecovery(...)`. | | `chat/convKeys.ts` | Refactor: `recipient_device_id` → `recipient_user_id`, `sender_device_id` → `sender_user_id`. `listDeviceKeys()` becomes `listMemberPublicKeys()` (joins `conversation_members` → `user_keys`). `OwnDeviceCtx` → `OwnUserCtx { userId; privateKey }`. | | `auth/device.ts` | Strip cryptographic functions: remove `provisionNewDevice`, `restoreDeviceFromServerRecord`, `saveDevicePrivateKey`, `loadDevicePrivateKey`, `forgetDevicePrivateKey`. Keep telemetry helpers (`registerDevice`, `listOwnDevices`, `touchDeviceLastSeen`); they no longer take/store `public_key`. | | `crypto/index.ts` | Re-export `userKey`. | ### `apps/desktop/src/lib` | File | Change | |------|--------| | `userIdentity.ts` | NEW. `loadOrUnlockUserKey({pin})`, `setupNewUserIdentity({pin, withRecovery})`, `cachedUserKey()`, `clearUserKeyCache()`. Caches cleartext key in Stronghold under `chatapp.userpriv.`. | | `secretStore.ts` | Unchanged interface. Stores `chatapp.userpriv.` instead of `chatapp.priv..`. | | `device.ts` | Strip `findExistingDevice`, `registerCurrentDevice`. Keep platform detection only. | | `deviceBackup.ts` | DELETE. | ### `apps/desktop/src/components` | File | Change | |------|--------| | `UserKeySetup.tsx` | NEW. PIN entry + confirm + "Recovery-Code anzeigen" + "Habe ich gespeichert" / "Überspringen (riskant)" buttons. | | `UserKeyUnlock.tsx` | NEW. Numeric PIN pad. Surfaces remaining attempts + lockout countdown. Switches to "Recovery-Code eingeben" tab. | | `DeviceRegistration.tsx` | Becomes onboarding wrapper that routes to `UserKeySetup` or `UserKeyUnlock` based on `fetchUserKeyBlob` result. Drop the device-name prompt (auto from hostname; editable in Settings). | | `DeviceRestore.tsx` | DELETE. | | `BackupExportDialog.tsx` | DELETE. | | `BackupRestoreDialog.tsx` | DELETE. | | `BackupPromptBanner.tsx` | DELETE. | | `SettingsPage.tsx` | Replace backup section with: "PIN ändern", "Recovery-Code neu erzeugen", "Identität zurücksetzen". | ### Supabase schema ```sql CREATE TABLE user_keys ( user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, public_key BYTEA NOT NULL, -- 32 bytes X25519 sealed_private_key BYTEA NOT NULL, -- nonce(24) || ciphertext salt BYTEA NOT NULL, -- 16 bytes kdf_params JSONB NOT NULL, -- { algo: 'argon2id', preset: 'moderate', opslimit, memlimit } recovery_sealed_private_key BYTEA NULL, -- present only if user kept recovery code recovery_salt BYTEA NULL, failed_attempts INT NOT NULL DEFAULT 0, locked_until TIMESTAMPTZ NULL, failed_recovery_attempts INT NOT NULL DEFAULT 0, recovery_locked_until TIMESTAMPTZ NULL, key_version INT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- RLS: a user reads/writes only their own row. ALTER TABLE user_keys ENABLE ROW LEVEL SECURITY; CREATE POLICY user_keys_self_rw ON user_keys USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); -- Anyone authenticated may read peer public_key only (separate policy on a view or column-level). CREATE VIEW user_public_keys AS SELECT user_id, public_key, key_version FROM user_keys; GRANT SELECT ON user_public_keys TO authenticated; ``` `conversation_keys`: - ADD `recipient_user_id UUID`, `sender_user_id UUID` (nullable during migration). - After migration cutoff: drop `recipient_device_id`, `sender_device_id`. - New unique constraint: `(conversation_id, recipient_user_id, key_version)`. RPCs (SECURITY DEFINER): - `try_unlock_user_key(p_user_id UUID)` → returns `{ sealed, salt, kdf_params, locked: bool, locked_until }`. Honors lockout. Caller must already be `auth.uid() = p_user_id`. Does NOT decrement attempts (decryption is offline; client reports outcome via `record_pin_attempt`). - `record_pin_attempt(p_user_id UUID, p_success BOOL, p_recovery BOOL)` → updates counters & lockout. Server-authoritative, atomic. - `share_conv_keys(...)` → existing RPC, signature changes from `p_recipient_device_id` to `p_recipient_user_id` per bundle entry. - `migrate_user_key_recipients(p_conv_id, p_user_id, p_old_device_id, p_new_bundles)` → bulk INSERT new `recipient_user_id` rows from re-wrapped bundles, idempotent via `ON CONFLICT (conversation_id, recipient_user_id, key_version) DO NOTHING`. ## Data Flow ### First-time setup 1. User signs in (magic link / password). `fetchUserKeyBlob(userId)` → `null`. 2. Routes to `UserKeySetup`. User chooses 6-digit PIN. 3. Client generates X25519 keypair + 16-byte salt; derives KEK via Argon2id; seals the private key. 4. Client generates a 24-char recovery code (32-symbol alphabet, ~120 bits) and seals the same private key with a recovery-derived KEK + separate salt. 5. UI shows recovery code once. User picks "Habe ich gespeichert" or "Überspringen". Skip leaves `recovery_sealed_private_key NULL`. 6. UPSERT `user_keys` row. 7. Cleartext private key cached in Stronghold (`chatapp.userpriv.`). ### Re-login on a fresh / wiped device 1. Sign-in OK. `fetchUserKeyBlob(userId)` returns row → routes to `UserKeyUnlock`. 2. User enters PIN. RPC `try_unlock_user_key` returns ciphertext+salt (or `locked: true`). 3. Client derives KEK + opens sealed key. On success: `record_pin_attempt(success=true)` resets counter; cleartext key cached in Stronghold; navigate to `/chats`. 4. On failure: `record_pin_attempt(success=false)` increments counter. UI shows remaining attempts. 5. On lockout: UI offers "Recovery-Code verwenden" tab. Same flow against `recovery_sealed_private_key` + `failed_recovery_attempts`. ### Sending a message (existing conversation) 1. `getOrCreateConvKey(convId, ownUserCtx)` looks up `conversation_keys` by `recipient_user_id = me`. 2. Bundle exists → unwrap with user private key → encrypt plaintext → send. 3. **Re-login works immediately**: the user's `public_key` is unchanged across sessions, so all existing wrapped conv-keys remain valid. ### New user joins a conversation 1. Existing member loads `public_key` from `user_public_keys`. 2. Calls `shareConvKeyToUser(convId, newUserId, newUserPubKey, ownCtx)` → wraps active conv-key, INSERT bundle. ### Migration of legacy `conversation_keys` Triggered automatically the first time a user signs in after the upgrade and has both an old device-key in Stronghold and no `user_keys` row yet: 1. Setup flow runs (PIN, generate user keypair, upload `user_keys`). 2. Migration pass: - List all `conversation_keys` rows where `recipient_device_id` belongs to one of the user's old devices. - Unwrap with the old device private key. - Re-wrap for `recipient_user_id = me` with the new user public key. - Bulk-insert via `migrate_user_key_recipients` (`ON CONFLICT DO NOTHING`). 3. Old rows remain untouched; other members migrate independently in their own passes. 4. Cross-member rewrap: when any member opens a conversation, the client lists members lacking a `recipient_user_id` bundle and proactively wraps for them in the background. 5. After all active users have migrated (telemetry-tracked), a follow-up migration drops `recipient_device_id` / `sender_device_id`. ### PIN change (Settings) 1. Prompt for current PIN → unlock locally (without server round-trip; we already have ciphertext+salt cached or can fetch). 2. Generate new salt; reseal with new PIN-derived KEK. 3. UPSERT `user_keys` row (same `public_key`, no conv-key rewrap). ### Identity reset Hard "burn it down" path. Generates new user keypair, replaces `user_keys`, deletes the user's `conversation_keys` bundles. Friends will see fingerprint-change in the future trust UI (not in MVP). ## Error Handling | Case | Behavior | |------|----------| | Wrong PIN | Offline crypto fails → `record_pin_attempt(success=false)` → counter increments. Cooldown schedule: attempts 1–4 none, 5–9 backoff (5s, 30s, 2m, 10m, 1h), 10 → `locked_until = now() + 24h`. Counter resets on success. UI shows remaining attempts and any active cooldown. | | Lockout active | `try_unlock_user_key` returns `{locked: true, locked_until}` without ciphertext. UI surfaces "Recovery-Code verwenden" tab. | | Recovery-code attempts | Independent counter `failed_recovery_attempts`; same backoff schedule, threshold 20 → permanent lock requiring identity reset. | | Forgot PIN, no recovery code | UI shows hard warning, then runs Identity-Reset flow (lose all old chats; fingerprint changes for friends). | | Stronghold cache lost (reinstall, wipe) | Identical to "Re-login on fresh device". | | `user_keys` row missing server-side but Stronghold has key | UI offers "Identität wieder hochladen": re-uploads using cached key + a fresh PIN entry. Telemetry-logged. | | Concurrent setup race | `INSERT … ON CONFLICT (user_id) DO NOTHING` + re-fetch. Loser unlocks the winner's blob with the same PIN. Mismatched PINs → loser sees "use the PIN chosen on the other device". | | Concurrent migration race | `migrate_user_key_recipients` inserts are idempotent; both clients converge to identical state. | | Conv-key bundle missing despite membership | As today: `getOrCreateConvKey` throws "Awaiting conversation key". Background sweep on conversation-open triggers proactive rewrap from any online member. The new model makes this strictly rarer (one bundle per user, not per device). | | Stronghold init fails | Existing fallback to localStorage retained. User-key lands in localStorage cleartext (same risk as today's dev-fallback path). | ## Testing **Unit (`packages/shared`):** - `crypto/userKey.test.ts`: roundtrip seal/open with correct PIN; wrong PIN throws; wrong salt throws; KDF preset is reproducible. - `auth/userKey.test.ts`: fetch returns null vs. row; UPSERT behavior; `record_pin_attempt` increments; lockout transitions. - `chat/convKeys.test.ts`: tests refactored to `recipient_user_id`. New: bootstrap with two members (each unwraps OK); share to new user works; awaiting-key path throws. **Integration (`apps/desktop`):** - `userIdentity.migration.test.ts`: fixture with legacy `recipient_device_id` rows. Run migration pass. Assert: new `recipient_user_id` rows exist with same plaintext conv-key; legacy rows untouched. - Race test: two parallel migration runs converge without duplicate inserts. **Component (`apps/desktop`):** - `UserKeySetup.test.tsx`: PIN-mismatch error; recovery-code rendered exactly once; "Skip" path leaves `recovery_sealed_private_key NULL`. - `UserKeyUnlock.test.tsx`: wrong PIN increments attempts; lockout countdown rendered; recovery tab functional. **E2E (Playwright via `e2e-testing` skill, optional in first cut):** - Happy path: login → setup PIN → send chat → sign-out → sign-in → PIN → old chats readable, new chats sendable. - Cross-context: User1 device A ↔ User2 conversation. User1 signs out, signs in fresh context, enters PIN, can read+write **without** User2 being online. **Manual smoke list (pre-release):** 1. Fresh install, setup with recovery save. 2. Fresh install, setup with recovery skip. 3. Existing user with legacy conv-keys → silent migration → old chats readable. 4. Existing user on second device: setup on A, sign-in on B with same PIN. 5. Forgot PIN → recovery-code → unlock OK. 6. PIN change in Settings. 7. Identity reset → old chats unreadable for me, new chats functional. 8. Lockout: 10 wrong PINs → lock + recovery tab. ## Out of Scope (future work) - Mobile (React Native) port — separate spec. - Trust-on-first-use fingerprint banner when peer rotates `key_version`. - Linked-device pairing flow (Signal-style) as a third recovery vector. - Forward-secret message keys (Double Ratchet). - Hardware-backed PIN entry (Secure Enclave / TPM bound). ## Migration Cutoff After ≥95% of MAU have a `user_keys` row (server telemetry), schedule a follow-up migration to drop `recipient_device_id` and `sender_device_id` columns.