# Server List Sync: Developer Integration Guide ## What It Does Haven now stores an encrypted copy of each user's server list on every server they log into. When a user logs in from a new device (or after clearing browser data), their full server list is automatically restored. No manual re-adding. ## Why The server list was previously stored only in `localStorage` / client-side storage. Switching devices, clearing app data, or reinstalling meant manually re-adding every server. This was the #1 friction point for multi-server users. ## How It Works ### The Flow 1. **On login** (password entry required, not auto-login/JWT refresh): - Client derives a wrapping key from the password using `HavenE2E.deriveWrappingKey(password)` (this already happens for E2E encryption) - Client calls `GET /api/auth/user-servers` → receives an encrypted blob (or null) - Client decrypts the blob using AES-256-GCM with the wrapping key - Client merges the decrypted server list with its local list (union by URL) - If the merged list differs from what the server had, client re-encrypts and calls `PUT /api/auth/user-servers` 2. **On adding/removing a server:** - Client updates local storage as before - Client re-encrypts the full list and pushes to the current server via `PUT /api/auth/user-servers` 3. **On password change:** - Client re-encrypts the server list blob with the new password-derived key (same as E2E key re-wrapping) ### Multi-Device Convergence The server list converges across devices passively: - DeviceA adds ServerD → pushes to ServerA - DeviceB logs into ServerA → pulls the updated list → now has ServerD - DeviceB visits ServerB → pushes the merged list → ServerB is updated too - No server-to-server communication ever occurs ### Removal Handling Removals are **local-only**. When a user removes a server: - The URL is added to a local `haven_servers_removed` set (stored in localStorage / app storage) - The server is removed from the local list - The updated (shorter) list is pushed to the server - Remote blobs on other servers may still contain the removed URL, but the local removed-set prevents it from reappearing after merge --- ## API Endpoints Both endpoints are on the auth router (`/api/auth/`), protected by JWT. ### `GET /api/auth/user-servers` **Headers:** `Authorization: Bearer ` **Response:** ```json { "blob": "" } ``` or ```json { "blob": null } ``` ### `PUT /api/auth/user-servers` **Headers:** `Authorization: Bearer `, `Content-Type: application/json` **Body:** ```json { "blob": "" } ``` **Constraints:** Blob must be a string, max 65536 characters. **Response:** ```json { "ok": true } ``` --- ## Encryption Format ### Key Derivation The wrapping key is the same one used for E2E DM encryption: ``` password (plaintext) → PBKDF2(SHA-256, salt="haven-e2e-wrapping-v3", iterations=210000) → 256 bits → hex string (64 chars) ``` This hex string is what `HavenE2E.deriveWrappingKey(password)` returns. The Android app likely already computes this for E2E; reuse it. ### Blob Encryption The blob stored on the server is: `base64(salt + iv + ciphertext)` ``` wrappingHex (64-char hex string) → convert to 32 raw bytes → PBKDF2(SHA-256, salt=, iterations=100000) → AES-256-GCM key plaintext = JSON.stringify(serverList) iv = 12 random bytes ciphertext = AES-GCM-encrypt(key, iv, plaintext) blob = base64(salt[16] + iv[12] + ciphertext[...]) ``` ### Blob Decryption ``` raw = base64decode(blob) salt = raw[0..15] (16 bytes) iv = raw[16..27] (12 bytes) ct = raw[28..] (remaining) key = PBKDF2(SHA-256, wrappingHexBytes, salt, 100000) → AES-256-GCM key plaintext = AES-GCM-decrypt(key, iv, ct) serverList = JSON.parse(plaintext) ``` ### Plaintext Format The decrypted JSON is an array of server objects: ```json [ { "url": "https://haven.example.com", "name": "My Server", "icon": "https://haven.example.com/uploads/icon.png", "addedAt": 1712937600000 } ] ``` Only `url` is required. `name`, `icon`, and `addedAt` are optional metadata. --- ## Merge Logic The merge is a **union by URL**: ``` localUrls = set of URLs from local storage remoteUrls = set of URLs from decrypted blob removedUrls = set of URLs the user has explicitly removed (local-only) for each remote server: if URL not in localUrls AND URL not in removedUrls: add to local list if merged list != remote list: re-encrypt and push ``` This is commutative and idempotent: order of operations doesn't matter, and running it twice produces the same result. --- ## Integration Checklist for Android 1. **Compute the wrapping key** from the password at login (you probably already do this for E2E): ``` PBKDF2(SHA-256, password, "haven-e2e-wrapping-v3", 210000) → 32 bytes → hex ``` 2. **After login**, call `GET /api/auth/user-servers` with the JWT 3. **If blob is non-null**, decrypt it using the format above 4. **Merge** with the app's local server list (union by URL, excluding removed servers) 5. **If changed**, re-encrypt and `PUT /api/auth/user-servers` 6. **On add/remove server**, re-encrypt the full list and push 7. **On password change**, re-encrypt with the new wrapping key and push 8. **Store removed-server URLs locally** (app preferences / local DB) so they don't reappear from stale blobs --- ## Security Notes - The server admin **cannot read** the server list: it's encrypted with the user's password - AES-GCM is authenticated: tampered blobs fail decryption silently (client falls back to local list) - No server-to-server communication exists; servers are completely unaware of each other - The wrapping key never leaves the client device