Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: PDS E2EE document storage and intra-PDS sharing (v0)

Adds end-to-end encrypted document storage on the user's AT Proto PDS
with support for sharing between users on the same PDS instance.

Architecture:
- X25519 identity keypair per user (IndexedDB + passphrase-encrypted backup on PDS)
- Document keys wrapped via ECDH + HKDF + AES-256-GCM per-recipient
- Self-share pattern enables cross-device access via any signed-in session
- All document content stored as opaque encrypted blobs on PDS
- Only metadata (type, name, timestamps) visible to PDS operator

New modules:
- identity-keys.ts: X25519 key generation, storage, wrapping, backup/restore
- pds-sync.ts: Low-level AT Proto CRUD for 4 lexicon collections
- pds-documents.ts: High-level orchestration (setup, save, load, share, discover)
- pds-setup.ts: Boot-time identity check and setup/recovery flow
- pds-pull-sync.ts: Pull remote documents missing from local IndexedDB
- pds-share.ts: Share documents with other users by handle

Integration:
- Provider saves trigger non-blocking PDS sync after local IndexedDB write
- Boot sequence checks PDS identity after auth, pulls remote docs before render
- Feature-gated behind instance-info sync/sharing flags

+1440 -2
+9 -1
src/landing.ts
··· 341 341 342 342 // --- Init --- 343 343 initUsername(eventDeps); 344 - ensureWrappingKey().then(() => syncKeys()).then(() => loadDocuments()); 344 + ensureWrappingKey() 345 + .then(() => syncKeys()) 346 + .then(async () => { 347 + const { ensurePdsIdentity } = await import('./lib/pds-setup.js'); 348 + await ensurePdsIdentity(); 349 + const { pullRemoteDocuments } = await import('./lib/pds-pull-sync.js'); 350 + await pullRemoteDocuments(); 351 + }) 352 + .then(() => loadDocuments()); 345 353 346 354 // --- Handle PWA shortcut actions (?action=new-doc, etc.) --- 347 355 const urlAction = new URLSearchParams(window.location.search).get('action');
+337
src/lib/identity-keys.ts
··· 1 + /** 2 + * X25519 identity key management for E2EE document sharing. 3 + * 4 + * Each user has a long-lived X25519 keypair: 5 + * - Public key: published as an AT Proto record (discoverable by others) 6 + * - Private key: stored in IndexedDB locally + encrypted backup on PDS 7 + * 8 + * Document keys are wrapped/unwrapped via ECDH key agreement: 9 + * - Generate ephemeral X25519 keypair 10 + * - ECDH(ephemeral private, recipient public) → shared secret 11 + * - HKDF(shared secret) → AES-256-GCM wrapping key 12 + * - Wrap document AES key with wrapping key 13 + */ 14 + 15 + import { bufToBase64url, base64urlToBuf } from './crypto.js'; 16 + 17 + const IDENTITY_KEY_STORE = 'identity-keys'; 18 + const IDENTITY_KEY_ID = 'primary'; 19 + const HKDF_INFO = new TextEncoder().encode('atmosphere-office-key-wrap-v1'); 20 + const PBKDF2_ITERATIONS = 600_000; 21 + 22 + export interface IdentityKeyPair { 23 + publicKey: CryptoKey; 24 + privateKey: CryptoKey; 25 + } 26 + 27 + export interface WrappedDocumentKey { 28 + ephemeralPublicKey: string; 29 + wrappedKey: string; 30 + } 31 + 32 + export interface KeyBackup { 33 + encryptedPrivateKey: string; 34 + salt: string; 35 + iterations: number; 36 + algorithm: string; 37 + } 38 + 39 + let _db: IDBDatabase | null = null; 40 + 41 + function openIdentityDb(): Promise<IDBDatabase> { 42 + if (_db) return Promise.resolve(_db); 43 + 44 + return new Promise((resolve, reject) => { 45 + const request = indexedDB.open('atmos-identity', 1); 46 + 47 + request.onupgradeneeded = () => { 48 + const db = request.result; 49 + if (!db.objectStoreNames.contains(IDENTITY_KEY_STORE)) { 50 + db.createObjectStore(IDENTITY_KEY_STORE, { keyPath: 'id' }); 51 + } 52 + }; 53 + 54 + request.onsuccess = () => { 55 + _db = request.result; 56 + _db.onclose = () => { _db = null; }; 57 + resolve(_db); 58 + }; 59 + 60 + request.onerror = () => reject(request.error); 61 + }); 62 + } 63 + 64 + export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> { 65 + const keyPair = await crypto.subtle.generateKey( 66 + { name: 'X25519' }, 67 + true, 68 + ['deriveKey', 'deriveBits'], 69 + ) as CryptoKeyPair; 70 + 71 + return { publicKey: keyPair.publicKey, privateKey: keyPair.privateKey }; 72 + } 73 + 74 + export async function storeIdentityKeyPair(keyPair: IdentityKeyPair): Promise<void> { 75 + const db = await openIdentityDb(); 76 + const tx = db.transaction(IDENTITY_KEY_STORE, 'readwrite'); 77 + const store = tx.objectStore(IDENTITY_KEY_STORE); 78 + 79 + store.put({ 80 + id: IDENTITY_KEY_ID, 81 + publicKey: keyPair.publicKey, 82 + privateKey: keyPair.privateKey, 83 + }); 84 + 85 + await new Promise<void>((resolve, reject) => { 86 + tx.oncomplete = () => resolve(); 87 + tx.onerror = () => reject(tx.error); 88 + }); 89 + } 90 + 91 + export async function getIdentityKeyPair(): Promise<IdentityKeyPair | null> { 92 + const db = await openIdentityDb(); 93 + const tx = db.transaction(IDENTITY_KEY_STORE, 'readonly'); 94 + const store = tx.objectStore(IDENTITY_KEY_STORE); 95 + 96 + return new Promise((resolve, reject) => { 97 + const request = store.get(IDENTITY_KEY_ID); 98 + request.onsuccess = () => { 99 + const result = request.result; 100 + if (result) { 101 + resolve({ publicKey: result.publicKey, privateKey: result.privateKey }); 102 + } else { 103 + resolve(null); 104 + } 105 + }; 106 + request.onerror = () => reject(request.error); 107 + }); 108 + } 109 + 110 + export async function exportPublicKey(publicKey: CryptoKey): Promise<string> { 111 + const raw = await crypto.subtle.exportKey('raw', publicKey); 112 + return bufToBase64url(new Uint8Array(raw)); 113 + } 114 + 115 + export async function importPublicKey(b64: string): Promise<CryptoKey> { 116 + const raw = base64urlToBuf(b64); 117 + return crypto.subtle.importKey( 118 + 'raw', 119 + raw, 120 + { name: 'X25519' }, 121 + true, 122 + [], 123 + ); 124 + } 125 + 126 + export async function exportPrivateKey(privateKey: CryptoKey): Promise<Uint8Array> { 127 + const pkcs8 = await crypto.subtle.exportKey('pkcs8', privateKey); 128 + return new Uint8Array(pkcs8); 129 + } 130 + 131 + export async function importPrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> { 132 + return crypto.subtle.importKey( 133 + 'pkcs8', 134 + pkcs8, 135 + { name: 'X25519' }, 136 + true, 137 + ['deriveKey', 'deriveBits'], 138 + ); 139 + } 140 + 141 + /** 142 + * Wrap a document's AES-256-GCM key for a recipient. 143 + * Uses an ephemeral X25519 keypair for forward secrecy. 144 + */ 145 + export async function wrapDocumentKey( 146 + documentKey: CryptoKey, 147 + recipientPublicKey: CryptoKey, 148 + ): Promise<WrappedDocumentKey> { 149 + const ephemeral = await crypto.subtle.generateKey( 150 + { name: 'X25519' }, 151 + true, 152 + ['deriveKey', 'deriveBits'], 153 + ) as CryptoKeyPair; 154 + 155 + const sharedBits = await crypto.subtle.deriveBits( 156 + { name: 'X25519', public: recipientPublicKey }, 157 + ephemeral.privateKey, 158 + 256, 159 + ); 160 + 161 + const wrappingKey = await deriveWrappingKeyFromShared(new Uint8Array(sharedBits)); 162 + 163 + const rawDocKey = await crypto.subtle.exportKey('raw', documentKey); 164 + const iv = crypto.getRandomValues(new Uint8Array(12)); 165 + const ciphertext = await crypto.subtle.encrypt( 166 + { name: 'AES-GCM', iv }, 167 + wrappingKey, 168 + rawDocKey, 169 + ); 170 + 171 + const wrapped = new Uint8Array(12 + ciphertext.byteLength); 172 + wrapped.set(iv, 0); 173 + wrapped.set(new Uint8Array(ciphertext), 12); 174 + 175 + const ephemeralPubRaw = await crypto.subtle.exportKey('raw', ephemeral.publicKey); 176 + 177 + return { 178 + ephemeralPublicKey: bufToBase64url(new Uint8Array(ephemeralPubRaw)), 179 + wrappedKey: bufToBase64url(wrapped), 180 + }; 181 + } 182 + 183 + /** 184 + * Unwrap a document's AES-256-GCM key using your private key. 185 + */ 186 + export async function unwrapDocumentKey( 187 + wrapped: WrappedDocumentKey, 188 + myPrivateKey: CryptoKey, 189 + ): Promise<CryptoKey> { 190 + const ephemeralPub = await crypto.subtle.importKey( 191 + 'raw', 192 + base64urlToBuf(wrapped.ephemeralPublicKey), 193 + { name: 'X25519' }, 194 + true, 195 + [], 196 + ); 197 + 198 + const sharedBits = await crypto.subtle.deriveBits( 199 + { name: 'X25519', public: ephemeralPub }, 200 + myPrivateKey, 201 + 256, 202 + ); 203 + 204 + const wrappingKey = await deriveWrappingKeyFromShared(new Uint8Array(sharedBits)); 205 + 206 + const data = base64urlToBuf(wrapped.wrappedKey); 207 + const iv = data.slice(0, 12); 208 + const ciphertext = data.slice(12); 209 + 210 + const rawDocKey = await crypto.subtle.decrypt( 211 + { name: 'AES-GCM', iv }, 212 + wrappingKey, 213 + ciphertext, 214 + ); 215 + 216 + return crypto.subtle.importKey( 217 + 'raw', 218 + rawDocKey, 219 + { name: 'AES-GCM', length: 256 }, 220 + true, 221 + ['encrypt', 'decrypt'], 222 + ); 223 + } 224 + 225 + /** 226 + * Create an encrypted backup of the private key, protected by a passphrase. 227 + */ 228 + export async function createKeyBackup( 229 + privateKey: CryptoKey, 230 + passphrase: string, 231 + ): Promise<KeyBackup> { 232 + const salt = crypto.getRandomValues(new Uint8Array(16)); 233 + 234 + const keyMaterial = await crypto.subtle.importKey( 235 + 'raw', 236 + new TextEncoder().encode(passphrase), 237 + 'PBKDF2', 238 + false, 239 + ['deriveKey'], 240 + ); 241 + 242 + const wrappingKey = await crypto.subtle.deriveKey( 243 + { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, 244 + keyMaterial, 245 + { name: 'AES-GCM', length: 256 }, 246 + false, 247 + ['encrypt', 'decrypt'], 248 + ); 249 + 250 + const pkcs8 = await crypto.subtle.exportKey('pkcs8', privateKey); 251 + const iv = crypto.getRandomValues(new Uint8Array(12)); 252 + const ciphertext = await crypto.subtle.encrypt( 253 + { name: 'AES-GCM', iv }, 254 + wrappingKey, 255 + pkcs8, 256 + ); 257 + 258 + const encrypted = new Uint8Array(12 + ciphertext.byteLength); 259 + encrypted.set(iv, 0); 260 + encrypted.set(new Uint8Array(ciphertext), 12); 261 + 262 + return { 263 + encryptedPrivateKey: bufToBase64url(encrypted), 264 + salt: bufToBase64url(salt), 265 + iterations: PBKDF2_ITERATIONS, 266 + algorithm: 'PBKDF2-AES-256-GCM', 267 + }; 268 + } 269 + 270 + /** 271 + * Restore a private key from an encrypted backup using the passphrase. 272 + */ 273 + export async function restoreKeyFromBackup( 274 + backup: KeyBackup, 275 + passphrase: string, 276 + ): Promise<CryptoKey> { 277 + const salt = base64urlToBuf(backup.salt); 278 + 279 + const keyMaterial = await crypto.subtle.importKey( 280 + 'raw', 281 + new TextEncoder().encode(passphrase), 282 + 'PBKDF2', 283 + false, 284 + ['deriveKey'], 285 + ); 286 + 287 + const wrappingKey = await crypto.subtle.deriveKey( 288 + { name: 'PBKDF2', salt, iterations: backup.iterations, hash: 'SHA-256' }, 289 + keyMaterial, 290 + { name: 'AES-GCM', length: 256 }, 291 + false, 292 + ['encrypt', 'decrypt'], 293 + ); 294 + 295 + const data = base64urlToBuf(backup.encryptedPrivateKey); 296 + const iv = data.slice(0, 12); 297 + const ciphertext = data.slice(12); 298 + 299 + const pkcs8 = await crypto.subtle.decrypt( 300 + { name: 'AES-GCM', iv }, 301 + wrappingKey, 302 + ciphertext, 303 + ); 304 + 305 + return crypto.subtle.importKey( 306 + 'pkcs8', 307 + pkcs8, 308 + { name: 'X25519' }, 309 + true, 310 + ['deriveKey', 'deriveBits'], 311 + ); 312 + } 313 + 314 + export function closeIdentityDb(): void { 315 + if (_db) { 316 + _db.close(); 317 + _db = null; 318 + } 319 + } 320 + 321 + async function deriveWrappingKeyFromShared(sharedSecret: Uint8Array): Promise<CryptoKey> { 322 + const keyMaterial = await crypto.subtle.importKey( 323 + 'raw', 324 + sharedSecret, 325 + 'HKDF', 326 + false, 327 + ['deriveKey'], 328 + ); 329 + 330 + return crypto.subtle.deriveKey( 331 + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: HKDF_INFO }, 332 + keyMaterial, 333 + { name: 'AES-GCM', length: 256 }, 334 + false, 335 + ['encrypt', 'decrypt'], 336 + ); 337 + }
+1 -1
src/lib/key-passphrase.ts
··· 139 139 140 140 // --- Modal UI --- 141 141 142 - function showPassphraseModal(mode: 'setup' | 'unlock'): Promise<string | null> { 142 + export function showPassphraseModal(mode: 'setup' | 'unlock'): Promise<string | null> { 143 143 return new Promise((resolve) => { 144 144 const overlay = document.createElement('div'); 145 145 overlay.className = 'passphrase-overlay';
+20
src/lib/local-store.ts
··· 158 158 return doc; 159 159 } 160 160 161 + export async function putDocumentFromSync( 162 + id: string, 163 + data: { type: DocType; name: string; tags: string[]; snapshot: ArrayBuffer; created_at: string; updated_at: string }, 164 + ): Promise<void> { 165 + const doc: DocumentMeta = { 166 + id, 167 + type: data.type, 168 + name: data.name, 169 + snapshot: data.snapshot, 170 + created_at: data.created_at, 171 + updated_at: data.updated_at, 172 + deleted_at: null, 173 + tags: data.tags, 174 + }; 175 + 176 + const { tx: transaction, stores } = await tx(STORES.documents, 'readwrite'); 177 + stores[STORES.documents].put(doc); 178 + await txComplete(transaction); 179 + } 180 + 161 181 export async function getDocument(id: string): Promise<DocumentMeta | null> { 162 182 const { stores } = await tx(STORES.documents, 'readonly'); 163 183 const result = await req<DocumentMeta | undefined>(stores[STORES.documents].get(id));
+351
src/lib/pds-documents.ts
··· 1 + /** 2 + * PDS document orchestration — ties together local storage, identity keys, 3 + * and PDS sync into coherent user-facing operations. 4 + * 5 + * This is the high-level API that the UI calls. It coordinates: 6 + * - Identity key setup (first-login flow) 7 + * - Saving documents to PDS (encrypted blob + metadata + self-share) 8 + * - Loading documents from PDS (fetch blob, unwrap key, decrypt) 9 + * - Sharing documents with other users on the same PDS 10 + * - Syncing document list across devices 11 + */ 12 + 13 + import type { Agent } from '@atproto/api'; 14 + import { nanoid } from 'nanoid'; 15 + import { encrypt, decrypt, exportKey } from './crypto.js'; 16 + import { 17 + generateIdentityKeyPair, 18 + storeIdentityKeyPair, 19 + getIdentityKeyPair, 20 + exportPublicKey, 21 + importPublicKey, 22 + wrapDocumentKey, 23 + unwrapDocumentKey, 24 + createKeyBackup, 25 + restoreKeyFromBackup, 26 + type IdentityKeyPair, 27 + type WrappedDocumentKey, 28 + } from './identity-keys.js'; 29 + import { 30 + uploadEncryptedBlob, 31 + putDocument, 32 + listDocuments as pdsListDocuments, 33 + fetchEncryptedBlob, 34 + putShare, 35 + listSharesForSubject, 36 + publishPublicKey, 37 + publishKeyBackup, 38 + getPublicKey, 39 + getKeyBackup, 40 + deleteDocument as pdsDeleteDocument, 41 + deleteShare as pdsDeleteShare, 42 + listShares, 43 + COLLECTION, 44 + } from './pds-sync.js'; 45 + import { 46 + getDocument as getLocalDoc, 47 + getDocKey, 48 + storeDocKey, 49 + updateDocument, 50 + type DocType, 51 + type DocumentMeta, 52 + } from './local-store.js'; 53 + import { getInstanceInfo } from './instance-info.js'; 54 + 55 + export interface SyncableDocument { 56 + rkey: string; 57 + type: DocType; 58 + name: string; 59 + tags: string[]; 60 + createdAt: string; 61 + updatedAt: string; 62 + blobCid: string; 63 + } 64 + 65 + export type SetupStatus = 66 + | { state: 'no-session' } 67 + | { state: 'no-sync' } 68 + | { state: 'needs-setup' } 69 + | { state: 'needs-recovery'; hasBackup: boolean } 70 + | { state: 'ready'; keyPair: IdentityKeyPair }; 71 + 72 + /** 73 + * Determine the identity key setup status for the current session. 74 + */ 75 + export async function getSetupStatus(agent: Agent, did: string): Promise<SetupStatus> { 76 + const info = await getInstanceInfo(); 77 + if (!info.features.sync) return { state: 'no-sync' }; 78 + 79 + const localKeyPair = await getIdentityKeyPair(); 80 + if (localKeyPair) return { state: 'ready', keyPair: localKeyPair }; 81 + 82 + const backup = await getKeyBackup(agent, did); 83 + if (backup) return { state: 'needs-recovery', hasBackup: true }; 84 + 85 + return { state: 'needs-setup' }; 86 + } 87 + 88 + /** 89 + * First-time setup: generate identity keypair, publish public key and backup. 90 + */ 91 + export async function setupIdentity( 92 + agent: Agent, 93 + did: string, 94 + passphrase: string, 95 + ): Promise<IdentityKeyPair> { 96 + const keyPair = await generateIdentityKeyPair(); 97 + await storeIdentityKeyPair(keyPair); 98 + 99 + const pubKeyB64 = await exportPublicKey(keyPair.publicKey); 100 + await publishPublicKey(agent, did, pubKeyB64); 101 + 102 + const backup = await createKeyBackup(keyPair.privateKey, passphrase); 103 + await publishKeyBackup(agent, did, backup); 104 + 105 + return keyPair; 106 + } 107 + 108 + /** 109 + * Recover identity from PDS backup using passphrase. 110 + */ 111 + export async function recoverIdentity( 112 + agent: Agent, 113 + did: string, 114 + passphrase: string, 115 + ): Promise<IdentityKeyPair> { 116 + const backup = await getKeyBackup(agent, did); 117 + if (!backup) throw new Error('No key backup found on PDS'); 118 + 119 + const privateKey = await restoreKeyFromBackup(backup, passphrase); 120 + 121 + const pubKeyB64 = await getPublicKey(agent, did); 122 + if (!pubKeyB64) throw new Error('No public key found on PDS'); 123 + const publicKey = await importPublicKey(pubKeyB64); 124 + 125 + const keyPair = { publicKey, privateKey }; 126 + await storeIdentityKeyPair(keyPair); 127 + return keyPair; 128 + } 129 + 130 + /** 131 + * Save a document to the PDS (encrypt + upload blob + create record + self-share). 132 + * Call this after saving to IndexedDB locally. 133 + */ 134 + export async function saveDocumentToPds( 135 + agent: Agent, 136 + did: string, 137 + docId: string, 138 + ): Promise<{ uri: string; cid: string }> { 139 + const keyPair = await getIdentityKeyPair(); 140 + if (!keyPair) throw new Error('Identity not set up'); 141 + 142 + const localDoc = await getLocalDoc(docId); 143 + if (!localDoc) throw new Error(`Document ${docId} not found locally`); 144 + 145 + const docKey = await getDocKey(docId); 146 + if (!docKey) throw new Error(`No key for document ${docId}`); 147 + 148 + const snapshot = new Uint8Array(localDoc.snapshot); 149 + const encryptedBlob = await encrypt(snapshot, docKey); 150 + 151 + const blobRef = await uploadEncryptedBlob(agent, encryptedBlob); 152 + 153 + const rkey = docId; 154 + const { uri, cid } = await putDocument(agent, did, rkey, { 155 + type: localDoc.type, 156 + name: localDoc.name, 157 + blob: blobRef, 158 + tags: localDoc.tags, 159 + createdAt: localDoc.created_at, 160 + updatedAt: localDoc.updated_at, 161 + }); 162 + 163 + const existingShares = await listSharesForSubject(agent, did, did); 164 + const hasSelfShare = existingShares.some( 165 + s => s.value.document === uri || s.value.document.endsWith(`/${rkey}`), 166 + ); 167 + 168 + if (!hasSelfShare) { 169 + const wrapped = await wrapDocumentKey(docKey, keyPair.publicKey); 170 + const shareRkey = `self-${rkey}`; 171 + await putShare(agent, did, shareRkey, { 172 + documentUri: uri, 173 + subject: did, 174 + role: 'editor', 175 + wrappedKey: wrapped, 176 + }); 177 + } 178 + 179 + return { uri, cid }; 180 + } 181 + 182 + /** 183 + * Load a document from PDS that belongs to you (decrypt using self-share). 184 + */ 185 + export async function loadDocumentFromPds( 186 + agent: Agent, 187 + did: string, 188 + rkey: string, 189 + ): Promise<{ snapshot: Uint8Array; meta: SyncableDocument } | null> { 190 + const keyPair = await getIdentityKeyPair(); 191 + if (!keyPair) throw new Error('Identity not set up'); 192 + 193 + const doc = await import('./pds-sync.js').then(m => m.getDocument(agent, did, rkey)); 194 + if (!doc) return null; 195 + 196 + const shares = await listSharesForSubject(agent, did, did); 197 + const selfShare = shares.find( 198 + s => s.value.document.endsWith(`/${rkey}`), 199 + ); 200 + if (!selfShare) throw new Error(`No self-share found for document ${rkey}`); 201 + 202 + const wrapped: WrappedDocumentKey = { 203 + ephemeralPublicKey: selfShare.value.ephemeralPublicKey, 204 + wrappedKey: selfShare.value.wrappedKey, 205 + }; 206 + const docKey = await unwrapDocumentKey(wrapped, keyPair.privateKey); 207 + 208 + const encryptedBlob = await fetchEncryptedBlob(agent, did, doc.blob.ref.$link); 209 + const snapshot = await decrypt(encryptedBlob, docKey); 210 + 211 + await storeDocKey(rkey, docKey); 212 + 213 + return { 214 + snapshot, 215 + meta: { 216 + rkey, 217 + type: doc.type, 218 + name: doc.name, 219 + tags: doc.tags, 220 + createdAt: doc.createdAt, 221 + updatedAt: doc.updatedAt, 222 + blobCid: doc.blob.ref.$link, 223 + }, 224 + }; 225 + } 226 + 227 + /** 228 + * List all documents on the PDS for this user. 229 + */ 230 + export async function listPdsDocuments( 231 + agent: Agent, 232 + did: string, 233 + ): Promise<SyncableDocument[]> { 234 + const records = await pdsListDocuments(agent, did); 235 + return records.map(r => ({ 236 + rkey: r.rkey, 237 + type: r.value.type, 238 + name: r.value.name, 239 + tags: r.value.tags, 240 + createdAt: r.value.createdAt, 241 + updatedAt: r.value.updatedAt, 242 + blobCid: r.value.blob.ref.$link, 243 + })); 244 + } 245 + 246 + /** 247 + * Share a document with another user on the same PDS. 248 + */ 249 + export async function shareDocument( 250 + agent: Agent, 251 + ownerDid: string, 252 + docId: string, 253 + recipientDid: string, 254 + role: 'viewer' | 'editor' = 'editor', 255 + ): Promise<{ uri: string }> { 256 + const docKey = await getDocKey(docId); 257 + if (!docKey) throw new Error(`No key for document ${docId}`); 258 + 259 + const recipientPubKeyB64 = await getPublicKey(agent, recipientDid); 260 + if (!recipientPubKeyB64) { 261 + throw new Error(`Recipient ${recipientDid} has no encryption key published`); 262 + } 263 + const recipientPubKey = await importPublicKey(recipientPubKeyB64); 264 + 265 + const wrapped = await wrapDocumentKey(docKey, recipientPubKey); 266 + 267 + const documentUri = `at://${ownerDid}/${COLLECTION.document}/${docId}`; 268 + const shareRkey = nanoid(); 269 + const { uri } = await putShare(agent, ownerDid, shareRkey, { 270 + documentUri, 271 + subject: recipientDid, 272 + role, 273 + wrappedKey: wrapped, 274 + }); 275 + 276 + return { uri }; 277 + } 278 + 279 + /** 280 + * Load a document shared with you by another user. 281 + */ 282 + export async function loadSharedDocument( 283 + agent: Agent, 284 + myDid: string, 285 + ownerDid: string, 286 + rkey: string, 287 + ): Promise<{ snapshot: Uint8Array; meta: SyncableDocument } | null> { 288 + const keyPair = await getIdentityKeyPair(); 289 + if (!keyPair) throw new Error('Identity not set up'); 290 + 291 + const doc = await import('./pds-sync.js').then(m => m.getDocument(agent, ownerDid, rkey)); 292 + if (!doc) return null; 293 + 294 + const shares = await listSharesForSubject(agent, ownerDid, myDid); 295 + const myShare = shares.find(s => s.value.document.endsWith(`/${rkey}`)); 296 + if (!myShare) throw new Error(`No share grant found for document ${rkey}`); 297 + 298 + const wrapped: WrappedDocumentKey = { 299 + ephemeralPublicKey: myShare.value.ephemeralPublicKey, 300 + wrappedKey: myShare.value.wrappedKey, 301 + }; 302 + const docKey = await unwrapDocumentKey(wrapped, keyPair.privateKey); 303 + 304 + const encryptedBlob = await fetchEncryptedBlob(agent, ownerDid, doc.blob.ref.$link); 305 + const snapshot = await decrypt(encryptedBlob, docKey); 306 + 307 + return { 308 + snapshot, 309 + meta: { 310 + rkey, 311 + type: doc.type, 312 + name: doc.name, 313 + tags: doc.tags, 314 + createdAt: doc.createdAt, 315 + updatedAt: doc.updatedAt, 316 + blobCid: doc.blob.ref.$link, 317 + }, 318 + }; 319 + } 320 + 321 + /** 322 + * Discover documents shared with you by listing share records from users on your PDS. 323 + * For v0, requires knowing which DIDs to check (same-PDS users from instance allowlist). 324 + */ 325 + export async function discoverSharedDocuments( 326 + agent: Agent, 327 + myDid: string, 328 + pdsUserDids: string[], 329 + ): Promise<Array<{ ownerDid: string; rkey: string; name: string; role: 'viewer' | 'editor' }>> { 330 + const results: Array<{ ownerDid: string; rkey: string; name: string; role: 'viewer' | 'editor' }> = []; 331 + 332 + for (const ownerDid of pdsUserDids) { 333 + if (ownerDid === myDid) continue; 334 + 335 + const shares = await listSharesForSubject(agent, ownerDid, myDid); 336 + for (const share of shares) { 337 + const docRkey = share.value.document.split('/').pop(); 338 + if (!docRkey) continue; 339 + 340 + const doc = await import('./pds-sync.js').then(m => m.getDocument(agent, ownerDid, docRkey)); 341 + results.push({ 342 + ownerDid, 343 + rkey: docRkey, 344 + name: doc?.name ?? 'Untitled', 345 + role: share.value.role, 346 + }); 347 + } 348 + } 349 + 350 + return results; 351 + }
+45
src/lib/pds-pull-sync.ts
··· 1 + /** 2 + * PDS pull sync — downloads documents from PDS that are missing locally. 3 + * Called once during boot after identity is ready. 4 + */ 5 + 6 + import type { Agent } from '@atproto/api'; 7 + import { listPdsDocuments, loadDocumentFromPds } from './pds-documents.js'; 8 + import { getDocument } from './local-store.js'; 9 + import { isPdsReady } from './pds-setup.js'; 10 + import { getSession } from './auth.js'; 11 + 12 + export async function pullRemoteDocuments(): Promise<number> { 13 + if (!isPdsReady()) return 0; 14 + 15 + const session = getSession(); 16 + if (!session) return 0; 17 + 18 + const remoteDocs = await listPdsDocuments(session.agent, session.did); 19 + let pulled = 0; 20 + 21 + for (const remote of remoteDocs) { 22 + const local = await getDocument(remote.rkey); 23 + if (local) continue; 24 + 25 + try { 26 + const result = await loadDocumentFromPds(session.agent, session.did, remote.rkey); 27 + if (!result) continue; 28 + 29 + const { putDocumentFromSync } = await import('./local-store.js'); 30 + await putDocumentFromSync(remote.rkey, { 31 + type: remote.type, 32 + name: remote.name, 33 + tags: remote.tags, 34 + snapshot: result.snapshot.buffer as ArrayBuffer, 35 + created_at: remote.createdAt, 36 + updated_at: remote.updatedAt, 37 + }); 38 + pulled++; 39 + } catch (err) { 40 + console.warn(`Failed to pull document ${remote.rkey} from PDS:`, err); 41 + } 42 + } 43 + 44 + return pulled; 45 + }
+80
src/lib/pds-setup.ts
··· 1 + /** 2 + * PDS identity setup integration — hooks into app boot sequence. 3 + * 4 + * After AT Proto OAuth sign-in, if sync is enabled: 5 + * 1. Check for local identity keypair → if found, ready 6 + * 2. Check PDS for key backup → prompt recovery passphrase 7 + * 3. No backup → prompt new passphrase, generate keypair, publish 8 + * 9 + * Uses the existing showPassphraseModal() for UX consistency. 10 + */ 11 + 12 + import { getSession, type AtmosSession } from './auth.js'; 13 + import { getInstanceInfo } from './instance-info.js'; 14 + import { getSetupStatus, setupIdentity, recoverIdentity } from './pds-documents.js'; 15 + import { showPassphraseModal } from './key-passphrase.js'; 16 + 17 + let _initialized = false; 18 + 19 + /** 20 + * Run the PDS identity check after sign-in. 21 + * Returns true if identity is ready for PDS operations, false if sync is disabled 22 + * or user cancelled setup. 23 + */ 24 + export async function ensurePdsIdentity(): Promise<boolean> { 25 + const info = await getInstanceInfo(); 26 + if (!info.features.sync) return false; 27 + 28 + const session = getSession(); 29 + if (!session) return false; 30 + 31 + const status = await getSetupStatus(session.agent, session.did); 32 + 33 + switch (status.state) { 34 + case 'no-sync': 35 + case 'no-session': 36 + return false; 37 + 38 + case 'ready': 39 + _initialized = true; 40 + return true; 41 + 42 + case 'needs-recovery': 43 + return await promptRecovery(session); 44 + 45 + case 'needs-setup': 46 + return await promptSetup(session); 47 + } 48 + } 49 + 50 + async function promptSetup(session: AtmosSession): Promise<boolean> { 51 + const passphrase = await showPassphraseModal('setup'); 52 + if (!passphrase) return false; 53 + 54 + try { 55 + await setupIdentity(session.agent, session.did, passphrase); 56 + _initialized = true; 57 + return true; 58 + } catch (err) { 59 + console.error('Identity setup failed:', err); 60 + return false; 61 + } 62 + } 63 + 64 + async function promptRecovery(session: AtmosSession): Promise<boolean> { 65 + const passphrase = await showPassphraseModal('unlock'); 66 + if (!passphrase) return false; 67 + 68 + try { 69 + await recoverIdentity(session.agent, session.did, passphrase); 70 + _initialized = true; 71 + return true; 72 + } catch (err) { 73 + console.error('Key recovery failed:', err); 74 + return false; 75 + } 76 + } 77 + 78 + export function isPdsReady(): boolean { 79 + return _initialized; 80 + }
+77
src/lib/pds-share.ts
··· 1 + /** 2 + * PDS document sharing — share with another user on the same PDS by handle or DID. 3 + * 4 + * Resolves handles to DIDs via the AT Proto agent, then wraps the document key 5 + * for the recipient's published public key. 6 + */ 7 + 8 + import type { Agent } from '@atproto/api'; 9 + import { getSession } from './auth.js'; 10 + import { isPdsReady } from './pds-setup.js'; 11 + import { shareDocument, discoverSharedDocuments } from './pds-documents.js'; 12 + import { getInstanceInfo } from './instance-info.js'; 13 + 14 + export interface ShareResult { 15 + success: boolean; 16 + error?: string; 17 + uri?: string; 18 + } 19 + 20 + export async function shareWithUser( 21 + docId: string, 22 + recipientHandle: string, 23 + role: 'viewer' | 'editor' = 'editor', 24 + ): Promise<ShareResult> { 25 + if (!isPdsReady()) { 26 + return { success: false, error: 'PDS identity not set up' }; 27 + } 28 + 29 + const session = getSession(); 30 + if (!session) { 31 + return { success: false, error: 'Not signed in' }; 32 + } 33 + 34 + const info = await getInstanceInfo(); 35 + if (!info.features.sharing) { 36 + return { success: false, error: 'Sharing is not enabled on this instance' }; 37 + } 38 + 39 + try { 40 + const resolved = await session.agent.resolveHandle({ handle: recipientHandle }); 41 + const recipientDid = resolved.data.did; 42 + 43 + if (recipientDid === session.did) { 44 + return { success: false, error: 'Cannot share with yourself' }; 45 + } 46 + 47 + const { uri } = await shareDocument( 48 + session.agent, 49 + session.did, 50 + docId, 51 + recipientDid, 52 + role, 53 + ); 54 + 55 + return { success: true, uri }; 56 + } catch (err: unknown) { 57 + const message = err instanceof Error ? err.message : 'Share failed'; 58 + return { success: false, error: message }; 59 + } 60 + } 61 + 62 + export async function getSharedWithMe(): Promise< 63 + Array<{ ownerDid: string; rkey: string; name: string; role: 'viewer' | 'editor' }> 64 + > { 65 + if (!isPdsReady()) return []; 66 + 67 + const session = getSession(); 68 + if (!session) return []; 69 + 70 + const info = await getInstanceInfo(); 71 + if (!info.features.sharing) return []; 72 + 73 + const allowlist = info.accessControl?.allowlist ?? []; 74 + if (allowlist.length === 0) return []; 75 + 76 + return discoverSharedDocuments(session.agent, session.did, allowlist); 77 + }
+333
src/lib/pds-sync.ts
··· 1 + /** 2 + * PDS sync layer — read/write documents and shares to AT Proto PDS. 3 + * 4 + * Lexicons (all under com.atmospheremail.office.*): 5 + * document — metadata + encrypted blob ref 6 + * share — wrapped document key grant (self or another user) 7 + * publicKey — user's X25519 encryption public key 8 + * keyBackup — passphrase-encrypted private key backup 9 + * 10 + * All document content is E2EE — the PDS stores opaque encrypted blobs. 11 + * Only metadata (type, name, timestamps) is readable by the PDS. 12 + */ 13 + 14 + import type { Agent } from '@atproto/api'; 15 + import type { WrappedDocumentKey, KeyBackup } from './identity-keys.js'; 16 + import type { DocType } from './local-store.js'; 17 + 18 + const NSID_PREFIX = 'com.atmospheremail.office'; 19 + const COLLECTION = { 20 + document: `${NSID_PREFIX}.document`, 21 + share: `${NSID_PREFIX}.share`, 22 + publicKey: `${NSID_PREFIX}.publicKey`, 23 + keyBackup: `${NSID_PREFIX}.keyBackup`, 24 + } as const; 25 + 26 + export interface PdsDocumentRecord { 27 + $type: typeof COLLECTION.document; 28 + type: DocType; 29 + name: string; 30 + blob: { ref: { $link: string }; mimeType: string; size: number }; 31 + tags: string[]; 32 + createdAt: string; 33 + updatedAt: string; 34 + } 35 + 36 + export interface PdsShareRecord { 37 + $type: typeof COLLECTION.share; 38 + document: string; 39 + subject: string; 40 + role: 'viewer' | 'editor'; 41 + ephemeralPublicKey: string; 42 + wrappedKey: string; 43 + createdAt: string; 44 + } 45 + 46 + export interface PdsPublicKeyRecord { 47 + $type: typeof COLLECTION.publicKey; 48 + algorithm: string; 49 + publicKey: string; 50 + createdAt: string; 51 + } 52 + 53 + export interface PdsKeyBackupRecord { 54 + $type: typeof COLLECTION.keyBackup; 55 + encryptedPrivateKey: string; 56 + salt: string; 57 + iterations: number; 58 + algorithm: string; 59 + createdAt: string; 60 + } 61 + 62 + interface ListRecordsResponse { 63 + records: Array<{ uri: string; cid: string; value: Record<string, unknown> }>; 64 + cursor?: string; 65 + } 66 + 67 + // ── Document CRUD ────────────────────────────────────────── 68 + 69 + export async function uploadEncryptedBlob( 70 + agent: Agent, 71 + encryptedData: Uint8Array, 72 + ): Promise<{ ref: { $link: string }; mimeType: string; size: number }> { 73 + const response = await agent.com.atproto.repo.uploadBlob(encryptedData, { 74 + encoding: 'application/octet-stream', 75 + }); 76 + 77 + return { 78 + ref: { $link: response.data.blob.ref.toString() }, 79 + mimeType: 'application/octet-stream', 80 + size: encryptedData.byteLength, 81 + }; 82 + } 83 + 84 + export async function putDocument( 85 + agent: Agent, 86 + did: string, 87 + rkey: string, 88 + doc: Omit<PdsDocumentRecord, '$type'>, 89 + ): Promise<{ uri: string; cid: string }> { 90 + const response = await agent.com.atproto.repo.putRecord({ 91 + repo: did, 92 + collection: COLLECTION.document, 93 + rkey, 94 + record: { $type: COLLECTION.document, ...doc }, 95 + }); 96 + 97 + return { uri: response.data.uri, cid: response.data.cid }; 98 + } 99 + 100 + export async function getDocument( 101 + agent: Agent, 102 + did: string, 103 + rkey: string, 104 + ): Promise<PdsDocumentRecord | null> { 105 + try { 106 + const response = await agent.com.atproto.repo.getRecord({ 107 + repo: did, 108 + collection: COLLECTION.document, 109 + rkey, 110 + }); 111 + return response.data.value as unknown as PdsDocumentRecord; 112 + } catch { 113 + return null; 114 + } 115 + } 116 + 117 + export async function listDocuments( 118 + agent: Agent, 119 + did: string, 120 + ): Promise<Array<{ uri: string; rkey: string; value: PdsDocumentRecord }>> { 121 + const results: Array<{ uri: string; rkey: string; value: PdsDocumentRecord }> = []; 122 + let cursor: string | undefined; 123 + 124 + do { 125 + const response = await agent.com.atproto.repo.listRecords({ 126 + repo: did, 127 + collection: COLLECTION.document, 128 + limit: 100, 129 + cursor, 130 + }); 131 + 132 + const data = response.data as unknown as ListRecordsResponse; 133 + for (const record of data.records) { 134 + const rkey = record.uri.split('/').pop()!; 135 + results.push({ uri: record.uri, rkey, value: record.value as unknown as PdsDocumentRecord }); 136 + } 137 + cursor = data.cursor; 138 + } while (cursor); 139 + 140 + return results; 141 + } 142 + 143 + export async function deleteDocument( 144 + agent: Agent, 145 + did: string, 146 + rkey: string, 147 + ): Promise<void> { 148 + await agent.com.atproto.repo.deleteRecord({ 149 + repo: did, 150 + collection: COLLECTION.document, 151 + rkey, 152 + }); 153 + } 154 + 155 + export async function fetchEncryptedBlob( 156 + agent: Agent, 157 + did: string, 158 + cid: string, 159 + ): Promise<Uint8Array> { 160 + const response = await agent.com.atproto.sync.getBlob({ did, cid }); 161 + return new Uint8Array(response.data as unknown as ArrayBuffer); 162 + } 163 + 164 + // ── Share Records ────────────────────────────────────────── 165 + 166 + export async function putShare( 167 + agent: Agent, 168 + did: string, 169 + rkey: string, 170 + share: { 171 + documentUri: string; 172 + subject: string; 173 + role: 'viewer' | 'editor'; 174 + wrappedKey: WrappedDocumentKey; 175 + }, 176 + ): Promise<{ uri: string; cid: string }> { 177 + const record: PdsShareRecord = { 178 + $type: COLLECTION.share, 179 + document: share.documentUri, 180 + subject: share.subject, 181 + role: share.role, 182 + ephemeralPublicKey: share.wrappedKey.ephemeralPublicKey, 183 + wrappedKey: share.wrappedKey.wrappedKey, 184 + createdAt: new Date().toISOString(), 185 + }; 186 + 187 + const response = await agent.com.atproto.repo.putRecord({ 188 + repo: did, 189 + collection: COLLECTION.share, 190 + rkey, 191 + record: record as unknown as Record<string, unknown>, 192 + }); 193 + 194 + return { uri: response.data.uri, cid: response.data.cid }; 195 + } 196 + 197 + export async function listShares( 198 + agent: Agent, 199 + did: string, 200 + ): Promise<Array<{ uri: string; rkey: string; value: PdsShareRecord }>> { 201 + const results: Array<{ uri: string; rkey: string; value: PdsShareRecord }> = []; 202 + let cursor: string | undefined; 203 + 204 + do { 205 + const response = await agent.com.atproto.repo.listRecords({ 206 + repo: did, 207 + collection: COLLECTION.share, 208 + limit: 100, 209 + cursor, 210 + }); 211 + 212 + const data = response.data as unknown as ListRecordsResponse; 213 + for (const record of data.records) { 214 + const rkey = record.uri.split('/').pop()!; 215 + results.push({ uri: record.uri, rkey, value: record.value as unknown as PdsShareRecord }); 216 + } 217 + cursor = data.cursor; 218 + } while (cursor); 219 + 220 + return results; 221 + } 222 + 223 + export async function listSharesForSubject( 224 + agent: Agent, 225 + ownerDid: string, 226 + subjectDid: string, 227 + ): Promise<Array<{ uri: string; rkey: string; value: PdsShareRecord }>> { 228 + const all = await listShares(agent, ownerDid); 229 + return all.filter(s => s.value.subject === subjectDid); 230 + } 231 + 232 + export async function deleteShare( 233 + agent: Agent, 234 + did: string, 235 + rkey: string, 236 + ): Promise<void> { 237 + await agent.com.atproto.repo.deleteRecord({ 238 + repo: did, 239 + collection: COLLECTION.share, 240 + rkey, 241 + }); 242 + } 243 + 244 + // ── Public Key ───────────────────────────────────────────── 245 + 246 + export async function publishPublicKey( 247 + agent: Agent, 248 + did: string, 249 + publicKeyB64: string, 250 + ): Promise<{ uri: string; cid: string }> { 251 + const record: PdsPublicKeyRecord = { 252 + $type: COLLECTION.publicKey, 253 + algorithm: 'X25519', 254 + publicKey: publicKeyB64, 255 + createdAt: new Date().toISOString(), 256 + }; 257 + 258 + const response = await agent.com.atproto.repo.putRecord({ 259 + repo: did, 260 + collection: COLLECTION.publicKey, 261 + rkey: 'self', 262 + record: record as unknown as Record<string, unknown>, 263 + }); 264 + 265 + return { uri: response.data.uri, cid: response.data.cid }; 266 + } 267 + 268 + export async function getPublicKey( 269 + agent: Agent, 270 + did: string, 271 + ): Promise<string | null> { 272 + try { 273 + const response = await agent.com.atproto.repo.getRecord({ 274 + repo: did, 275 + collection: COLLECTION.publicKey, 276 + rkey: 'self', 277 + }); 278 + const value = response.data.value as unknown as PdsPublicKeyRecord; 279 + return value.publicKey; 280 + } catch { 281 + return null; 282 + } 283 + } 284 + 285 + // ── Key Backup ───────────────────────────────────────────── 286 + 287 + export async function publishKeyBackup( 288 + agent: Agent, 289 + did: string, 290 + backup: KeyBackup, 291 + ): Promise<{ uri: string; cid: string }> { 292 + const record: PdsKeyBackupRecord = { 293 + $type: COLLECTION.keyBackup, 294 + encryptedPrivateKey: backup.encryptedPrivateKey, 295 + salt: backup.salt, 296 + iterations: backup.iterations, 297 + algorithm: backup.algorithm, 298 + createdAt: new Date().toISOString(), 299 + }; 300 + 301 + const response = await agent.com.atproto.repo.putRecord({ 302 + repo: did, 303 + collection: COLLECTION.keyBackup, 304 + rkey: 'self', 305 + record: record as unknown as Record<string, unknown>, 306 + }); 307 + 308 + return { uri: response.data.uri, cid: response.data.cid }; 309 + } 310 + 311 + export async function getKeyBackup( 312 + agent: Agent, 313 + did: string, 314 + ): Promise<KeyBackup | null> { 315 + try { 316 + const response = await agent.com.atproto.repo.getRecord({ 317 + repo: did, 318 + collection: COLLECTION.keyBackup, 319 + rkey: 'self', 320 + }); 321 + const value = response.data.value as unknown as PdsKeyBackupRecord; 322 + return { 323 + encryptedPrivateKey: value.encryptedPrivateKey, 324 + salt: value.salt, 325 + iterations: value.iterations, 326 + algorithm: value.algorithm, 327 + }; 328 + } catch { 329 + return null; 330 + } 331 + } 332 + 333 + export { COLLECTION };
+15
src/lib/provider.ts
··· 11 11 import { Awareness, removeAwarenessStates } from 'y-protocols/awareness'; 12 12 import { encrypt, decrypt } from './crypto.js'; 13 13 import { getDocument, updateDocument } from './local-store.js'; 14 + import { isPdsReady } from './pds-setup.js'; 15 + import { getSession } from './auth.js'; 16 + import { saveDocumentToPds } from './pds-documents.js'; 14 17 15 18 const SAVE_DEBOUNCE = 500; 16 19 const MAX_SAVE_WAIT = 5_000; ··· 207 210 this._hasUnsavedChanges = false; 208 211 this._hadSnapshot = true; 209 212 this._setSaveStatus('saved'); 213 + 214 + this._syncToPds(); 210 215 } catch (err: unknown) { 211 216 console.warn('Failed to save snapshot', err); 212 217 this._setSaveStatus('error'); ··· 225 230 updateDocument(this.roomId, { snapshot: encrypted.buffer as ArrayBuffer }).catch(() => {}); 226 231 }).catch(() => {}); 227 232 } catch { /* best effort */ } 233 + } 234 + 235 + private _syncToPds(): void { 236 + if (!isPdsReady()) return; 237 + const session = getSession(); 238 + if (!session) return; 239 + 240 + saveDocumentToPds(session.agent, session.did, this.roomId).catch(err => { 241 + console.warn('PDS sync failed (will retry on next save):', err); 242 + }); 228 243 } 229 244 230 245 setAwareness(state: Record<string, unknown>): void {
+172
tests/identity-keys.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import 'fake-indexeddb/auto'; 3 + import { 4 + generateIdentityKeyPair, 5 + storeIdentityKeyPair, 6 + getIdentityKeyPair, 7 + exportPublicKey, 8 + importPublicKey, 9 + exportPrivateKey, 10 + importPrivateKey, 11 + wrapDocumentKey, 12 + unwrapDocumentKey, 13 + createKeyBackup, 14 + restoreKeyFromBackup, 15 + closeIdentityDb, 16 + } from '../src/lib/identity-keys.js'; 17 + 18 + describe('identity-keys', () => { 19 + beforeEach(() => { 20 + closeIdentityDb(); 21 + indexedDB = new IDBFactory(); 22 + }); 23 + 24 + describe('key generation', () => { 25 + it('generates an X25519 keypair', async () => { 26 + const keyPair = await generateIdentityKeyPair(); 27 + expect(keyPair.publicKey).toBeDefined(); 28 + expect(keyPair.privateKey).toBeDefined(); 29 + expect(keyPair.publicKey.type).toBe('public'); 30 + expect(keyPair.privateKey.type).toBe('private'); 31 + }); 32 + 33 + it('generates unique keypairs each time', async () => { 34 + const kp1 = await generateIdentityKeyPair(); 35 + const kp2 = await generateIdentityKeyPair(); 36 + const pub1 = await exportPublicKey(kp1.publicKey); 37 + const pub2 = await exportPublicKey(kp2.publicKey); 38 + expect(pub1).not.toBe(pub2); 39 + }); 40 + }); 41 + 42 + describe('key storage', () => { 43 + it('stores and retrieves a keypair from IndexedDB', async () => { 44 + const keyPair = await generateIdentityKeyPair(); 45 + await storeIdentityKeyPair(keyPair); 46 + 47 + const retrieved = await getIdentityKeyPair(); 48 + expect(retrieved).not.toBeNull(); 49 + 50 + const originalPub = await exportPublicKey(keyPair.publicKey); 51 + const retrievedPub = await exportPublicKey(retrieved!.publicKey); 52 + expect(retrievedPub).toBe(originalPub); 53 + }); 54 + 55 + it('returns null when no keypair stored', async () => { 56 + const result = await getIdentityKeyPair(); 57 + expect(result).toBeNull(); 58 + }); 59 + }); 60 + 61 + describe('key export/import', () => { 62 + it('round-trips a public key through export/import', async () => { 63 + const keyPair = await generateIdentityKeyPair(); 64 + const exported = await exportPublicKey(keyPair.publicKey); 65 + const imported = await importPublicKey(exported); 66 + 67 + const reExported = await exportPublicKey(imported); 68 + expect(reExported).toBe(exported); 69 + }); 70 + 71 + it('round-trips a private key through export/import', async () => { 72 + const keyPair = await generateIdentityKeyPair(); 73 + const exported = await exportPrivateKey(keyPair.privateKey); 74 + const imported = await importPrivateKey(exported); 75 + 76 + const reExported = await exportPrivateKey(imported); 77 + expect(Buffer.from(reExported)).toEqual(Buffer.from(exported)); 78 + }); 79 + 80 + it('exported public key is 32 bytes base64url', async () => { 81 + const keyPair = await generateIdentityKeyPair(); 82 + const exported = await exportPublicKey(keyPair.publicKey); 83 + expect(exported.length).toBe(43); // 32 bytes = 43 base64url chars (no padding) 84 + }); 85 + }); 86 + 87 + describe('document key wrapping', () => { 88 + it('wraps and unwraps a document key', async () => { 89 + const identity = await generateIdentityKeyPair(); 90 + 91 + const docKey = await crypto.subtle.generateKey( 92 + { name: 'AES-GCM', length: 256 }, 93 + true, 94 + ['encrypt', 'decrypt'], 95 + ); 96 + 97 + const wrapped = await wrapDocumentKey(docKey, identity.publicKey); 98 + expect(wrapped.ephemeralPublicKey).toBeDefined(); 99 + expect(wrapped.wrappedKey).toBeDefined(); 100 + 101 + const unwrapped = await unwrapDocumentKey(wrapped, identity.privateKey); 102 + const originalRaw = await crypto.subtle.exportKey('raw', docKey); 103 + const unwrappedRaw = await crypto.subtle.exportKey('raw', unwrapped); 104 + expect(Buffer.from(unwrappedRaw)).toEqual(Buffer.from(originalRaw)); 105 + }); 106 + 107 + it('wraps for a different recipient and they can unwrap', async () => { 108 + const sender = await generateIdentityKeyPair(); 109 + const recipient = await generateIdentityKeyPair(); 110 + 111 + const docKey = await crypto.subtle.generateKey( 112 + { name: 'AES-GCM', length: 256 }, 113 + true, 114 + ['encrypt', 'decrypt'], 115 + ); 116 + 117 + const wrapped = await wrapDocumentKey(docKey, recipient.publicKey); 118 + const unwrapped = await unwrapDocumentKey(wrapped, recipient.privateKey); 119 + 120 + const originalRaw = await crypto.subtle.exportKey('raw', docKey); 121 + const unwrappedRaw = await crypto.subtle.exportKey('raw', unwrapped); 122 + expect(Buffer.from(unwrappedRaw)).toEqual(Buffer.from(originalRaw)); 123 + 124 + // Sender cannot unwrap (wrong private key) 125 + await expect( 126 + unwrapDocumentKey(wrapped, sender.privateKey), 127 + ).rejects.toThrow(); 128 + }); 129 + 130 + it('uses a unique ephemeral key per wrap', async () => { 131 + const identity = await generateIdentityKeyPair(); 132 + const docKey = await crypto.subtle.generateKey( 133 + { name: 'AES-GCM', length: 256 }, 134 + true, 135 + ['encrypt', 'decrypt'], 136 + ); 137 + 138 + const wrapped1 = await wrapDocumentKey(docKey, identity.publicKey); 139 + const wrapped2 = await wrapDocumentKey(docKey, identity.publicKey); 140 + 141 + expect(wrapped1.ephemeralPublicKey).not.toBe(wrapped2.ephemeralPublicKey); 142 + expect(wrapped1.wrappedKey).not.toBe(wrapped2.wrappedKey); 143 + }); 144 + }); 145 + 146 + describe('key backup', () => { 147 + it('creates a backup and restores with correct passphrase', async () => { 148 + const keyPair = await generateIdentityKeyPair(); 149 + const passphrase = 'correct horse battery staple'; 150 + 151 + const backup = await createKeyBackup(keyPair.privateKey, passphrase); 152 + expect(backup.algorithm).toBe('PBKDF2-AES-256-GCM'); 153 + expect(backup.iterations).toBe(600_000); 154 + expect(backup.encryptedPrivateKey).toBeDefined(); 155 + expect(backup.salt).toBeDefined(); 156 + 157 + const restored = await restoreKeyFromBackup(backup, passphrase); 158 + const originalExport = await exportPrivateKey(keyPair.privateKey); 159 + const restoredExport = await exportPrivateKey(restored); 160 + expect(Buffer.from(restoredExport)).toEqual(Buffer.from(originalExport)); 161 + }); 162 + 163 + it('fails to restore with wrong passphrase', async () => { 164 + const keyPair = await generateIdentityKeyPair(); 165 + const backup = await createKeyBackup(keyPair.privateKey, 'right passphrase'); 166 + 167 + await expect( 168 + restoreKeyFromBackup(backup, 'wrong passphrase'), 169 + ).rejects.toThrow(); 170 + }); 171 + }); 172 + });