Files
ChatApp/docs/superpowers/specs/2026-05-15-encryption-ux-simplification-design.md
T

249 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Encryption UX Simplification — Design
**Date:** 2026-05-15
**Scope:** Desktop (Electron/Tauri) first. Mobile follows in a subsequent spec.
**Status:** Approved by user (sections 15).
## 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.<userId>`. |
| `secretStore.ts` | Unchanged interface. Stores `chatapp.userpriv.<userId>` instead of `chatapp.priv.<userId>.<deviceId>`. |
| `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.<userId>`).
### 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 14 none, 59 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.