docs: encryption-UX spec + plan from 2026-05-15 brainstorming session

This commit is contained in:
byGalax
2026-05-16 00:02:27 +02:00
parent 0e05a2cd85
commit 1c18078b1f
2 changed files with 3226 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,248 @@
# 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.