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

17 KiB
Raw Blame History

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_idrecipient_user_id, sender_device_idsender_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_idrecipient_user_id, sender_device_idsender_user_id. listDeviceKeys() becomes listMemberPublicKeys() (joins conversation_membersuser_keys). OwnDeviceCtxOwnUserCtx { 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 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.