An encrypted personal cloud built on the AT Protocol.
0
fork

Configure Feed

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

at main 499 lines 28 kB view raw view rendered
1<!-- 2 NOTE TO EDITORS: 3 Opake uses a dual-documentation system. If you modify the architectural model, 4 encryption schemes, or data flows in this file, you MUST also update the 5 corresponding MDX content in `web/src/content/` to prevent documentation drift. 6--> 7 8# Opake — Architecture 9 10## System Overview 11 12```mermaid 13graph TB 14 subgraph Client ["Client (your machine / browser)"] 15 CLI["opake CLI"] 16 Web["Web SPA"] 17 Core["opake-core library"] 18 Crypto["Client-side crypto<br/>(AES-256-GCM, X25519)"] 19 end 20 21 subgraph Server ["AppView (self-hosted)"] 22 AppView["opake-appview<br/>(Elixir/Phoenix)"] 23 Postgres["PostgreSQL"] 24 end 25 26 subgraph Network ["AT Protocol Network"] 27 OwnPDS["Your PDS"] 28 OtherPDS["Other user's PDS"] 29 PLC["PLC Directory"] 30 Jetstream["Jetstream firehose"] 31 end 32 33 CLI --> Core 34 Web -->|WASM| Core 35 Core --> Crypto 36 Core -->|XRPC / HTTPS| OwnPDS 37 Core -->|unauthenticated| OtherPDS 38 Core -->|DID resolution| PLC 39 CLI -->|inbox query| AppView 40 Web -->|inbox query| AppView 41 42 AppView -->|subscribe| Jetstream 43 AppView --> Postgres 44 Jetstream -.->|events from| OwnPDS 45 Jetstream -.->|events from| OtherPDS 46 47 OwnPDS -.->|federation / sync| OtherPDS 48 49 style Client fill:#1a1a2e,color:#eee 50 style Server fill:#0f3460,color:#eee 51 style Network fill:#16213e,color:#eee 52``` 53 54Both the CLI and the web frontend talk directly to PDS instances over XRPC. No PDS modifications needed. All encryption and decryption happens client-side — on your machine (CLI) or in the browser (Web via WASM). The AppView is an optional component that indexes grants and keyrings from the firehose for discovery. 55 56## Crate Structure 57 58``` 59crates/ 60 opake-core/ Platform-agnostic library (compiles to WASM) 61 src/ 62 atproto.rs AT-URI parsing, shared AT Protocol primitives 63 resolve.rs Handle/DID → PDS → public key resolution pipeline 64 storage.rs Config, Identity types + Storage trait (cross-platform contract) 65 error.rs Typed error hierarchy (thiserror) 66 test_utils.rs MockTransport + response queue (behind test-utils feature) 67 crypto/ 68 mod.rs Type defs, constants, re-exports 69 content.rs AES-256-GCM: generate_content_key(), encrypt_blob(), decrypt_blob() 70 key_wrapping.rs X25519-HKDF-A256KW: wrap_key(), unwrap_key(), create_group_key() 71 keyring_wrapping.rs Symmetric AES-KW: wrap/unwrap content key under group key 72 mnemonic/ 73 mod.rs Mnemonic type, parse_mnemonic(), wordlist (BIP-39 embedded) 74 generate.rs generate_mnemonic() — entropy → 24 words 75 derive.rs derive_identity_from_mnemonic() — PBKDF2 → HKDF dual-path 76 format.rs format_mnemonic_grid(), parse_mnemonic_grid() — .txt import/export 77 records/ 78 mod.rs SCHEMA_VERSION, Versioned trait, check_version(), re-exports 79 defs.rs WrappedKey, EncryptionEnvelope, KeyringRef 80 document.rs DirectEncryption, KeyringEncryption, Encryption, Document 81 public_key.rs PublicKeyRecord, collection/rkey constants 82 grant.rs Grant 83 keyring.rs KeyHistoryEntry, Keyring 84 client/ 85 mod.rs Re-exports 86 transport.rs Transport trait (HTTP abstraction for WASM compat) 87 did.rs Unauthenticated DID resolution and cross-PDS queries 88 list.rs Generic paginated collection fetcher 89 dpop.rs DPoP keypair (P-256/ES256) + proof JWT generation 90 oauth_discovery.rs OAuth AS discovery + PKCE S256 generation 91 oauth_token.rs PAR, authorization code exchange, token refresh (all with DPoP) 92 xrpc/ 93 mod.rs XrpcClient struct, Session enum (Legacy/OAuth), dual auth dispatch 94 auth.rs login(), refresh_session() (legacy + OAuth) 95 blobs.rs upload_blob(), get_blob() 96 repo.rs create_record(), put_record(), get_record(), list_records(), delete_record() 97 directories/ 98 mod.rs Re-exports, collection constants, shared test fixtures 99 create.rs create_directory() 100 delete.rs delete_directory() — single empty directory 101 entries.rs add_entry(), remove_entry() — fetch-modify-put on parent 102 get_or_create_root.rs Root singleton (rkey "self") management 103 list.rs list_directories() 104 tree.rs DirectoryTree — in-memory snapshot for path resolution 105 remove.rs remove() — path-aware deletion (recursive, with parent cleanup) 106 documents/ 107 mod.rs Re-exports, shared test fixtures 108 upload.rs encrypt_and_upload() 109 download.rs download_and_decrypt() — direct-encrypted documents 110 download_grant.rs download_shared() — cross-PDS via grant URI 111 download_keyring.rs download_keyring_document() — keyring-encrypted documents 112 list.rs list_documents() 113 delete.rs delete_document() 114 resolve.rs Filename → AT-URI resolution 115 keyrings/ 116 mod.rs Re-exports, resolve_keyring_uri() 117 create.rs create_keyring() → group key + record 118 list.rs list_keyrings() 119 add_member.rs add_member() — wrap GK to new member 120 remove_member.rs remove_member() — rotate GK, re-wrap to remaining 121 sharing/ 122 mod.rs Re-exports 123 create.rs create_grant() 124 list.rs list_grants() 125 revoke.rs revoke_grant() 126 pairing/ 127 mod.rs Re-exports 128 request.rs create_pair_request() — write ephemeral key to PDS 129 respond.rs respond_to_pair_request() — encrypt + wrap identity 130 receive.rs receive_pair_response() — decrypt + verify identity 131 cleanup.rs cleanup_pair_records() — delete request + response 132 133 opake-cli/ CLI binary wrapping opake-core 134 src/ 135 main.rs Clap app, command dispatch 136 config.rs FileStorage (impl Storage for filesystem), anyhow wrappers 137 session.rs CommandContext resolution, session persistence 138 identity.rs Identity loading, migration (signing keys), permission checks 139 keyring_store.rs Local group key persistence (per-keyring) 140 transport.rs reqwest-based Transport implementation 141 oauth.rs OAuth loopback redirect server + browser open 142 utils.rs Test harness, env helpers 143 commands/ 144 login.rs Auth + seed phrase generation + key publish (OAuth-first) 145 recover.rs Seed phrase recovery (stdin or --file .txt import) 146 upload.rs File → encrypt → upload (direct or --keyring) 147 download.rs Download + decrypt (direct, keyring, or --grant) 148 ls.rs List documents 149 metadata.rs View/edit document metadata (rename, tags, description) 150 mkdir.rs Create directory 151 rm.rs Path-aware delete (documents, directories, recursive) 152 resolve.rs Identity resolution display 153 share.rs Grant creation 154 revoke.rs Grant deletion 155 shared.rs List created grants 156 keyring.rs Keyring CRUD (create, ls, add-member, remove-member) 157 pair.rs Device pairing (request, approve) 158 accounts.rs List accounts 159 logout.rs Remove account 160 set_default.rs Switch default account 161 162 opake-derive/ Proc-macro crate (RedactedDebug derive) 163 src/ 164 lib.rs #[derive(RedactedDebug)] + #[redact] attribute 165 166web/ React SPA (Vite + TanStack Router + Tailwind + daisyUI) 167 src/ 168 lib/ 169 storage.ts Storage interface (mirrors opake-core Storage trait) 170 storage-types.ts Config, Identity, Session types (mirrors opake-core) 171 indexeddb-storage.ts IndexedDbStorage (impl Storage over Dexie.js/IndexedDB) 172 api.ts API client helpers 173 crypto-types.ts Crypto type definitions 174 stores/ 175 auth.ts Auth state (Zustand) 176 routes/ 177 __root.tsx Root layout with auth guard 178 index.tsx Landing page 179 login.tsx Login form 180 cabinet.tsx File cabinet (main UI) 181 components/cabinet/ 182 PanelStack.tsx Stacked panel navigation 183 PanelContent.tsx File grid/list view 184 Sidebar.tsx Navigation sidebar 185 TopBar.tsx Header with account switcher 186 FileGridCard.tsx Grid card with file icon + metadata 187 FileListRow.tsx List row variant 188 types.ts Discriminated union types for cabinet state 189 wasm/opake-wasm/ WASM build of opake-core (via wasm-pack) 190 workers/ 191 crypto.worker.ts Web Worker for off-main-thread crypto (Comlink) 192 tests/ 193 lib/ 194 indexeddb-storage.test.ts Storage contract tests (fake-indexeddb) 195 196appview/ Elixir/Phoenix indexer + REST API (replaces Rust appview) 197 lib/ 198 opake_appview/ 199 application.ex OTP supervision tree (Repo, KeyCache, Endpoint, Consumer) 200 indexer.ex Event dispatch, cursor saving, connection state (ETS) 201 release.ex Release tasks (create_db, migrate, rollback, status) 202 repo.ex Ecto Repo 203 auth/ 204 plug.ex Opake-Ed25519 header verification (Plug) 205 key_cache.ex GenServer + ETS, 5-min TTL per DID 206 key_fetcher.ex DID → PDS → publicKey → signingKey resolution 207 base64.ex Flexible base64 decode (padded/unpadded) 208 jetstream/ 209 consumer.ex WebSockex client with exponential backoff 210 event.ex Jetstream JSON → tagged tuples 211 queries/ 212 cursor_queries.ex Singleton cursor upsert/load 213 grant_queries.ex Grant CRUD + inbox pagination 214 keyring_queries.ex Keyring member CRUD + membership pagination 215 pagination.ex Shared cursor-based pagination helpers 216 schemas/ 217 cursor.ex Singleton cursor (id=1) 218 grant.ex Grant (uri PK) 219 keyring_member.ex Keyring member (composite PK) 220 opake_appview_web/ 221 router.ex /api/health (public), /api/inbox + /api/keyrings (auth'd) 222 endpoint.ex Bandit HTTP, API-only (no sessions/static) 223 plugs/rate_limit.ex Hammer ETS rate limiting per IP 224 controllers/ 225 health_controller.ex Indexer status + cursor lag 226 inbox_controller.ex Grants by recipient DID 227 keyrings_controller.ex Keyrings by member DID 228 pagination_helpers.ex Shared param parsing (did, limit, cursor) 229``` 230 231The boundary is strict: `opake-core` never touches the filesystem, stdin, or any platform-specific API. All I/O happens through the `Storage` trait — `FileStorage` (CLI, filesystem) and `IndexedDbStorage` (web, IndexedDB) implement the same contract with platform-specific backends. This keeps `opake-core` compilable to WASM, which the web frontend uses via `wasm-pack`. 232 233## Encryption Model 234 235Every file is encrypted before it leaves your machine. The PDS stores opaque ciphertext. 236 237### Hybrid Encryption 238 239Same pattern as git-crypt: symmetric content encryption + asymmetric key wrapping. 240 241``` 242plaintext file 243 → AES-256-GCM with random content key K → ciphertext blob 244 → X25519-HKDF-A256KW wraps K to owner's public key → wrappedKey in document record 245``` 246 247**Content encryption** (AES-256-GCM) — fast, handles arbitrary-size data. A random 256-bit key and 96-bit nonce are generated per file. 248 249**Key wrapping** (x25519-hkdf-a256kw) — wraps the 256-bit content key to a recipient's X25519 public key. Uses ephemeral ECDH + HKDF-SHA256 + AES-256-KW. The wrapped key ciphertext is `[32-byte ephemeral pubkey ‖ 40-byte AES-KW output]`. 250 251The algorithm name `x25519-hkdf-a256kw` is intentionally distinct from JWE's `ECDH-ES+A256KW` — we use HKDF-SHA256, not JWE's Concat KDF. The HKDF info string includes the schema version for domain separation: `opake-v1-x25519-hkdf-a256kw-{did}`. 252 253### Two Sharing Modes 254 255**Direct encryption** — the content key is wrapped individually to each authorized DID. The `keys` array in the document's encryption envelope holds one entry per authorized user. Good for ad-hoc sharing of individual files. 256 257**Keyring encryption** — a named group has a shared group key (GK), wrapped to each member's X25519 public key. Documents have their content key wrapped under GK (AES-256-KW) instead of individual public keys. Adding a member to the keyring gives them access to all its documents without per-document changes. Removing a member rotates GK and re-wraps to the remaining members. 258 259### Revocation 260 261Deleting a grant record removes the recipient's wrapped key from the network. However, if they previously cached the key or the decrypted content, that access can't be revoked retroactively. True forward secrecy requires re-encrypting the blob with a new content key and deleting the old blob. The schema supports this workflow. 262 263### Public Key Discovery 264 265AT Protocol DID documents only contain signing keys (secp256k1/P-256), not encryption keys. Opake publishes `app.opake.publicKey/self` singleton records on each user's PDS containing: 266 267- **X25519 encryption public key** — used for key wrapping (sharing) 268- **Ed25519 signing public key** — used for AppView authentication 269 270Key discovery is an unauthenticated `getRecord` call — no auth needed to look up someone's public key. Both keys are published automatically on every `opake login` via an idempotent `putRecord`. 271 272## Identity Derivation 273 274Identity keypairs are deterministically derived from a BIP-39 mnemonic (24 words / 256-bit entropy). The same phrase always produces the same keys. 275 276``` 277256 bits entropy (CSPRNG) 278 → BIP-39 encode → 24-word mnemonic 279 → PBKDF2-HMAC-SHA512 (2048 rounds, salt = "mnemonic") 280 → 512-bit master seed 281 → HKDF-SHA256 (info = "opake-v1-x25519-identity") → X25519 private key 282 → HKDF-SHA256 (info = "opake-v1-ed25519-signing") → Ed25519 signing key 283``` 284 285The PBKDF2 salt is `"mnemonic"` per the [BIP-39 specification](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#from-mnemonic-to-seed) — security comes from the 256-bit entropy, not the salt. The HKDF info strings include the schema version for domain separation, consistent with the key wrapping convention. 286 287The mnemonic is shown once at first login and never stored. Recovery is via `opake recover` (CLI) or the "Use your recovery phrase" flow (web). See [flows/seed-phrase-recovery.md](flows/seed-phrase-recovery.md) for sequence diagrams. 288 289## Data Model 290 291All records live under the `app.opake.*` NSID namespace. See [lexicons/README.md](../lexicons/README.md) for the schema reference and [lexicons/EXAMPLES.md](../lexicons/EXAMPLES.md) for annotated example records. 292 293```mermaid 294erDiagram 295 DOCUMENT ||--o{ GRANT : "shared via" 296 DOCUMENT }o--o| KEYRING : "encrypted under" 297 PUBLICKEY ||--|| ACCOUNT : "one per" 298 299 DOCUMENT { 300 blob encrypted_content 301 union encryption "direct or keyring" 302 ref encryptedMetadata "name, type, size, tags, description" 303 string visibility 304 } 305 306 GRANT { 307 at-uri document 308 did recipient 309 wrappedKey key "content key wrapped to recipient" 310 string permissions 311 } 312 313 KEYRING { 314 string name 315 wrappedKey[] members "group key wrapped to each member" 316 int rotation 317 keyHistoryEntry[] keyHistory "previous rotation snapshots" 318 } 319 320 PUBLICKEY { 321 bytes public_key "X25519" 322 string algo 323 } 324``` 325 326### Encrypted Metadata 327 328All document metadata (name, MIME type, size, tags, description) is encrypted inside `encryptedMetadata` using the same content key as the blob. The PDS never sees real filenames or tags. This means server-side search/indexing requires client-side decryption — a deliberate tradeoff for privacy. 329 330## Cross-PDS Access 331 332When you share a file, the data stays on your PDS. The recipient's client fetches everything directly from the source: 333 3341. Grant record (contains wrapped content key) 3352. Document record (contains blob reference and nonce) 3363. Blob (encrypted file content) 337 338All three are unauthenticated reads — AT Protocol records and blobs are public by design. The encryption is the access control, not the transport. 339 340## Storage Abstraction 341 342Config, identity, and session types live in `opake-core/src/storage.rs` alongside the `Storage` trait. This lets both platforms share the same data model and mutation logic (e.g. `Config::add_account`, `Config::remove_account`, `Config::set_default`). 343 344| Method | Contract | 345| ----------------------------------------------- | ---------------------------------------------------------------------- | 346| `load_config` / `save_config` | Read/write the global config (accounts map, default DID) | 347| `load_identity` / `save_identity` | Read/write per-account encryption keypairs | 348| `load_session` / `save_session` | Read/write per-account JWT tokens | 349| `remove_account` | Full cleanup: mutate config + delete identity/session data + persist | 350| `cache_get_record` / `cache_put_records` | Record-level cache: look up or upsert individual PDS records | 351| `cache_remove_record` | Remove a single cached record (e.g. after deletion or metadata update) | 352| `cache_get_collection` / `cache_put_collection` | Collection-level cache: all records + `fetched_at` timestamp | 353| `cache_invalidate_collection` | Clear `fetched_at` (records stay for offline/record-level use) | 354| `cache_clear` | Remove all cached data for an account | 355 356`Config` includes a `cache_enabled: bool` field (defaults `true`) for per-device cache control. 357 358**CLI (`FileStorage`)** — TOML config at `~/.config/opake/config.toml`, JSON files in per-account directories, unix permissions (0600/0700). Cache methods are no-ops (not yet implemented). 359 360**Web (`IndexedDbStorage`)** — Dexie.js over IndexedDB. Schema v3 adds `cacheRecords` (compound key `[did+collection+uri]`) and `cacheMeta` (compound key `[did+collection]`) tables. `removeAccount` clears cache as part of its atomic transaction. 361 362### Local Record Cache 363 364The cache stores **encrypted PDS records** — the same ciphertext the PDS returns. No plaintext metadata is ever persisted locally. Decrypted metadata lives only in-memory and is discarded on page unload. 365 366#### Design: Two-Path Loading 367 368The cache separates the **UI path** (what the user sees) from the **warming path** (how the cache gets populated). This avoids rate-limiting the PDS while keeping the UI responsive. 369 370``` 371┌─────────────────────────────────────────────────────────┐ 372│ loadCabinet() │ 373│ │ 374│ 1. Fetch directories + grants (small, list-all) │ 375│ 2. Build directory tree → show shell immediately │ 376│ 3. Kick off background document cache warm │ 377└───────────────────┬─────────────────────────────────────┘ 378379 ┌───────────┴───────────┐ 380 ▼ ▼ 381 UI Path (foreground) Warming Path (background) 382 ┌──────────────────┐ ┌──────────────────────────┐ 383 │ ensureDirectory- │ │ listDocumentsRaw() │ 384 │ Ready(uri) │ │ │ 385 │ │ │ One paginated request │ 386 │ For each doc URI: │ │ (1-3 pages) fetches all │ 387 │ cache hit → use │ │ documents → writes each │ 388 │ cache miss → │ │ to cache via │ 389 │ getRecordRaw │ │ cachePutCollection() │ 390 │ (rare) │ │ │ 391 └──────────────────┘ └──────────────────────────┘ 392``` 393 394**Why not just fetch per-directory?** AT Protocol's `listRecords` is paginated (1-3 requests for an entire collection). Fetching per-directory means N individual `getRecord` calls — one per document. For 50 documents, that's 50 requests vs 2-3. PDS rate limits make the N+1 pattern impractical as the primary fetch strategy. 395 396**Why not just fetch all documents upfront?** The user shouldn't wait for all documents to load before seeing the directory tree. The tree (directories + grants) is small and loads fast. Documents are the heavy part — caching them in the background lets the UI show the tree instantly while records trickle into the cache. 397 398**The steady state:** After the first background warm, all subsequent directory navigations are pure cache reads. Zero PDS requests. The background warm runs on each `loadCabinet` call (page load, post-mutation refresh) to keep the cache fresh. 399 400**Cache misses are rare.** They only happen when a document was created between the last `listDocumentsRaw` and the user navigating to its directory. In that case, `ensureDirectoryReady` falls back to a single `getRecordRaw` call for the missing record. 401 402#### Invalidation 403 404Mutations invalidate affected caches so stale data isn't shown on the next load: 405 406| Mutation | Invalidation | 407| ----------------------------- | -------------------------------------------------------------------------- | 408| Delete file | `cacheRemoveRecord` (document) + `cacheInvalidateCollection` (directories) | 409| Delete folder | `cacheInvalidateCollection` (documents + directories) | 410| Update metadata | `cacheRemoveRecord` (document) | 411| Rename directory | `cacheInvalidateCollection` (directories) | 412| Upload / create folder / move | Triggers `loadCabinet` which re-warms the cache | 413 414#### Future: Daemon Warming 415 416The background warm in `loadCabinet` is the in-process version of daemon warming. A future service worker or background process would do the same thing — call `listDocumentsRaw` periodically and write to the cache via `cachePutCollection`. The `ensureDirectoryReady` UI path doesn't change. 417 418## Authentication 419 420The CLI authenticates via AT Protocol OAuth 2.0 with DPoP (Demonstrating Proof-of-Possession). On `opake login`, the CLI: 421 4221. Discovers the PDS's authorization server via `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` 4232. Generates a DPoP keypair (P-256/ES256) and PKCE S256 challenge 4243. Sends a Pushed Authorization Request (PAR) with DPoP proof 4254. Opens the browser for user authorization 4265. Listens on a loopback server (`127.0.0.1`) for the OAuth callback 4276. Exchanges the authorization code for tokens with DPoP proof 428 429If OAuth discovery fails (PDS doesn't support it), the CLI falls back to legacy password-based `createSession` with a warning. 430 431`Session` is a discriminated union — `Legacy(LegacySession)` or `OAuth(OAuthSession)`. The XRPC client dispatches auth headers based on the variant: `Authorization: Bearer` for legacy, `Authorization: DPoP` + `DPoP` proof header for OAuth. Token refresh also dispatches per-variant. Existing `session.json` files without a `"type"` field deserialize as `Legacy` for backward compatibility. 432 433The DPoP key is per-session (generated at login time), not per-identity. It's a separate key from the X25519 encryption key and Ed25519 signing key. 434 435## Multi-Account Support 436 437The CLI supports multiple authenticated accounts. Each account has its own: 438 439- Session (OAuth tokens + DPoP key, or legacy JWTs) 440- X25519 keypair 441- PDS URL and handle 442 443Both binaries resolve their config directory through the same chain: `--config-dir` flag → `OPAKE_DATA_DIR` env → `XDG_CONFIG_HOME/opake``~/.config/opake`. Resolution logic lives in `opake-core/src/paths.rs`. 444 445Storage layout: 446 447``` 448~/.config/opake/ 449 config.toml CLI config (default DID, account map) 450 accounts/ 451 <did>/ 452 session.json JWT tokens 453 identity.json X25519 + Ed25519 keypairs (0600, checked on load) 454 keyrings/ 455 <rkey>.json Group keys for each keyring (per-rotation) 456``` 457 458Group keys are stored locally because they never appear in plaintext on the PDS — only wrapped copies exist in the keyring record. Each keyring file holds an array of `{ rotation, group_key }` entries so that keys from previous rotations remain available for decrypting older documents. Legacy files (single `group_key` without rotation) are auto-migrated to rotation 0 on read. 459 460The `--as <handle-or-did>` flag overrides the default account for any command. Keypairs are derived from a BIP-39 seed phrase on first login — see [Identity Derivation](#identity-derivation). 461 462## Device Pairing 463 464When a user logs in on a new device, they can recover their identity either by entering their seed phrase (see [Identity Derivation](#identity-derivation)) or by pairing with an existing device. The pairing protocol uses the PDS as a relay — both devices are authenticated to the same DID and can read/write records in the same repo. 465 466The protocol uses ephemeral X25519 Diffie-Hellman to establish a shared secret. The identity payload is encrypted with AES-256-GCM and the content key is wrapped to the ephemeral public key using the same `x25519-hkdf-a256kw` scheme as document encryption. Both `pairRequest` and `pairResponse` records are deleted after a successful transfer. 467 468```mermaid 469sequenceDiagram 470 participant B as Device B (new) 471 participant PDS 472 participant A as Device A (existing) 473 474 B->>PDS: createRecord pairReq<br/>{ ephemeralKey } 475 A->>PDS: listRecords pairReq 476 PDS->>A: return pairRequest 477 478 Note right of A: DH + encrypt identity 479 480 A->>PDS: createRecord pairResp<br/>{ wrappedKey, ciphertext } 481 B->>PDS: listRecords pairResp 482 PDS->>B: return pairResponse 483 484 Note left of B: unwrap + decrypt identity<br/>verify pubkey matches<br/>save identity.json 485 486 B->>PDS: deleteRecord pairReq 487 B->>PDS: deleteRecord pairResp 488``` 489 490Login on a second device detects an existing `publicKey/self` record and offers three options: `opake pair request` (transfer from existing device), `opake recover` (enter seed phrase), or `opake login --force` (overwrite with new identity). This prevents accidental key overwrites. 491 492See [docs/flows/pairing.md](flows/pairing.md) for the full sequence diagrams. 493 494## File Permissions 495 496All sensitive files (identity, session, config, keyring keys) are written with 4970600 permissions. Directories are created with 0700. Loading `identity.json` 498checks permissions and bails with a `chmod 600` hint if the file is 499group- or world-readable, matching SSH's `StrictModes` behavior.