docs: encryption-UX spec + plan from 2026-05-15 brainstorming session
This commit is contained in:
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 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.<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 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.
|
||||||
Reference in New Issue
Block a user