-
Notifications
You must be signed in to change notification settings - Fork 8
Description
⚠️ AI-Generated: May contain errors. Verify before use.
Reviewed by feature-analyzer agent, security-analyst agent, cryptographer agent
Soft-review by human
What & Why
Problem: Users lose critical local data if they:
- Clear browser cache/data
- Lose/reset their device
- Get forced to re-authenticate (Safari passkey bug)
- Uninstall the mobile app
Current State:
- Messages exist ONLY on peer devices (P2P architecture) - recoverable via sync IF another peer online
- DM encryption states (Double Ratchet) are LOCAL ONLY with NO recovery path - this is the critical gap
- Space encryption states (Triple Ratchet) sync via
spaceKeysin UserConfig IFallowSync=true - User config (space memberships, bookmarks, spaceKeys) syncs to API IF
allowSync=true
Why This Matters:
- DM history is permanently unrecoverable without backup - the Double Ratchet state cannot be reconstructed
- Users experiencing the Safari passkey bug must clear cache → lose DM decryption capability forever
- Single-device users with
allowSync=falsehave no redundancy for ANYTHING
Context
Current Data Storage Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ DATA LOCATION MATRIX │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ✅ RECOVERABLE (via API/sync): │
│ ├── spaces → Manifest on API: getSpaceManifest() │
│ ├── space_keys → Via UserConfig.spaceKeys sync │
│ ├── user_config → API sync if allowSync=true │
│ ├── bookmarks → Via UserConfig.bookmarks sync │
│ ├── space_members → Re-fetchable from hub │
│ └── Space messages → P2P sync from other members │
│ │
│ ⚠️ UNRECOVERABLE (DM data - LOCAL ONLY): │
│ ├── DM messages → No hub, no peer sync, deleted after fetch │
│ ├── DM conversations → Metadata for DMs │
│ └── encryption_states → Double Ratchet state for DM decryption │
│ │
│ 📋 Other local stores (reconstructable/transient): │
│ ├── action_queue → Transient task queue │
│ ├── user_info → Re-fetchable from API │
│ ├── inbox_mapping → Reconstructable from conversations │
│ ├── conversation_users → Re-fetchable profiles │
│ ├── latest_states → Derived from encryption_states │
│ └── deleted_messages → Sync dedup tombstones │
│ │
│ 🔑 Key Storage: │
│ └── WebAuthn/Passkey → Ed448 private key (or IndexedDB fallback) │
│ │
│ ⚠️ CRITICAL: DM data has NO sync mechanism - backup is the ONLY way │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Existing Encryption Pattern
From ConfigService.ts - user config is encrypted with:
User Ed448 Private Key → SHA-512 → First 32 bytes → AES-256-GCM key
This same pattern can encrypt backups - only the user with their private key can decrypt.
Recovery Scenarios Today (Without Backup)
| Scenario | Spaces | Space Keys | Space Messages | DM Messages | DM Encryption |
|---|---|---|---|---|---|
Clear cache, allowSync=true |
✅ API | ✅ UserConfig | ✅ Peer sync | ❌ LOST | ❌ LOST |
Clear cache, allowSync=false |
❌ Lost | ❌ Lost | ❌ Lost | ❌ LOST | ❌ LOST |
New device, import key, allowSync=true |
✅ API | ✅ UserConfig | ✅ Peer sync | ❌ LOST | ❌ LOST |
| Safari passkey bug workaround | ✅ API | ✅ UserConfig | ✅ Peer sync | ❌ LOST | ❌ LOST |
Key insight: For users with allowSync=true, only DM data is unrecoverable. Everything else syncs.
Understanding the Encryption Protocols
Space Messages (Triple Ratchet):
- Encryption state syncs via
spaceKeys[].encryptionStatein UserConfig - New device can decrypt Space messages IF it receives the ratchet state from sync
- All members share the same session state
DM Messages (Double Ratchet):
- Encryption state is per-device, per-contact-inbox
- Each device has SEPARATE sessions with each of the other party's devices
- The
encryption_statesIndexedDB store contains these sessions - No mechanism exists to sync or backup these states
- Without the exact ratchet state, past DMs are mathematically impossible to decrypt
Implementation
MVP: Export Backup Only (Settings)
Minimal viable feature - just export. Test that the backup file is valid before building restore.
-
Add DM export method to MessageDB (
src/db/messages.ts)getAllDMMessages()- returns all DM messages (excludes Space messages)- Implementation: Get all
type: 'direct'conversations, then fetch messages for each - Space messages are excluded because they resync from other members
- DM messages cannot resync (no hub, no peer sync) - must be backed up
- Note:
getConversations({ type: 'direct' })already exists, just needs high limit
-
Create BackupService (
src/services/BackupService.ts)- Follow same pattern as ConfigService (keyset passed to methods, used transiently, never stored)
- Constructor receives:
{ messageDB: MessageDB } exportBackup({ keyset })method collects and encrypts data- Collect only unrecoverable data (keeps backup small and focused)
- Include (DM data only - the ONLY unrecoverable data):
messages(DMs only) - no hub, no peer sync, truly lost without backupconversations(type: 'direct'only) - DM metadataencryption_states- critical for decrypting DM history (Double Ratchet state)
- Exclude (all recoverable via sync or API):
messages(Space) - resync from other space membersconversations(type: 'group') - Space conversations, recoverablespaces- manifest stored on API server, recoverable viagetSpaceManifest()user_config- syncs via API ifallowSync=truespace_keys- syncs via UserConfig.spaceKeysbookmarks- syncs via UserConfig.bookmarksaction_queue- transient task queueuser_info- re-fetchable from APIinbox_mapping- reconstructable from conversationsconversation_users- re-fetchable from API (cached profiles)latest_states- derived from encryption_states (performance cache)muted_users- low priority, can be re-applied manuallydeleted_messages- only for sync dedup, not user dataspace_members- re-fetchable from hub
-
Implement backup encryption
- Use domain-separated key derivation:
SHA-512('quorum-backup-v1' + privateKey)[0:32] - File format:
{ version: 1, iv: hex, ciphertext: hex, createdAt: timestamp, signature: hex } - Sign ALL fields:
version || iv || ciphertext || createdAt - Verify signature BEFORE decryption on import
- Use domain-separated key derivation:
-
Add "Export Backup" button (
UserSettingsModal/PrivacySecurity.tsx)- Near existing "Export Key" button
- Filename:
quorum_backup_YYYYMMDD_HHMMSS_XXXXXX.qmbak(XXXXXX = last 6 chars of user address) - Disable button while exporting (
isProcessingstate)
MVP Done when: User can click button → encrypted .qmbak file downloads
Phase 2: Import Backup (Settings)
Add ability to restore messages from backup while logged in.
- Add "Import Backup" button (next to Export)
- Implement decryption and validation
- Verify signature first
- Check
version: 1 - Decrypt with user's key
- Restore data to IndexedDB
- Merge messages by messageId (no duplicates)
- Skip encryption_states (user has active sessions)
- Invalidate React Query cache
- Show success message
Phase 3: Restore on Login Screen
For fresh device / cache cleared scenarios.
- Add "Restore from Backup" option (
Login.tsx) - Flow:
- User imports their Ed448
.keyfile (from "Export Key" feature) - App derives AES key from imported Ed448 key
- User selects
.qmbakbackup file - App decrypts backup with derived AES key
- App restores ALL data (including encryption_states - safe on fresh device)
- User imports their Ed448
- Complete onboarding after restore
Note: User MUST have their .key file to decrypt the backup. The backup is encrypted with a key derived from the Ed448 private key.
Phase 4: Smart Merge (Later)
- Message deduplication by messageId
- Keep newer version on conflict
- Conversation metadata merge
Edge Cases
| Scenario | Expected Behavior | Risk | Mitigation |
|---|---|---|---|
| Restore old backup on device with newer DM states | User prompted to skip encryption_states | 🚨 High - breaks DM | Never overwrite newer states |
| Backup file corrupted | Show error, abort restore | Medium | Validate checksum/structure |
| Backup from different account | Decrypt fails (wrong key) | Low | Key derivation ensures this |
| Very large backup (years of messages) | May timeout or fail | Medium | Chunk processing, progress UI |
| Restore on device with partial sync | Merge needed | High | Deduplicate by messageId |
| User restores then peer syncs same messages | Duplicates possible | Medium | Dedupe on messageId |
| Single-member space messages lost | Space recovers, messages don't | Low | |
| Abandoned space (all peers offline) | Space recovers, messages can't sync | Low |
Limitations: Space Messages Not Backed Up
By design, this backup feature focuses on DM data only. Space messages are excluded because:
- They typically resync from other members via P2P
- Including them would make backups potentially huge (thousands of messages)
Edge cases NOT covered:
- Single-member spaces: If user creates a space, posts messages, and loses device before anyone joins → messages are lost (space definition recovers via API)
- Abandoned spaces: If all other members are permanently offline → messages can't sync back
Why this is acceptable for MVP:
- These scenarios are rare (most spaces have active members)
- Space definitions still recover (just not messages)
- Users can mitigate by inviting members or using DMs for critical 1:1 content
- Future enhancement: Optional "Include Space messages" checkbox for users with single-member spaces
DM Encryption State Conflict - Detailed Analysis
The Double Ratchet protocol advances with EVERY message. This creates serious backup/restore challenges:
Scenario: User has Device A (active) and creates backup.
User gets Device B, imports key, sends new DMs from Device B.
User loses Device B, restores backup from Device A to Device C.
Problem: Backup has OLD ratchet states. Device B advanced the ratchet.
What happens:
┌─────────────────────────────────────────────────────────────────────────┐
│ Timeline: │
│ │
│ Device A: state=S1 ──[backup]──────────────────────[restore to C] │
│ ↓ │
│ Device B: state=S1 → S2 → S3 → S4 (new DMs)──[lost] state=S1 (old!) │
│ │
│ Result on Device C: │
│ - Messages sent before backup (S1): ✅ Can decrypt │
│ - Messages sent from Device B (S2-S4): ❌ Cannot decrypt │
│ - New messages after restore: ⚠️ Ratchet desync with counterparty │
└─────────────────────────────────────────────────────────────────────────┘
The counterparty's device advanced to expect state S4.
Device C is at state S1. They can no longer communicate properly.
Key insight: Unlike Space encryption (Triple Ratchet) which has a shared session, DM encryption (Double Ratchet) creates device-specific sessions. Restoring an old state doesn't just lose history - it breaks future communication.
Mitigation strategies:
- On fresh device: Safe to restore (no existing states, counterparty will reinitialize)
- On device with states: NEVER overwrite - compare timestamps, keep newer
- Best practice: Create fresh backup AFTER each DM session, BEFORE switching devices
- Consider: Re-initialize DM sessions after restore (loses history but fixes sync)
Verification
MVP Testing
- Export creates
.qmbakfile - File is encrypted (not readable as plaintext)
- File contains signature
- File size is reasonable (not empty)
Phase 2+ Testing
- Import decrypts and restores messages
- Wrong key fails gracefully
- Duplicate messages handled
- Encryption states skipped when logged in
Definition of Done
MVP
- BackupService with exportBackup() method
- Export button in Settings
- Encrypted .qmbak file downloads
- No TypeScript errors
Full Feature
- Import in Settings (Phase 2)
- Restore on Login (Phase 3)
- Message deduplication (Phase 4)
Security Requirements
Reviewed by human + cryptographic expert
Based on security analysis, these requirements should be addressed during implementation:
Critical (Must Fix in MVP)
-
Domain separation in key derivation
- Do NOT reuse the same key derivation as UserConfig sync
- Add context string to prevent cross-protocol attacks:
SHA-512('quorum-backup-v1' + privateKey) → First 32 bytes → AES key- Different cryptographic contexts MUST use different derived keys
-
Sign ALL fields including version and IV
- Sign:
version || iv || ciphertext || createdAt - Verify signature BEFORE attempting decryption
- Prevents version field manipulation and detects tampering early
- File format:
{ version, iv, ciphertext, createdAt, signature }
- Sign:
-
Backup file size limit
- Add
MAX_BACKUP_SIZE = 100MBcheck before processing - Prevents DoS via multi-GB files
- Check size BEFORE attempting decryption
- Add
High (Should Fix)
-
Backup versioning
- Include
version: 1in backup format - Reject unknown versions with clear error
- Migration strategy can be added later when needed
- Include
-
Disable buttons during operations
- Disable export/import buttons while either operation is in progress
- Simple
isProcessingstate flag
-
Replay protection for restore
- Warn user when restoring backup older than current data
- Compare
backup.createdAtvs latest local message timestamp - User must confirm to proceed with older backup
Future Considerations (Out of Scope)
- Automatic periodic backups - Save to local file system automatically
- Cloud backup integration - Optional encrypted backup to user's cloud storage
- Incremental backups - Only backup changes since last backup
- Cross-platform restore - Restore desktop backup on mobile (needs quorum-shared integration)
- Server-side encrypted backup - Store encrypted backup blob on API (user-controlled)
- Large backup handling - Size estimation, chunked processing, compression, progress UI (not needed until file/video uploads)
- Post-export verification - Decrypt backup after creation to verify integrity (AES-GCM already provides integrity, so this is extra paranoia)
Related Documentation
- Data Management Architecture - IndexedDB schema
- Config Sync System - Encryption pattern to reuse
- Cryptographic Architecture - Key hierarchy
- Passkey Authentication Flow - Key export logic
- Safari Passkey Bug - Motivation for this feature