# rSpace Data Architecture — Layered Local-First Model > **Status:** Implemented (Layers 0-2), Designed (Layer 3), Deferred (P2P) > **Last updated:** 2026-03-02 ## Overview rSpace uses a 4-layer data architecture where plaintext only exists on the user's device. Each layer adds availability and collaboration capabilities while maintaining zero-knowledge guarantees for encrypted spaces. ``` Layer 3: Federated Replication (future — user-owned VPS) Layer 2: Shared Space Sync (collaboration — participant + relay mode) Layer 1: Encrypted Server Backup (zero-knowledge — cross-device restore) Layer 0: User's Device (maximum privacy — plaintext only here) ``` --- ## Layer 0: User's Device (Maximum Privacy) The only place plaintext exists for encrypted spaces. ### Storage - **IndexedDB** via `EncryptedDocStore` — per-document AES-256-GCM encryption at rest - Database: `rspace-docs` with object stores: `docs`, `meta`, `sync` ### Key Hierarchy ``` WebAuthn PRF output (from passkey) → HKDF (salt: "rspace-space-key-v1", info: "rspace:{spaceId}") → Space Key (HKDF) → HKDF (salt: "rspace-doc-key-v1", info: "doc:{docId}") → Doc Key (AES-256-GCM, non-extractable) ``` ### Encryption - `DocCrypto` class handles all key derivation and AES-256-GCM operations - 12-byte random nonce per encryption - Keys are non-extractable `CryptoKey` objects (Web Crypto API) - `EncryptedDocBridge` connects WebAuthn PRF to DocCrypto ### Implementation - `shared/local-first/crypto.ts` — DocCrypto - `shared/local-first/storage.ts` — EncryptedDocStore - `shared/local-first/encryptid-bridge.ts` — PRF-to-DocCrypto bridge --- ## Layer 1: Encrypted Server Backup (Zero-Knowledge) Server stores opaque ciphertext blobs it cannot decrypt. ### Design Principles - **Opt-in per user** (default OFF for maximum privacy) - **Same encryption as Layer 0** — client encrypts before upload - **Delta-only push** — compare local manifest vs server manifest, upload only changed docs - **Cross-device restore** — after passkey auth, download all blobs, decrypt locally ### Storage Layout ``` /data/backups/{userId}/{spaceSlug}/{docId-hash}.enc /data/backups/{userId}/{spaceSlug}/manifest.json ``` ### API ``` PUT /api/backup/:space/:docId — upload encrypted blob (10 MB limit) GET /api/backup/:space/:docId — download encrypted blob GET /api/backup/:space — list manifest DELETE /api/backup/:space/:docId — delete specific backup DELETE /api/backup/:space — delete all for space GET /api/backup/status — overall backup status ``` All endpoints require EncryptID JWT authentication. ### Client - `BackupSyncManager` reads already-encrypted blobs from IndexedDB (no double-encryption) - Auto-backup on configurable interval (default: 5 minutes) - `pushBackup()` — delta-only upload - `pullRestore()` — full download for new devices ### Implementation - `server/local-first/backup-store.ts` — filesystem blob storage - `server/local-first/backup-routes.ts` — Hono REST API - `shared/local-first/backup.ts` — BackupSyncManager --- ## Layer 2: Shared Space Sync (Collaboration) Multi-document real-time sync over WebSocket. ### Two Operating Modes #### Participant Mode (unencrypted spaces) - Server maintains its own copy of each Automerge document - Full Automerge sync protocol — `receiveSyncMessage` + `generateSyncMessage` - Server can read, index, validate, and persist documents - Documents saved as Automerge binary at `/data/docs/{space}/{module}/{collection}/{itemId}.automerge` #### Relay Mode (encrypted spaces) - Server forwards encrypted sync messages between peers by `docId` - Server cannot read document content - Opaque backup blobs stored via `relay-backup` / `relay-restore` wire protocol - Stored as `.automerge.enc` files alongside regular docs ### At-Rest Encryption for Module Docs When a space has `meta.encrypted === true`, module documents are encrypted at rest using the server-side encryption utilities (HMAC-SHA256 derived AES-256-GCM from `ENCRYPTION_SECRET`). File format (rSEN): ``` [4 bytes: magic "rSEN" (0x72 0x53 0x45 0x4E)] [4 bytes: keyId length (uint32)] [N bytes: keyId (UTF-8)] [12 bytes: IV] [remaining: ciphertext + 16-byte auth tag] ``` ### Wire Protocol ``` { type: 'sync', docId, data: number[] } { type: 'subscribe', docIds: string[] } { type: 'unsubscribe', docIds: string[] } { type: 'awareness', docId, peer, cursor?, selection?, username?, color? } { type: 'relay-backup', docId, data: number[] } — client → server (opaque blob) { type: 'relay-restore', docId, data: number[] } — server → client (stored blob) { type: 'ping' } / { type: 'pong' } ``` ### Implementation - `shared/local-first/sync.ts` — DocSyncManager (client) - `server/local-first/sync-server.ts` — SyncServer (server) - `server/local-first/doc-persistence.ts` — filesystem persistence with encryption - `server/local-first/encryption-utils.ts` — shared server-side AES-256-GCM primitives - `server/sync-instance.ts` — SyncServer singleton with encryption wiring --- ## Layer 3: Federated Replication (Future) Optional replication to user's own infrastructure. ### Design (Not Yet Implemented) - Same zero-knowledge blobs as Layer 1 - User configures a replication target (their own VPS, S3 bucket, etc.) - Server pushes encrypted blobs to the target on change - User can restore from their own infrastructure independently of rSpace ### Prerequisites - Layer 1 must be proven stable - User-facing configuration UI - Replication protocol specification --- ## P2P WebRTC Sync (Future) Direct peer-to-peer sync as fallback when server is unavailable. ### Design (Not Yet Implemented) - WebRTC data channels between clients - Signaling via existing WebSocket connection - Same Automerge sync protocol as Layer 2 - Useful for: LAN-only operation, server downtime, low-latency collaboration ### Prerequisites - Layer 1 backup solves the primary resilience concern - WebRTC signaling server or STUN/TURN infrastructure --- ## Threat Model ### What the server knows (unencrypted spaces) - Full document content (participant mode) - Document metadata, sync state, member list ### What the server knows (encrypted spaces) - Space exists, number of documents, document sizes - Member DIDs (from community doc metadata) - Timing of sync activity (when peers connect/disconnect) ### What the server CANNOT know (encrypted spaces) - Document content (encrypted at rest, relay mode) - Backup blob content (client-encrypted before upload) - Encryption keys (derived from WebAuthn PRF on device) ### Compromised server scenario - Attacker gets ciphertext blobs — cannot decrypt without passkey - Attacker modifies ciphertext — AES-GCM auth tag detects tampering - Attacker deletes blobs — client has local copy in IndexedDB (Layer 0) ### Compromised device scenario - Plaintext exposed on that device only - Other devices are unaffected (no key sharing between devices) - Passkey revocation invalidates future PRF derivations --- ## Key Rotation ### Current Approach - Server-side at-rest keys derived from `ENCRYPTION_SECRET` + keyId - `keyId` stored in community doc `meta.encryptionKeyId` - Rotation: generate new keyId → re-encrypt all docs → update meta ### Future Approach (with EncryptID Layer 2) - Client-side key delegation via EncryptID key hierarchy - Server never has access to plaintext keys - Rotation managed by space admin through EncryptID --- ## Data Flow Diagrams ### Normal Operation (Unencrypted Space) ``` Client A Server Client B | | | |-- sync(docId, data) ---->| | | |-- sync(docId, data) ---->| | |-- saveDoc(docId) ------->| disk |<-- sync(docId, resp) ----| | ``` ### Relay Mode (Encrypted Space) ``` Client A Server Client B | | | |-- sync(docId, data) ---->| | | |-- sync(docId, data) ---->| (forwarded) |-- relay-backup --------->| | | |-- save .enc blob ------->| disk ``` ### Backup Restore (New Device) ``` New Device Server Backup Store | | | |-- GET /api/backup/space->| | |<-- manifest -------------| | |-- GET /api/backup/doc -->|-- load blob ------------>| |<-- encrypted blob -------|<-- blob bytes -----------| | | | | (client decrypts with passkey, writes to IndexedDB) | ```