An encrypted personal cloud built on the AT Protocol.
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└───────────────────┬─────────────────────────────────────┘
378 │
379 ┌───────────┴───────────┐
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.