17 KiB
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:
- 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. - Backup restore must be done twice. Restoring from
BackupRestoreDialogin 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 cachedlocalStoragedevice-id between sessions. - 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 (
moderatepreset) raises per-attempt cost to ~hundreds of ms. - Server-side lockout counter (
failed_attempts,locked_untilinuser_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.
- Argon2id KDF (
- 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
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 beauth.uid() = p_user_id. Does NOT decrement attempts (decryption is offline; client reports outcome viarecord_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 fromp_recipient_device_idtop_recipient_user_idper bundle entry.migrate_user_key_recipients(p_conv_id, p_user_id, p_old_device_id, p_new_bundles)→ bulk INSERT newrecipient_user_idrows from re-wrapped bundles, idempotent viaON CONFLICT (conversation_id, recipient_user_id, key_version) DO NOTHING.
Data Flow
First-time setup
- User signs in (magic link / password).
fetchUserKeyBlob(userId)→null. - Routes to
UserKeySetup. User chooses 6-digit PIN. - Client generates X25519 keypair + 16-byte salt; derives KEK via Argon2id; seals the private key.
- 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.
- UI shows recovery code once. User picks "Habe ich gespeichert" or "Überspringen". Skip leaves
recovery_sealed_private_key NULL. - UPSERT
user_keysrow. - Cleartext private key cached in Stronghold (
chatapp.userpriv.<userId>).
Re-login on a fresh / wiped device
- Sign-in OK.
fetchUserKeyBlob(userId)returns row → routes toUserKeyUnlock. - User enters PIN. RPC
try_unlock_user_keyreturns ciphertext+salt (orlocked: true). - Client derives KEK + opens sealed key. On success:
record_pin_attempt(success=true)resets counter; cleartext key cached in Stronghold; navigate to/chats. - On failure:
record_pin_attempt(success=false)increments counter. UI shows remaining attempts. - On lockout: UI offers "Recovery-Code verwenden" tab. Same flow against
recovery_sealed_private_key+failed_recovery_attempts.
Sending a message (existing conversation)
getOrCreateConvKey(convId, ownUserCtx)looks upconversation_keysbyrecipient_user_id = me.- Bundle exists → unwrap with user private key → encrypt plaintext → send.
- Re-login works immediately: the user's
public_keyis unchanged across sessions, so all existing wrapped conv-keys remain valid.
New user joins a conversation
- Existing member loads
public_keyfromuser_public_keys. - 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:
- Setup flow runs (PIN, generate user keypair, upload
user_keys). - Migration pass:
- List all
conversation_keysrows whererecipient_device_idbelongs to one of the user's old devices. - Unwrap with the old device private key.
- Re-wrap for
recipient_user_id = mewith the new user public key. - Bulk-insert via
migrate_user_key_recipients(ON CONFLICT DO NOTHING).
- List all
- Old rows remain untouched; other members migrate independently in their own passes.
- Cross-member rewrap: when any member opens a conversation, the client lists members lacking a
recipient_user_idbundle and proactively wraps for them in the background. - After all active users have migrated (telemetry-tracked), a follow-up migration drops
recipient_device_id/sender_device_id.
PIN change (Settings)
- Prompt for current PIN → unlock locally (without server round-trip; we already have ciphertext+salt cached or can fetch).
- Generate new salt; reseal with new PIN-derived KEK.
- UPSERT
user_keysrow (samepublic_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_attemptincrements; lockout transitions.chat/convKeys.test.ts: tests refactored torecipient_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 legacyrecipient_device_idrows. Run migration pass. Assert: newrecipient_user_idrows 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 leavesrecovery_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):
- Fresh install, setup with recovery save.
- Fresh install, setup with recovery skip.
- Existing user with legacy conv-keys → silent migration → old chats readable.
- Existing user on second device: setup on A, sign-in on B with same PIN.
- Forgot PIN → recovery-code → unlock OK.
- PIN change in Settings.
- Identity reset → old chats unreadable for me, new chats functional.
- 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.