An encrypted personal cloud built on the AT Protocol.

Split FLOWS.md into per-topic files under docs/flows/

847-line monolith → 7 focused files (auth, documents, sharing,
crypto, keyrings, revisions, multi-device). Original FLOWS.md
replaced with redirect table.

+861 -845
+10 -844
docs/FLOWS.md
··· 1 1 # Opake — Operation Flows 2 2 3 - Sequence diagrams for every CLI operation. All crypto happens client-side — the PDS only stores and serves opaque bytes. 4 - 5 - ## Authentication 6 - 7 - ### Login 8 - 9 - Authenticates with a PDS, persists session + identity, and publishes the encryption public key as a singleton record. 10 - 11 - ```mermaid 12 - sequenceDiagram 13 - participant User 14 - participant CLI 15 - participant PDS 16 - 17 - User->>CLI: opake login --pds <url> --identifier <handle> 18 - CLI->>User: Password prompt (or OPAKE_PASSWORD env) 19 - User-->>CLI: password 20 - 21 - CLI->>PDS: com.atproto.server.createSession 22 - PDS-->>CLI: { did, handle, accessJwt, refreshJwt } 23 - 24 - CLI->>CLI: Save account config + session tokens 25 - CLI->>CLI: Load or generate X25519 keypair 26 - 27 - CLI->>PDS: com.atproto.repo.putRecord (publicKey/self) 28 - PDS-->>CLI: { uri, cid } 29 - 30 - CLI->>User: Logged in as <handle> 31 - ``` 32 - 33 - The `putRecord` call is idempotent — same key, same record. Safe to call on every login. 34 - 35 - ### Token Refresh 36 - 37 - Transparent to the user. The XRPC client detects expired tokens and refreshes automatically. 38 - 39 - ```mermaid 40 - sequenceDiagram 41 - participant CLI 42 - participant PDS 43 - 44 - CLI->>PDS: Any XRPC call (expired accessJwt) 45 - PDS-->>CLI: 400 ExpiredToken 46 - 47 - CLI->>PDS: com.atproto.server.refreshSession (refreshJwt) 48 - PDS-->>CLI: { accessJwt, refreshJwt } (new tokens) 49 - 50 - CLI->>CLI: Update stored session 51 - 52 - CLI->>PDS: Retry original XRPC call (new accessJwt) 53 - PDS-->>CLI: Success 54 - ``` 55 - 56 - ## Document Operations 57 - 58 - ### Upload 59 - 60 - Encrypts a file and uploads it as an opaque blob with a metadata record. 61 - 62 - ```mermaid 63 - sequenceDiagram 64 - participant User 65 - participant CLI 66 - participant Crypto 67 - participant PDS 68 - 69 - User->>CLI: opake upload photo.jpg --tags vacation 70 - 71 - CLI->>CLI: Read file from disk, detect MIME type 72 - CLI->>Crypto: generate_content_key() 73 - Crypto-->>CLI: random AES-256-GCM key K 74 - 75 - CLI->>Crypto: encrypt_blob(K, plaintext) 76 - Crypto-->>CLI: { ciphertext, nonce } 77 - 78 - CLI->>PDS: com.atproto.repo.uploadBlob (ciphertext) 79 - PDS-->>CLI: blob ref { $link, size } 80 - 81 - CLI->>Crypto: wrap_key(K, owner_pubkey, owner_did) 82 - Crypto-->>CLI: wrappedKey (x25519-hkdf-a256kw) 83 - 84 - CLI->>PDS: com.atproto.repo.createRecord (document) 85 - PDS-->>CLI: { uri, cid } 86 - 87 - CLI->>User: Uploaded: at://did/app.opake.cloud.document/<tid> 88 - ``` 89 - 90 - ### Download (Own Files) 91 - 92 - Fetches a document you own, unwraps the content key, and decrypts. 93 - 94 - ```mermaid 95 - sequenceDiagram 96 - participant User 97 - participant CLI 98 - participant PDS 99 - participant Crypto 100 - 101 - User->>CLI: opake download photo.jpg 102 - 103 - CLI->>CLI: Resolve filename → AT-URI (via listRecords if needed) 104 - 105 - CLI->>PDS: com.atproto.repo.getRecord (document) 106 - PDS-->>CLI: Document record (envelope, blob ref) 107 - 108 - CLI->>CLI: Find wrappedKey matching own DID 109 - CLI->>Crypto: unwrap_key(wrappedKey, private_key) 110 - Crypto-->>CLI: content key K 111 - 112 - CLI->>PDS: com.atproto.sync.getBlob (did, cid) 113 - PDS-->>CLI: ciphertext bytes 114 - 115 - CLI->>Crypto: decrypt_blob(K, nonce, ciphertext) 116 - Crypto-->>CLI: plaintext 117 - 118 - CLI->>CLI: Write plaintext to disk 119 - CLI->>User: Saved to ./photo.jpg 120 - ``` 121 - 122 - ### Download (Shared Files — Cross-PDS) 123 - 124 - Downloads a file shared with you by another user. Requires the grant URI (auto-discovery via `inbox` is not yet implemented). 125 - 126 - ```mermaid 127 - sequenceDiagram 128 - participant User 129 - participant CLI 130 - participant PLC as PLC Directory 131 - participant OwnerPDS as Owner's PDS 132 - participant Crypto 133 - 134 - User->>CLI: opake download --grant at://did:plc:owner/.../grant-tid 135 - 136 - CLI->>CLI: Parse grant URI, extract owner DID 137 - 138 - CLI->>PLC: GET /did:plc:owner (DID document) 139 - PLC-->>CLI: { service: [{ #atproto_pds: owner-pds-url }] } 140 - 141 - CLI->>OwnerPDS: com.atproto.repo.getRecord (grant) 142 - OwnerPDS-->>CLI: Grant record { document, wrappedKey } 143 - 144 - CLI->>Crypto: unwrap_key(grant.wrappedKey, private_key) 145 - Crypto-->>CLI: content key K 146 - 147 - CLI->>OwnerPDS: com.atproto.repo.getRecord (document) 148 - OwnerPDS-->>CLI: Document record { blob, encryption.nonce } 149 - 150 - CLI->>OwnerPDS: com.atproto.sync.getBlob (did, cid) 151 - OwnerPDS-->>CLI: ciphertext bytes 152 - 153 - CLI->>Crypto: decrypt_blob(K, nonce, ciphertext) 154 - Crypto-->>CLI: plaintext 155 - 156 - CLI->>CLI: Write to disk 157 - CLI->>User: Saved to ./shared-file.txt 158 - ``` 159 - 160 - Data never leaves the owner's PDS. The recipient fetches everything directly from the source. 161 - 162 - ### List 163 - 164 - Lists document records on your PDS with optional tag filtering. 165 - 166 - ```mermaid 167 - sequenceDiagram 168 - participant User 169 - participant CLI 170 - participant PDS 171 - 172 - User->>CLI: opake ls --tag vacation --long 173 - 174 - loop Paginate until no cursor 175 - CLI->>PDS: com.atproto.repo.listRecords (collection, cursor) 176 - PDS-->>CLI: { records: [...], cursor? } 177 - end 178 - 179 - CLI->>CLI: Parse documents, filter by tag 180 - CLI->>User: Display table (name, size, tags, URI) 181 - ``` 182 - 183 - ### Delete 184 - 185 - Deletes a document record. The blob becomes orphaned and is eventually garbage-collected by the PDS. 186 - 187 - ```mermaid 188 - sequenceDiagram 189 - participant User 190 - participant CLI 191 - participant PDS 192 - 193 - User->>CLI: opake rm photo.jpg 194 - 195 - CLI->>CLI: Resolve filename → AT-URI 196 - CLI->>User: Delete photo.jpg? [y/N] 197 - User-->>CLI: y 198 - 199 - CLI->>PDS: com.atproto.repo.deleteRecord (collection, rkey) 200 - PDS-->>CLI: 200 OK 201 - 202 - CLI->>User: Deleted 203 - ``` 204 - 205 - ## Sharing 206 - 207 - ### Resolve 208 - 209 - Resolves a handle or DID to its PDS and X25519 public key. Used internally by `share`, exposed as a standalone command for inspection. 210 - 211 - ```mermaid 212 - sequenceDiagram 213 - participant User 214 - participant CLI 215 - participant CallerPDS as Caller's PDS 216 - participant PLC as PLC Directory 217 - participant TargetPDS as Target's PDS 218 - 219 - User->>CLI: opake resolve alice.example.com 220 - 221 - alt Input is a handle 222 - CLI->>CallerPDS: com.atproto.identity.resolveHandle 223 - CallerPDS-->>CLI: did:plc:alice 224 - else Input is a DID 225 - CLI->>CLI: Use directly 226 - end 227 - 228 - CLI->>PLC: GET /did:plc:alice (DID document) 229 - PLC-->>CLI: { alsoKnownAs, service: [#atproto_pds → pds-url] } 230 - 231 - CLI->>TargetPDS: com.atproto.repo.getRecord (publicKey/self) 232 - TargetPDS-->>CLI: PublicKeyRecord { publicKey, algo } 233 - 234 - CLI->>User: DID, handle, PDS URL, public key, algorithm 235 - ``` 236 - 237 - ### Share 238 - 239 - Grants another user access to a document by wrapping the content key to their public key. 240 - 241 - ```mermaid 242 - sequenceDiagram 243 - participant User 244 - participant CLI 245 - participant PDS as Own PDS 246 - participant PLC as PLC Directory 247 - participant RecipientPDS as Recipient's PDS 248 - participant Crypto 249 - 250 - User->>CLI: opake share photo.jpg alice.example.com 251 - 252 - CLI->>CLI: Resolve filename → AT-URI 253 - 254 - Note over CLI,RecipientPDS: Resolve recipient identity 255 - CLI->>PLC: DID document for recipient 256 - PLC-->>CLI: { pds_url } 257 - CLI->>RecipientPDS: getRecord (publicKey/self) 258 - RecipientPDS-->>CLI: recipient's X25519 public key 259 - 260 - Note over CLI,PDS: Fetch content key from own document 261 - CLI->>PDS: getRecord (document) 262 - PDS-->>CLI: Document record with owner's wrappedKey 263 - CLI->>Crypto: unwrap_key(owner_wrappedKey, private_key) 264 - Crypto-->>CLI: content key K 265 - 266 - Note over CLI,PDS: Create grant 267 - CLI->>Crypto: wrap_key(K, recipient_pubkey, recipient_did) 268 - Crypto-->>CLI: wrappedKey for recipient 269 - 270 - CLI->>PDS: createRecord (grant) 271 - PDS-->>CLI: { uri, cid } 272 - 273 - CLI->>User: Shared: at://did/.../grant-tid 274 - ``` 275 - 276 - ### Revoke 277 - 278 - Deletes a grant record. The recipient loses network access to the wrapped key. 279 - 280 - ```mermaid 281 - sequenceDiagram 282 - participant User 283 - participant CLI 284 - participant PDS 285 - 286 - User->>CLI: opake revoke at://did/.../grant-tid 287 - 288 - CLI->>CLI: Validate URI is a grant collection 289 - CLI->>PDS: com.atproto.repo.deleteRecord (grant collection, rkey) 290 - PDS-->>CLI: 200 OK 291 - 292 - CLI->>User: Revoked 293 - ``` 294 - 295 - For true forward secrecy, the document should also be re-encrypted with a new content key — the schema supports this but the CLI doesn't automate it yet. 296 - 297 - ## Encryption Primitives 298 - 299 - ### Key Wrapping (x25519-hkdf-a256kw) 300 - 301 - How a symmetric content key gets wrapped to a recipient's X25519 public key. This is the core crypto operation behind both direct encryption and grant creation. 302 - 303 - ```mermaid 304 - flowchart LR 305 - subgraph Wrap ["wrap_key()"] 306 - direction TB 307 - EphKey["Generate ephemeral<br/>X25519 keypair"] --> ECDH 308 - RecipPub["Recipient's<br/>X25519 public key"] --> ECDH 309 - ECDH["X25519 ECDH<br/>shared secret"] --> HKDF 310 - HKDF["HKDF-SHA256<br/>info = 'opake-v1-x25519-hkdf-a256kw-{did}'"] --> KEK 311 - KEK["256-bit key<br/>encryption key"] --> AESKW 312 - ContentKey["Content key K<br/>(AES-256)"] --> AESKW 313 - AESKW["AES-256-KW"] --> Ciphertext 314 - end 315 - 316 - Ciphertext["wrappedKey.ciphertext:<br/>[32B ephemeral pubkey ‖ 40B wrapped key]"] 317 - 318 - style Wrap fill:#1a1a2e,color:#eee 319 - style Ciphertext fill:#16213e,color:#eee 320 - ``` 321 - 322 - ### Keyring Key Wrapping (AES-256-KW) 323 - 324 - How a content key gets wrapped under a keyring's group key. Symmetric wrap — no ECDH, no ephemeral keys. 325 - 326 - ```mermaid 327 - flowchart LR 328 - subgraph Wrap ["wrap_content_key_for_keyring()"] 329 - direction TB 330 - GK["Group key GK<br/>(AES-256)"] --> KEK["AES-256-KW<br/>(RFC 3394)"] 331 - ContentKey["Content key K<br/>(AES-256)"] --> KEK 332 - end 333 - 334 - KEK --> Wrapped["40 bytes<br/>(32B key + 8B integrity)"] 335 - 336 - style Wrap fill:#1a1a2e,color:#eee 337 - style Wrapped fill:#16213e,color:#eee 338 - ``` 339 - 340 - The group key itself is wrapped to each member's X25519 public key using the asymmetric wrapping scheme above. 341 - 342 - ### Content Encryption (AES-256-GCM) 343 - 344 - ```mermaid 345 - flowchart LR 346 - subgraph Encrypt ["encrypt_blob()"] 347 - direction TB 348 - K["Content key K"] --> GCM 349 - Nonce["Random 12-byte nonce"] --> GCM 350 - Plaintext["File bytes"] --> GCM 351 - GCM["AES-256-GCM"] 352 - end 353 - 354 - GCM --> Ciphertext["Ciphertext + auth tag"] 355 - GCM --> StoredNonce["Nonce stored in<br/>document record"] 356 - 357 - style Encrypt fill:#1a1a2e,color:#eee 358 - ``` 359 - 360 - ## Keyrings 361 - 362 - ### Keyring Model 363 - 364 - Two-layer key wrapping for group access. Per-document content keys are wrapped under the group key (AES-KW), and the group key is wrapped to each member's X25519 public key. 365 - 366 - ```mermaid 367 - flowchart TB 368 - subgraph Keyring ["Keyring Record"] 369 - GK["Group Key GK"] 370 - GK -->|wrapped to| Alice["Alice's pubkey"] 371 - GK -->|wrapped to| Bob["Bob's pubkey"] 372 - GK -->|wrapped to| Carol["Carol's pubkey"] 373 - end 374 - 375 - subgraph Doc1 ["Document 1"] 376 - K1["Content Key K₁"] -->|wrapped under GK| GK 377 - end 378 - 379 - subgraph Doc2 ["Document 2"] 380 - K2["Content Key K₂"] -->|wrapped under GK| GK 381 - end 382 - 383 - Alice -.->|unwrap GK, then K₁| K1 384 - Bob -.->|unwrap GK, then K₂| K2 385 - 386 - style Keyring fill:#1a1a2e,color:#eee 387 - style Doc1 fill:#16213e,color:#eee 388 - style Doc2 fill:#16213e,color:#eee 389 - ``` 390 - 391 - ### Create Keyring 392 - 393 - Generates a group key, wraps it to the owner, creates the keyring record, and stores the group key locally. 394 - 395 - ```mermaid 396 - sequenceDiagram 397 - participant User 398 - participant CLI 399 - participant Crypto 400 - participant PDS 401 - participant Disk as Local Storage 402 - 403 - User->>CLI: opake keyring create family-photos 404 - 405 - CLI->>Crypto: create_group_key() 406 - Crypto-->>CLI: group key GK + wrappedKey (GK → owner pubkey) 407 - 408 - CLI->>PDS: com.atproto.repo.createRecord (keyring) 409 - PDS-->>CLI: { uri, cid } 410 - 411 - CLI->>Disk: Save GK to ~/.config/opake/accounts/<did>/keyrings/<rkey>.json 412 - 413 - CLI->>User: family-photos → at://did/.../keyring-tid 414 - ``` 415 - 416 - The group key is never stored in plaintext on the PDS — only the wrapped copies live in the keyring record. 417 - 418 - ### List Keyrings 419 - 420 - ```mermaid 421 - sequenceDiagram 422 - participant User 423 - participant CLI 424 - participant PDS 425 - 426 - User->>CLI: opake keyring ls --long 427 - 428 - loop Paginate until no cursor 429 - CLI->>PDS: com.atproto.repo.listRecords (keyring collection, cursor) 430 - PDS-->>CLI: { records: [...], cursor? } 431 - end 432 - 433 - CLI->>User: Display table (name, members, rotation, URI) 434 - ``` 435 - 436 - ### Add Member 437 - 438 - Resolves the new member's identity, wraps the group key to their public key, and appends them to the keyring record. 439 - 440 - ```mermaid 441 - sequenceDiagram 442 - participant User 443 - participant CLI 444 - participant PDS as Own PDS 445 - participant PLC as PLC Directory 446 - participant MemberPDS as Member's PDS 447 - participant Crypto 448 - participant Disk as Local Storage 449 - 450 - User->>CLI: opake keyring add-member family-photos alice.example.com 451 - 452 - CLI->>PDS: listRecords → resolve "family-photos" to keyring URI 453 - PDS-->>CLI: keyring URI + rkey 454 - 455 - CLI->>Disk: Load group key GK for this keyring 456 - Disk-->>CLI: GK 457 - 458 - Note over CLI,MemberPDS: Resolve new member identity 459 - CLI->>PLC: DID document for alice 460 - PLC-->>CLI: { pds_url } 461 - CLI->>MemberPDS: getRecord (publicKey/self) 462 - MemberPDS-->>CLI: Alice's X25519 public key 463 - 464 - CLI->>Crypto: wrap_key(GK, alice_pubkey, alice_did) 465 - Crypto-->>CLI: wrappedKey for Alice 466 - 467 - CLI->>PDS: getRecord (keyring) → append Alice → putRecord 468 - PDS-->>CLI: 200 OK 469 - 470 - CLI->>User: added alice.example.com to family-photos 471 - ``` 472 - 473 - ### Remove Member 474 - 475 - Removes the member, generates a new group key, re-wraps to all remaining members, and increments the rotation counter. 476 - 477 - ```mermaid 478 - sequenceDiagram 479 - participant User 480 - participant CLI 481 - participant PDS as Own PDS 482 - participant PLC as PLC Directory 483 - participant Crypto 484 - participant Disk as Local Storage 485 - 486 - User->>CLI: opake keyring remove-member family-photos bob.example.com 487 - 488 - CLI->>PDS: Resolve keyring URI + fetch keyring record 489 - PDS-->>CLI: Keyring with members [Alice, Bob, Carol] 490 - 491 - CLI->>User: Removing bob will rotate the group key. Continue? [y/N] 492 - User-->>CLI: y 493 - 494 - Note over CLI,PLC: Resolve remaining members' public keys 495 - CLI->>PLC: DID documents for Alice, Carol 496 - PLC-->>CLI: PDS URLs 497 - CLI->>PDS: getRecord (publicKey/self) for each 498 - PDS-->>CLI: Public keys for Alice, Carol 499 - 500 - CLI->>Crypto: create_group_key() → new GK' 501 - Crypto-->>CLI: GK' + wrappedKeys for [Alice, Carol] 502 - 503 - CLI->>PDS: putRecord (keyring: members=[Alice, Carol], rotation++, keyHistory appended) 504 - PDS-->>CLI: 200 OK 505 - 506 - CLI->>Disk: Save new GK' alongside old GK (keyed by rotation) 507 - 508 - CLI->>User: removed bob from family-photos (key rotated) 509 - ``` 510 - 511 - Before replacing the group key, the old rotation's remaining member entries are archived into the keyring's `keyHistory` array. This lets remaining members still decrypt documents uploaded under previous rotations — even on a new device, the old wrapped group keys are preserved in the record. 512 - 513 - Existing documents encrypted under the old group key stay as-is. New uploads use the new group key. Removed members' wrapped keys are excluded from history, so they cannot recover old group keys from the record. 514 - 515 - ### Upload with Keyring 516 - 517 - Encrypts a file and wraps the content key under the keyring's group key instead of individual public keys. 518 - 519 - ```mermaid 520 - sequenceDiagram 521 - participant User 522 - participant CLI 523 - participant Crypto 524 - participant PDS 525 - participant Disk as Local Storage 526 - 527 - User->>CLI: opake upload photo.jpg --keyring family-photos 528 - 529 - CLI->>PDS: listRecords → resolve "family-photos" to keyring URI 530 - PDS-->>CLI: keyring URI + rkey + rotation 531 - 532 - CLI->>Disk: Load group key GK 533 - Disk-->>CLI: GK 534 - 535 - CLI->>CLI: Read file from disk, detect MIME type 536 - CLI->>Crypto: generate_content_key() → K 537 - CLI->>Crypto: encrypt_blob(K, plaintext) 538 - Crypto-->>CLI: { ciphertext, nonce } 539 - 540 - CLI->>PDS: com.atproto.repo.uploadBlob (ciphertext) 541 - PDS-->>CLI: blob ref 542 - 543 - CLI->>Crypto: wrap_content_key_for_keyring(K, GK) 544 - Crypto-->>CLI: AES-KW wrapped content key 545 - 546 - CLI->>PDS: createRecord (document with keyringEncryption) 547 - PDS-->>CLI: { uri, cid } 548 - 549 - CLI->>User: Uploaded: at://did/.../document-tid 550 - ``` 551 - 552 - The document record references the keyring URI and stores `wrappedContentKey` (content key wrapped under GK) instead of per-DID wrapped keys. 553 - 554 - ### Download Keyring-Encrypted Document 555 - 556 - Automatically detected — the CLI peeks at the document's encryption type and loads the group key if needed. 557 - 558 - ```mermaid 559 - sequenceDiagram 560 - participant User 561 - participant CLI 562 - participant PDS 563 - participant Crypto 564 - participant Disk as Local Storage 3 + This document has been split into per-topic files for maintainability. See [flows/README.md](flows/README.md) for the index. 565 4 566 - User->>CLI: opake download photo.jpg 567 - 568 - CLI->>CLI: Resolve filename → AT-URI 569 - 570 - CLI->>PDS: com.atproto.repo.getRecord (document) 571 - PDS-->>CLI: Document record (keyringEncryption variant) 572 - 573 - CLI->>CLI: Detect keyring encryption, extract keyring rkey + rotation 574 - 575 - CLI->>Disk: Load group key GK for this keyring at document's rotation 576 - Disk-->>CLI: GK 577 - 578 - CLI->>Crypto: unwrap_content_key_from_keyring(wrappedContentKey, GK) 579 - Crypto-->>CLI: content key K 580 - 581 - CLI->>PDS: com.atproto.sync.getBlob (did, cid) 582 - PDS-->>CLI: ciphertext bytes 583 - 584 - CLI->>Crypto: decrypt_blob(K, nonce, ciphertext) 585 - Crypto-->>CLI: plaintext 586 - 587 - CLI->>CLI: Write plaintext to disk 588 - CLI->>User: Saved to ./photo.jpg 589 - ``` 590 - 591 - ## Revisions (Planned) 592 - 593 - Collaborative editing via revision records. Each member uploads revisions to their own PDS — data stays under their control, and the AppView stitches it together. Same pattern as Bluesky replies: your content lives on your PDS, the AppView presents the thread. 594 - 595 - ### Propose Revision (Direct Share) 596 - 597 - A grant recipient uploads a revised version of a shared document to their own PDS. 598 - 599 - ```mermaid 600 - sequenceDiagram 601 - participant Recipient 602 - participant CLI as Recipient's CLI 603 - participant Crypto 604 - participant RecipientPDS as Recipient's PDS 605 - 606 - Recipient->>CLI: opake propose at://owner/.../document/tid photo-edited.jpg 607 - 608 - CLI->>CLI: Read new file, detect MIME type 609 - CLI->>Crypto: generate_content_key() → K' 610 - CLI->>Crypto: encrypt_blob(K', plaintext) 611 - Crypto-->>CLI: { ciphertext, nonce } 612 - 613 - CLI->>RecipientPDS: uploadBlob (ciphertext) 614 - RecipientPDS-->>CLI: blob ref 615 - 616 - CLI->>Crypto: wrap_key(K', recipient_own_pubkey) 617 - Crypto-->>CLI: wrappedKey (self-wrap) 618 - 619 - CLI->>RecipientPDS: createRecord (revision) 620 - Note right of RecipientPDS: origin: at://owner/.../document/tid<br/>blob, encryption, nonce 621 - RecipientPDS-->>CLI: { uri, cid } 622 - 623 - CLI->>Recipient: Proposed: at://recipient/.../revision/tid 624 - ``` 625 - 626 - The revision record lives on the recipient's PDS. It references the original document via an `origin` AT-URI. The owner's data is untouched. 627 - 628 - ### Propose Revision (Keyring Member) 629 - 630 - A keyring member uploads a revised version, encrypted under the shared group key. 631 - 632 - ```mermaid 633 - sequenceDiagram 634 - participant Member 635 - participant CLI as Member's CLI 636 - participant Crypto 637 - participant MemberPDS as Member's PDS 638 - participant Disk as Local Storage 639 - 640 - Member->>CLI: opake propose at://owner/.../document/tid recipe-v2.pdf --keyring family 641 - 642 - CLI->>Disk: Load group key GK for keyring 643 - Disk-->>CLI: GK 644 - 645 - CLI->>CLI: Read new file, detect MIME type 646 - CLI->>Crypto: generate_content_key() → K' 647 - CLI->>Crypto: encrypt_blob(K', plaintext) 648 - Crypto-->>CLI: { ciphertext, nonce } 649 - 650 - CLI->>MemberPDS: uploadBlob (ciphertext) 651 - MemberPDS-->>CLI: blob ref 652 - 653 - CLI->>Crypto: wrap_content_key_for_keyring(K', GK) 654 - Crypto-->>CLI: AES-KW wrapped content key 655 - 656 - CLI->>MemberPDS: createRecord (revision with keyringEncryption) 657 - Note right of MemberPDS: origin: at://owner/.../document/tid<br/>keyring ref from original document 658 - MemberPDS-->>CLI: { uri, cid } 659 - 660 - CLI->>Member: Proposed: at://member/.../revision/tid 661 - ``` 662 - 663 - Any keyring member can decrypt the revision — they already have GK. 664 - 665 - ### Accept Revision (Owner) 666 - 667 - The document owner reviews a proposed revision and applies it by replacing their blob. 668 - 669 - ```mermaid 670 - sequenceDiagram 671 - participant Owner 672 - participant CLI as Owner's CLI 673 - participant PLC as PLC Directory 674 - participant ProposerPDS as Proposer's PDS 675 - participant Crypto 676 - participant OwnerPDS as Owner's PDS 677 - 678 - Owner->>CLI: opake accept at://proposer/.../revision/tid 679 - 680 - CLI->>CLI: Parse revision URI, extract proposer DID 681 - CLI->>PLC: DID document for proposer 682 - PLC-->>CLI: { pds_url } 683 - 684 - CLI->>ProposerPDS: getRecord (revision) 685 - ProposerPDS-->>CLI: Revision record { origin, blob, encryption } 686 - 687 - CLI->>Crypto: Decrypt revision blob (via grant key or GK) 688 - Crypto-->>CLI: new plaintext 689 - 690 - Note over CLI: Re-encrypt under owner's own keys 691 - CLI->>Crypto: generate_content_key() → K'' 692 - CLI->>Crypto: encrypt_blob(K'', plaintext) 693 - Crypto-->>CLI: { ciphertext, nonce } 694 - 695 - CLI->>OwnerPDS: uploadBlob (ciphertext) 696 - OwnerPDS-->>CLI: new blob ref 697 - 698 - CLI->>Crypto: Wrap K'' (to owner + keyring/grants as before) 699 - Crypto-->>CLI: new wrapped keys 700 - 701 - CLI->>OwnerPDS: putRecord (update document with new blob + keys) 702 - OwnerPDS-->>CLI: 200 OK 703 - 704 - CLI->>Owner: Accepted revision, document updated 705 - ``` 706 - 707 - The owner re-encrypts with a fresh content key rather than reusing the proposer's. This ensures the owner's document record remains self-consistent — all wrapped keys reference the same content key, and the blob is stored on the owner's PDS. 708 - 709 - ### Discovery via AppView 710 - 711 - Without an AppView, revision discovery requires polling known members' PDSes. The AppView automates this by watching firehose events. 712 - 713 - ```mermaid 714 - sequenceDiagram 715 - participant AppView 716 - participant MemberPDS as Member's PDS 717 - participant OwnerPDS as Owner's PDS 718 - 719 - MemberPDS->>AppView: Firehose event: new revision record 720 - AppView->>AppView: Index revision by origin URI 721 - 722 - Note over AppView: Later, owner queries pending revisions 723 - 724 - OwnerPDS->>AppView: GET /revisions?origin=at://owner/.../document/tid 725 - AppView-->>OwnerPDS: [{ revision_uri, proposer, created_at }, ...] 726 - ``` 727 - 728 - Without the AppView, `opake revisions <document>` can fall back to polling each keyring member's PDS for `app.opake.cloud.revision` records whose `origin` matches the document URI. Slow but functional, and zero-trust — no intermediary needed. 729 - 730 - ## Multi-Device Identity (Planned) 731 - 732 - Deterministic keypair derivation from a BIP-39 mnemonic. Same seed on any device produces the same X25519 keypair — no key sync protocol needed. Replaces the current plaintext keypair file at `~/.config/opake/accounts/<did>/identity.json`. 733 - 734 - ### Keypair Derivation 735 - 736 - ```mermaid 737 - flowchart TB 738 - subgraph Generate ["First-time setup (opake init or first login)"] 739 - direction TB 740 - Entropy["128 bits of entropy<br/>(from OS CSPRNG)"] --> Mnemonic 741 - Mnemonic["BIP-39 mnemonic<br/>(12 words)"] 742 - end 743 - 744 - subgraph Derive ["Keypair derivation (every login)"] 745 - direction TB 746 - Mnemonic --> PBKDF["BIP-39 seed derivation<br/>PBKDF2-HMAC-SHA512<br/>2048 rounds, salt = 'mnemonic'"] 747 - PBKDF --> Seed["512-bit master seed"] 748 - Seed --> HKDF["HKDF-SHA256<br/>info = 'opake-v1-x25519-identity'"] 749 - HKDF --> PrivKey["32-byte X25519<br/>private key"] 750 - PrivKey --> PubKey["X25519 public key<br/>(clamped, published to PDS)"] 751 - end 752 - 753 - style Generate fill:#1a1a2e,color:#eee 754 - style Derive fill:#16213e,color:#eee 755 - ``` 756 - 757 - The mnemonic is the root secret — everything else is derived. Losing it means losing access to all encrypted data. The HKDF info string includes the schema version for domain separation, same convention as key wrapping. 758 - 759 - ### First Login (New Device) 760 - 761 - ```mermaid 762 - sequenceDiagram 763 - participant User 764 - participant CLI 765 - participant Crypto 766 - participant PDS 767 - 768 - User->>CLI: opake login --pds <url> --identifier <handle> 769 - CLI->>User: No identity found. Enter seed phrase or generate new? 770 - User-->>CLI: "abandon ability able about above absent ..." 771 - 772 - CLI->>Crypto: BIP-39 validate (checksum, wordlist) 773 - Crypto-->>CLI: valid 774 - 775 - CLI->>Crypto: mnemonic → PBKDF2 → 512-bit seed 776 - CLI->>Crypto: seed → HKDF-SHA256 → X25519 private key 777 - Crypto-->>CLI: keypair (private + public) 778 - 779 - CLI->>CLI: Save identity (private key to disk) 780 - 781 - CLI->>PDS: com.atproto.server.createSession 782 - PDS-->>CLI: { did, handle, accessJwt, refreshJwt } 783 - 784 - CLI->>PDS: putRecord (publicKey/self) 785 - PDS-->>CLI: { uri, cid } 786 - 787 - CLI->>User: Logged in as <handle> 788 - ``` 789 - 790 - If the published public key doesn't match the derived one, the CLI warns — either a wrong seed phrase or a key was published from a different seed. The user decides whether to overwrite. 791 - 792 - ### Generate New Identity 793 - 794 - ```mermaid 795 - sequenceDiagram 796 - participant User 797 - participant CLI 798 - participant Crypto 799 - 800 - User->>CLI: opake init 801 - 802 - CLI->>Crypto: Generate 128 bits entropy (CSPRNG) 803 - Crypto-->>CLI: entropy 804 - CLI->>Crypto: BIP-39 encode (entropy → 12 words) 805 - Crypto-->>CLI: mnemonic 806 - 807 - CLI->>User: Your seed phrase (write this down):<br/>"abandon ability able about ..." 808 - 809 - CLI->>User: Confirm by entering words 3, 7, 11 810 - User-->>CLI: "able", "absent", "above" 811 - CLI->>CLI: Verify matches 812 - 813 - CLI->>Crypto: Derive keypair from mnemonic 814 - Crypto-->>CLI: keypair 815 - 816 - CLI->>CLI: Save identity to disk 817 - CLI->>User: Identity created. Run `opake login` to connect to a PDS. 818 - ``` 819 - 820 - The confirmation step guards against clipboard-and-forget. The seed phrase is shown exactly once — the CLI never stores or displays it again. 821 - 822 - ### Key Mismatch Recovery 823 - 824 - ```mermaid 825 - sequenceDiagram 826 - participant User 827 - participant CLI 828 - participant PDS 829 - 830 - User->>CLI: opake login --pds <url> --identifier <handle> 831 - CLI->>CLI: Derive keypair from seed phrase 832 - 833 - CLI->>PDS: getRecord (publicKey/self) 834 - PDS-->>CLI: Published public key ≠ derived public key 835 - 836 - CLI->>User: Warning: PDS has a different public key.<br/>This means either:<br/>1. Wrong seed phrase<br/>2. Key was published from another seed 837 - 838 - CLI->>User: Overwrite published key? [y/N] 839 - User-->>CLI: y 840 - 841 - CLI->>PDS: putRecord (publicKey/self) with derived key 842 - PDS-->>CLI: { uri, cid } 843 - 844 - CLI->>User: Public key updated. Previous grants may be unreadable. 845 - ``` 846 - 847 - Overwriting the published key breaks any existing grants or keyring memberships that wrapped keys to the old public key. The CLI should be loud about this. 5 + | File | Topic | 6 + |------|-------| 7 + | [flows/authentication.md](flows/authentication.md) | Login, token refresh | 8 + | [flows/documents.md](flows/documents.md) | Upload, download, list, delete | 9 + | [flows/sharing.md](flows/sharing.md) | Resolve, share, revoke | 10 + | [flows/crypto.md](flows/crypto.md) | Key wrapping, content encryption primitives | 11 + | [flows/keyrings.md](flows/keyrings.md) | Create, list, add/remove member, keyring upload/download | 12 + | [flows/revisions.md](flows/revisions.md) | Collaborative editing via revision records (planned) | 13 + | [flows/multi-device.md](flows/multi-device.md) | Seed phrase identity, BIP-39 derivation (planned) |
+13
docs/flows/README.md
··· 1 + # Opake — Operation Flows 2 + 3 + Sequence diagrams for every CLI operation. All crypto happens client-side — the PDS only stores and serves opaque bytes. 4 + 5 + | File | Topic | 6 + |------|-------| 7 + | [authentication.md](authentication.md) | Login, token refresh | 8 + | [documents.md](documents.md) | Upload, download, list, delete | 9 + | [sharing.md](sharing.md) | Resolve, share, revoke | 10 + | [crypto.md](crypto.md) | Key wrapping, content encryption primitives | 11 + | [keyrings.md](keyrings.md) | Create, list, add/remove member, keyring upload/download | 12 + | [revisions.md](revisions.md) | Collaborative editing via revision records (planned) | 13 + | [multi-device.md](multi-device.md) | Seed phrase identity, BIP-39 derivation (planned) |
+50
docs/flows/authentication.md
··· 1 + # Authentication 2 + 3 + ## Login 4 + 5 + Authenticates with a PDS, persists session + identity, and publishes the encryption public key as a singleton record. 6 + 7 + ```mermaid 8 + sequenceDiagram 9 + participant User 10 + participant CLI 11 + participant PDS 12 + 13 + User->>CLI: opake login --pds <url> --identifier <handle> 14 + CLI->>User: Password prompt (or OPAKE_PASSWORD env) 15 + User-->>CLI: password 16 + 17 + CLI->>PDS: com.atproto.server.createSession 18 + PDS-->>CLI: { did, handle, accessJwt, refreshJwt } 19 + 20 + CLI->>CLI: Save account config + session tokens 21 + CLI->>CLI: Load or generate X25519 keypair 22 + 23 + CLI->>PDS: com.atproto.repo.putRecord (publicKey/self) 24 + PDS-->>CLI: { uri, cid } 25 + 26 + CLI->>User: Logged in as <handle> 27 + ``` 28 + 29 + The `putRecord` call is idempotent — same key, same record. Safe to call on every login. 30 + 31 + ## Token Refresh 32 + 33 + Transparent to the user. The XRPC client detects expired tokens and refreshes automatically. 34 + 35 + ```mermaid 36 + sequenceDiagram 37 + participant CLI 38 + participant PDS 39 + 40 + CLI->>PDS: Any XRPC call (expired accessJwt) 41 + PDS-->>CLI: 400 ExpiredToken 42 + 43 + CLI->>PDS: com.atproto.server.refreshSession (refreshJwt) 44 + PDS-->>CLI: { accessJwt, refreshJwt } (new tokens) 45 + 46 + CLI->>CLI: Update stored session 47 + 48 + CLI->>PDS: Retry original XRPC call (new accessJwt) 49 + PDS-->>CLI: Success 50 + ```
+62
docs/flows/crypto.md
··· 1 + # Encryption Primitives 2 + 3 + ## Key Wrapping (x25519-hkdf-a256kw) 4 + 5 + How a symmetric content key gets wrapped to a recipient's X25519 public key. This is the core crypto operation behind both direct encryption and grant creation. 6 + 7 + ```mermaid 8 + flowchart LR 9 + subgraph Wrap ["wrap_key()"] 10 + direction TB 11 + EphKey["Generate ephemeral<br/>X25519 keypair"] --> ECDH 12 + RecipPub["Recipient's<br/>X25519 public key"] --> ECDH 13 + ECDH["X25519 ECDH<br/>shared secret"] --> HKDF 14 + HKDF["HKDF-SHA256<br/>info = 'opake-v1-x25519-hkdf-a256kw-{did}'"] --> KEK 15 + KEK["256-bit key<br/>encryption key"] --> AESKW 16 + ContentKey["Content key K<br/>(AES-256)"] --> AESKW 17 + AESKW["AES-256-KW"] --> Ciphertext 18 + end 19 + 20 + Ciphertext["wrappedKey.ciphertext:<br/>[32B ephemeral pubkey ‖ 40B wrapped key]"] 21 + 22 + style Wrap fill:#1a1a2e,color:#eee 23 + style Ciphertext fill:#16213e,color:#eee 24 + ``` 25 + 26 + ## Keyring Key Wrapping (AES-256-KW) 27 + 28 + How a content key gets wrapped under a keyring's group key. Symmetric wrap — no ECDH, no ephemeral keys. 29 + 30 + ```mermaid 31 + flowchart LR 32 + subgraph Wrap ["wrap_content_key_for_keyring()"] 33 + direction TB 34 + GK["Group key GK<br/>(AES-256)"] --> KEK["AES-256-KW<br/>(RFC 3394)"] 35 + ContentKey["Content key K<br/>(AES-256)"] --> KEK 36 + end 37 + 38 + KEK --> Wrapped["40 bytes<br/>(32B key + 8B integrity)"] 39 + 40 + style Wrap fill:#1a1a2e,color:#eee 41 + style Wrapped fill:#16213e,color:#eee 42 + ``` 43 + 44 + The group key itself is wrapped to each member's X25519 public key using the asymmetric wrapping scheme above. 45 + 46 + ## Content Encryption (AES-256-GCM) 47 + 48 + ```mermaid 49 + flowchart LR 50 + subgraph Encrypt ["encrypt_blob()"] 51 + direction TB 52 + K["Content key K"] --> GCM 53 + Nonce["Random 12-byte nonce"] --> GCM 54 + Plaintext["File bytes"] --> GCM 55 + GCM["AES-256-GCM"] 56 + end 57 + 58 + GCM --> Ciphertext["Ciphertext + auth tag"] 59 + GCM --> StoredNonce["Nonce stored in<br/>document record"] 60 + 61 + style Encrypt fill:#1a1a2e,color:#eee 62 + ```
+148
docs/flows/documents.md
··· 1 + # Document Operations 2 + 3 + ## Upload 4 + 5 + Encrypts a file and uploads it as an opaque blob with a metadata record. 6 + 7 + ```mermaid 8 + sequenceDiagram 9 + participant User 10 + participant CLI 11 + participant Crypto 12 + participant PDS 13 + 14 + User->>CLI: opake upload photo.jpg --tags vacation 15 + 16 + CLI->>CLI: Read file from disk, detect MIME type 17 + CLI->>Crypto: generate_content_key() 18 + Crypto-->>CLI: random AES-256-GCM key K 19 + 20 + CLI->>Crypto: encrypt_blob(K, plaintext) 21 + Crypto-->>CLI: { ciphertext, nonce } 22 + 23 + CLI->>PDS: com.atproto.repo.uploadBlob (ciphertext) 24 + PDS-->>CLI: blob ref { $link, size } 25 + 26 + CLI->>Crypto: wrap_key(K, owner_pubkey, owner_did) 27 + Crypto-->>CLI: wrappedKey (x25519-hkdf-a256kw) 28 + 29 + CLI->>PDS: com.atproto.repo.createRecord (document) 30 + PDS-->>CLI: { uri, cid } 31 + 32 + CLI->>User: Uploaded: at://did/app.opake.cloud.document/<tid> 33 + ``` 34 + 35 + ## Download (Own Files) 36 + 37 + Fetches a document you own, unwraps the content key, and decrypts. 38 + 39 + ```mermaid 40 + sequenceDiagram 41 + participant User 42 + participant CLI 43 + participant PDS 44 + participant Crypto 45 + 46 + User->>CLI: opake download photo.jpg 47 + 48 + CLI->>CLI: Resolve filename → AT-URI (via listRecords if needed) 49 + 50 + CLI->>PDS: com.atproto.repo.getRecord (document) 51 + PDS-->>CLI: Document record (envelope, blob ref) 52 + 53 + CLI->>CLI: Find wrappedKey matching own DID 54 + CLI->>Crypto: unwrap_key(wrappedKey, private_key) 55 + Crypto-->>CLI: content key K 56 + 57 + CLI->>PDS: com.atproto.sync.getBlob (did, cid) 58 + PDS-->>CLI: ciphertext bytes 59 + 60 + CLI->>Crypto: decrypt_blob(K, nonce, ciphertext) 61 + Crypto-->>CLI: plaintext 62 + 63 + CLI->>CLI: Write plaintext to disk 64 + CLI->>User: Saved to ./photo.jpg 65 + ``` 66 + 67 + ## Download (Shared Files — Cross-PDS) 68 + 69 + Downloads a file shared with you by another user. Requires the grant URI (auto-discovery via `inbox` is not yet implemented). 70 + 71 + ```mermaid 72 + sequenceDiagram 73 + participant User 74 + participant CLI 75 + participant PLC as PLC Directory 76 + participant OwnerPDS as Owner's PDS 77 + participant Crypto 78 + 79 + User->>CLI: opake download --grant at://did:plc:owner/.../grant-tid 80 + 81 + CLI->>CLI: Parse grant URI, extract owner DID 82 + 83 + CLI->>PLC: GET /did:plc:owner (DID document) 84 + PLC-->>CLI: { service: [{ #atproto_pds: owner-pds-url }] } 85 + 86 + CLI->>OwnerPDS: com.atproto.repo.getRecord (grant) 87 + OwnerPDS-->>CLI: Grant record { document, wrappedKey } 88 + 89 + CLI->>Crypto: unwrap_key(grant.wrappedKey, private_key) 90 + Crypto-->>CLI: content key K 91 + 92 + CLI->>OwnerPDS: com.atproto.repo.getRecord (document) 93 + OwnerPDS-->>CLI: Document record { blob, encryption.nonce } 94 + 95 + CLI->>OwnerPDS: com.atproto.sync.getBlob (did, cid) 96 + OwnerPDS-->>CLI: ciphertext bytes 97 + 98 + CLI->>Crypto: decrypt_blob(K, nonce, ciphertext) 99 + Crypto-->>CLI: plaintext 100 + 101 + CLI->>CLI: Write to disk 102 + CLI->>User: Saved to ./shared-file.txt 103 + ``` 104 + 105 + Data never leaves the owner's PDS. The recipient fetches everything directly from the source. 106 + 107 + ## List 108 + 109 + Lists document records on your PDS with optional tag filtering. 110 + 111 + ```mermaid 112 + sequenceDiagram 113 + participant User 114 + participant CLI 115 + participant PDS 116 + 117 + User->>CLI: opake ls --tag vacation --long 118 + 119 + loop Paginate until no cursor 120 + CLI->>PDS: com.atproto.repo.listRecords (collection, cursor) 121 + PDS-->>CLI: { records: [...], cursor? } 122 + end 123 + 124 + CLI->>CLI: Parse documents, filter by tag 125 + CLI->>User: Display table (name, size, tags, URI) 126 + ``` 127 + 128 + ## Delete 129 + 130 + Deletes a document record. The blob becomes orphaned and is eventually garbage-collected by the PDS. 131 + 132 + ```mermaid 133 + sequenceDiagram 134 + participant User 135 + participant CLI 136 + participant PDS 137 + 138 + User->>CLI: opake rm photo.jpg 139 + 140 + CLI->>CLI: Resolve filename → AT-URI 141 + CLI->>User: Delete photo.jpg? [y/N] 142 + User-->>CLI: y 143 + 144 + CLI->>PDS: com.atproto.repo.deleteRecord (collection, rkey) 145 + PDS-->>CLI: 200 OK 146 + 147 + CLI->>User: Deleted 148 + ```
+230
docs/flows/keyrings.md
··· 1 + # Keyrings 2 + 3 + ## Keyring Model 4 + 5 + Two-layer key wrapping for group access. Per-document content keys are wrapped under the group key (AES-KW), and the group key is wrapped to each member's X25519 public key. 6 + 7 + ```mermaid 8 + flowchart TB 9 + subgraph Keyring ["Keyring Record"] 10 + GK["Group Key GK"] 11 + GK -->|wrapped to| Alice["Alice's pubkey"] 12 + GK -->|wrapped to| Bob["Bob's pubkey"] 13 + GK -->|wrapped to| Carol["Carol's pubkey"] 14 + end 15 + 16 + subgraph Doc1 ["Document 1"] 17 + K1["Content Key K₁"] -->|wrapped under GK| GK 18 + end 19 + 20 + subgraph Doc2 ["Document 2"] 21 + K2["Content Key K₂"] -->|wrapped under GK| GK 22 + end 23 + 24 + Alice -.->|unwrap GK, then K₁| K1 25 + Bob -.->|unwrap GK, then K₂| K2 26 + 27 + style Keyring fill:#1a1a2e,color:#eee 28 + style Doc1 fill:#16213e,color:#eee 29 + style Doc2 fill:#16213e,color:#eee 30 + ``` 31 + 32 + ## Create Keyring 33 + 34 + Generates a group key, wraps it to the owner, creates the keyring record, and stores the group key locally. 35 + 36 + ```mermaid 37 + sequenceDiagram 38 + participant User 39 + participant CLI 40 + participant Crypto 41 + participant PDS 42 + participant Disk as Local Storage 43 + 44 + User->>CLI: opake keyring create family-photos 45 + 46 + CLI->>Crypto: create_group_key() 47 + Crypto-->>CLI: group key GK + wrappedKey (GK → owner pubkey) 48 + 49 + CLI->>PDS: com.atproto.repo.createRecord (keyring) 50 + PDS-->>CLI: { uri, cid } 51 + 52 + CLI->>Disk: Save GK to ~/.config/opake/accounts/<did>/keyrings/<rkey>.json 53 + 54 + CLI->>User: family-photos → at://did/.../keyring-tid 55 + ``` 56 + 57 + The group key is never stored in plaintext on the PDS — only the wrapped copies live in the keyring record. 58 + 59 + ## List Keyrings 60 + 61 + ```mermaid 62 + sequenceDiagram 63 + participant User 64 + participant CLI 65 + participant PDS 66 + 67 + User->>CLI: opake keyring ls --long 68 + 69 + loop Paginate until no cursor 70 + CLI->>PDS: com.atproto.repo.listRecords (keyring collection, cursor) 71 + PDS-->>CLI: { records: [...], cursor? } 72 + end 73 + 74 + CLI->>User: Display table (name, members, rotation, URI) 75 + ``` 76 + 77 + ## Add Member 78 + 79 + Resolves the new member's identity, wraps the group key to their public key, and appends them to the keyring record. 80 + 81 + ```mermaid 82 + sequenceDiagram 83 + participant User 84 + participant CLI 85 + participant PDS as Own PDS 86 + participant PLC as PLC Directory 87 + participant MemberPDS as Member's PDS 88 + participant Crypto 89 + participant Disk as Local Storage 90 + 91 + User->>CLI: opake keyring add-member family-photos alice.example.com 92 + 93 + CLI->>PDS: listRecords → resolve "family-photos" to keyring URI 94 + PDS-->>CLI: keyring URI + rkey 95 + 96 + CLI->>Disk: Load group key GK for this keyring 97 + Disk-->>CLI: GK 98 + 99 + Note over CLI,MemberPDS: Resolve new member identity 100 + CLI->>PLC: DID document for alice 101 + PLC-->>CLI: { pds_url } 102 + CLI->>MemberPDS: getRecord (publicKey/self) 103 + MemberPDS-->>CLI: Alice's X25519 public key 104 + 105 + CLI->>Crypto: wrap_key(GK, alice_pubkey, alice_did) 106 + Crypto-->>CLI: wrappedKey for Alice 107 + 108 + CLI->>PDS: getRecord (keyring) → append Alice → putRecord 109 + PDS-->>CLI: 200 OK 110 + 111 + CLI->>User: added alice.example.com to family-photos 112 + ``` 113 + 114 + ## Remove Member 115 + 116 + Removes the member, generates a new group key, re-wraps to all remaining members, and increments the rotation counter. 117 + 118 + ```mermaid 119 + sequenceDiagram 120 + participant User 121 + participant CLI 122 + participant PDS as Own PDS 123 + participant PLC as PLC Directory 124 + participant Crypto 125 + participant Disk as Local Storage 126 + 127 + User->>CLI: opake keyring remove-member family-photos bob.example.com 128 + 129 + CLI->>PDS: Resolve keyring URI + fetch keyring record 130 + PDS-->>CLI: Keyring with members [Alice, Bob, Carol] 131 + 132 + CLI->>User: Removing bob will rotate the group key. Continue? [y/N] 133 + User-->>CLI: y 134 + 135 + Note over CLI,PLC: Resolve remaining members' public keys 136 + CLI->>PLC: DID documents for Alice, Carol 137 + PLC-->>CLI: PDS URLs 138 + CLI->>PDS: getRecord (publicKey/self) for each 139 + PDS-->>CLI: Public keys for Alice, Carol 140 + 141 + CLI->>Crypto: create_group_key() → new GK' 142 + Crypto-->>CLI: GK' + wrappedKeys for [Alice, Carol] 143 + 144 + CLI->>PDS: putRecord (keyring: members=[Alice, Carol], rotation++, keyHistory appended) 145 + PDS-->>CLI: 200 OK 146 + 147 + CLI->>Disk: Save new GK' alongside old GK (keyed by rotation) 148 + 149 + CLI->>User: removed bob from family-photos (key rotated) 150 + ``` 151 + 152 + Before replacing the group key, the old rotation's remaining member entries are archived into the keyring's `keyHistory` array. This lets remaining members still decrypt documents uploaded under previous rotations — even on a new device, the old wrapped group keys are preserved in the record. 153 + 154 + Existing documents encrypted under the old group key stay as-is. New uploads use the new group key. Removed members' wrapped keys are excluded from history, so they cannot recover old group keys from the record. 155 + 156 + ## Upload with Keyring 157 + 158 + Encrypts a file and wraps the content key under the keyring's group key instead of individual public keys. 159 + 160 + ```mermaid 161 + sequenceDiagram 162 + participant User 163 + participant CLI 164 + participant Crypto 165 + participant PDS 166 + participant Disk as Local Storage 167 + 168 + User->>CLI: opake upload photo.jpg --keyring family-photos 169 + 170 + CLI->>PDS: listRecords → resolve "family-photos" to keyring URI 171 + PDS-->>CLI: keyring URI + rkey + rotation 172 + 173 + CLI->>Disk: Load group key GK 174 + Disk-->>CLI: GK 175 + 176 + CLI->>CLI: Read file from disk, detect MIME type 177 + CLI->>Crypto: generate_content_key() → K 178 + CLI->>Crypto: encrypt_blob(K, plaintext) 179 + Crypto-->>CLI: { ciphertext, nonce } 180 + 181 + CLI->>PDS: com.atproto.repo.uploadBlob (ciphertext) 182 + PDS-->>CLI: blob ref 183 + 184 + CLI->>Crypto: wrap_content_key_for_keyring(K, GK) 185 + Crypto-->>CLI: AES-KW wrapped content key 186 + 187 + CLI->>PDS: createRecord (document with keyringEncryption) 188 + PDS-->>CLI: { uri, cid } 189 + 190 + CLI->>User: Uploaded: at://did/.../document-tid 191 + ``` 192 + 193 + The document record references the keyring URI and stores `wrappedContentKey` (content key wrapped under GK) instead of per-DID wrapped keys. 194 + 195 + ## Download Keyring-Encrypted Document 196 + 197 + Automatically detected — the CLI peeks at the document's encryption type and loads the group key if needed. 198 + 199 + ```mermaid 200 + sequenceDiagram 201 + participant User 202 + participant CLI 203 + participant PDS 204 + participant Crypto 205 + participant Disk as Local Storage 206 + 207 + User->>CLI: opake download photo.jpg 208 + 209 + CLI->>CLI: Resolve filename → AT-URI 210 + 211 + CLI->>PDS: com.atproto.repo.getRecord (document) 212 + PDS-->>CLI: Document record (keyringEncryption variant) 213 + 214 + CLI->>CLI: Detect keyring encryption, extract keyring rkey + rotation 215 + 216 + CLI->>Disk: Load group key GK for this keyring at document's rotation 217 + Disk-->>CLI: GK 218 + 219 + CLI->>Crypto: unwrap_content_key_from_keyring(wrappedContentKey, GK) 220 + Crypto-->>CLI: content key K 221 + 222 + CLI->>PDS: com.atproto.sync.getBlob (did, cid) 223 + PDS-->>CLI: ciphertext bytes 224 + 225 + CLI->>Crypto: decrypt_blob(K, nonce, ciphertext) 226 + Crypto-->>CLI: plaintext 227 + 228 + CLI->>CLI: Write plaintext to disk 229 + CLI->>User: Saved to ./photo.jpg 230 + ```
+118
docs/flows/multi-device.md
··· 1 + # Multi-Device Identity (Planned) 2 + 3 + Deterministic keypair derivation from a BIP-39 mnemonic. Same seed on any device produces the same X25519 keypair — no key sync protocol needed. Replaces the current plaintext keypair file at `~/.config/opake/accounts/<did>/identity.json`. 4 + 5 + ## Keypair Derivation 6 + 7 + ```mermaid 8 + flowchart TB 9 + subgraph Generate ["First-time setup (opake init or first login)"] 10 + direction TB 11 + Entropy["128 bits of entropy<br/>(from OS CSPRNG)"] --> Mnemonic 12 + Mnemonic["BIP-39 mnemonic<br/>(12 words)"] 13 + end 14 + 15 + subgraph Derive ["Keypair derivation (every login)"] 16 + direction TB 17 + Mnemonic --> PBKDF["BIP-39 seed derivation<br/>PBKDF2-HMAC-SHA512<br/>2048 rounds, salt = 'mnemonic'"] 18 + PBKDF --> Seed["512-bit master seed"] 19 + Seed --> HKDF["HKDF-SHA256<br/>info = 'opake-v1-x25519-identity'"] 20 + HKDF --> PrivKey["32-byte X25519<br/>private key"] 21 + PrivKey --> PubKey["X25519 public key<br/>(clamped, published to PDS)"] 22 + end 23 + 24 + style Generate fill:#1a1a2e,color:#eee 25 + style Derive fill:#16213e,color:#eee 26 + ``` 27 + 28 + The mnemonic is the root secret — everything else is derived. Losing it means losing access to all encrypted data. The HKDF info string includes the schema version for domain separation, same convention as key wrapping. 29 + 30 + ## First Login (New Device) 31 + 32 + ```mermaid 33 + sequenceDiagram 34 + participant User 35 + participant CLI 36 + participant Crypto 37 + participant PDS 38 + 39 + User->>CLI: opake login --pds <url> --identifier <handle> 40 + CLI->>User: No identity found. Enter seed phrase or generate new? 41 + User-->>CLI: "abandon ability able about above absent ..." 42 + 43 + CLI->>Crypto: BIP-39 validate (checksum, wordlist) 44 + Crypto-->>CLI: valid 45 + 46 + CLI->>Crypto: mnemonic → PBKDF2 → 512-bit seed 47 + CLI->>Crypto: seed → HKDF-SHA256 → X25519 private key 48 + Crypto-->>CLI: keypair (private + public) 49 + 50 + CLI->>CLI: Save identity (private key to disk) 51 + 52 + CLI->>PDS: com.atproto.server.createSession 53 + PDS-->>CLI: { did, handle, accessJwt, refreshJwt } 54 + 55 + CLI->>PDS: putRecord (publicKey/self) 56 + PDS-->>CLI: { uri, cid } 57 + 58 + CLI->>User: Logged in as <handle> 59 + ``` 60 + 61 + If the published public key doesn't match the derived one, the CLI warns — either a wrong seed phrase or a key was published from a different seed. The user decides whether to overwrite. 62 + 63 + ## Generate New Identity 64 + 65 + ```mermaid 66 + sequenceDiagram 67 + participant User 68 + participant CLI 69 + participant Crypto 70 + 71 + User->>CLI: opake init 72 + 73 + CLI->>Crypto: Generate 128 bits entropy (CSPRNG) 74 + Crypto-->>CLI: entropy 75 + CLI->>Crypto: BIP-39 encode (entropy → 12 words) 76 + Crypto-->>CLI: mnemonic 77 + 78 + CLI->>User: Your seed phrase (write this down):<br/>"abandon ability able about ..." 79 + 80 + CLI->>User: Confirm by entering words 3, 7, 11 81 + User-->>CLI: "able", "absent", "above" 82 + CLI->>CLI: Verify matches 83 + 84 + CLI->>Crypto: Derive keypair from mnemonic 85 + Crypto-->>CLI: keypair 86 + 87 + CLI->>CLI: Save identity to disk 88 + CLI->>User: Identity created. Run `opake login` to connect to a PDS. 89 + ``` 90 + 91 + The confirmation step guards against clipboard-and-forget. The seed phrase is shown exactly once — the CLI never stores or displays it again. 92 + 93 + ## Key Mismatch Recovery 94 + 95 + ```mermaid 96 + sequenceDiagram 97 + participant User 98 + participant CLI 99 + participant PDS 100 + 101 + User->>CLI: opake login --pds <url> --identifier <handle> 102 + CLI->>CLI: Derive keypair from seed phrase 103 + 104 + CLI->>PDS: getRecord (publicKey/self) 105 + PDS-->>CLI: Published public key ≠ derived public key 106 + 107 + CLI->>User: Warning: PDS has a different public key.<br/>This means either:<br/>1. Wrong seed phrase<br/>2. Key was published from another seed 108 + 109 + CLI->>User: Overwrite published key? [y/N] 110 + User-->>CLI: y 111 + 112 + CLI->>PDS: putRecord (publicKey/self) with derived key 113 + PDS-->>CLI: { uri, cid } 114 + 115 + CLI->>User: Public key updated. Previous grants may be unreadable. 116 + ``` 117 + 118 + Overwriting the published key breaks any existing grants or keyring memberships that wrapped keys to the old public key. The CLI should be loud about this.
+138
docs/flows/revisions.md
··· 1 + # Revisions (Planned) 2 + 3 + Collaborative editing via revision records. Each member uploads revisions to their own PDS — data stays under their control, and the AppView stitches it together. Same pattern as Bluesky replies: your content lives on your PDS, the AppView presents the thread. 4 + 5 + ## Propose Revision (Direct Share) 6 + 7 + A grant recipient uploads a revised version of a shared document to their own PDS. 8 + 9 + ```mermaid 10 + sequenceDiagram 11 + participant Recipient 12 + participant CLI as Recipient's CLI 13 + participant Crypto 14 + participant RecipientPDS as Recipient's PDS 15 + 16 + Recipient->>CLI: opake propose at://owner/.../document/tid photo-edited.jpg 17 + 18 + CLI->>CLI: Read new file, detect MIME type 19 + CLI->>Crypto: generate_content_key() → K' 20 + CLI->>Crypto: encrypt_blob(K', plaintext) 21 + Crypto-->>CLI: { ciphertext, nonce } 22 + 23 + CLI->>RecipientPDS: uploadBlob (ciphertext) 24 + RecipientPDS-->>CLI: blob ref 25 + 26 + CLI->>Crypto: wrap_key(K', recipient_own_pubkey) 27 + Crypto-->>CLI: wrappedKey (self-wrap) 28 + 29 + CLI->>RecipientPDS: createRecord (revision) 30 + Note right of RecipientPDS: origin: at://owner/.../document/tid<br/>blob, encryption, nonce 31 + RecipientPDS-->>CLI: { uri, cid } 32 + 33 + CLI->>Recipient: Proposed: at://recipient/.../revision/tid 34 + ``` 35 + 36 + The revision record lives on the recipient's PDS. It references the original document via an `origin` AT-URI. The owner's data is untouched. 37 + 38 + ## Propose Revision (Keyring Member) 39 + 40 + A keyring member uploads a revised version, encrypted under the shared group key. 41 + 42 + ```mermaid 43 + sequenceDiagram 44 + participant Member 45 + participant CLI as Member's CLI 46 + participant Crypto 47 + participant MemberPDS as Member's PDS 48 + participant Disk as Local Storage 49 + 50 + Member->>CLI: opake propose at://owner/.../document/tid recipe-v2.pdf --keyring family 51 + 52 + CLI->>Disk: Load group key GK for keyring 53 + Disk-->>CLI: GK 54 + 55 + CLI->>CLI: Read new file, detect MIME type 56 + CLI->>Crypto: generate_content_key() → K' 57 + CLI->>Crypto: encrypt_blob(K', plaintext) 58 + Crypto-->>CLI: { ciphertext, nonce } 59 + 60 + CLI->>MemberPDS: uploadBlob (ciphertext) 61 + MemberPDS-->>CLI: blob ref 62 + 63 + CLI->>Crypto: wrap_content_key_for_keyring(K', GK) 64 + Crypto-->>CLI: AES-KW wrapped content key 65 + 66 + CLI->>MemberPDS: createRecord (revision with keyringEncryption) 67 + Note right of MemberPDS: origin: at://owner/.../document/tid<br/>keyring ref from original document 68 + MemberPDS-->>CLI: { uri, cid } 69 + 70 + CLI->>Member: Proposed: at://member/.../revision/tid 71 + ``` 72 + 73 + Any keyring member can decrypt the revision — they already have GK. 74 + 75 + ## Accept Revision (Owner) 76 + 77 + The document owner reviews a proposed revision and applies it by replacing their blob. 78 + 79 + ```mermaid 80 + sequenceDiagram 81 + participant Owner 82 + participant CLI as Owner's CLI 83 + participant PLC as PLC Directory 84 + participant ProposerPDS as Proposer's PDS 85 + participant Crypto 86 + participant OwnerPDS as Owner's PDS 87 + 88 + Owner->>CLI: opake accept at://proposer/.../revision/tid 89 + 90 + CLI->>CLI: Parse revision URI, extract proposer DID 91 + CLI->>PLC: DID document for proposer 92 + PLC-->>CLI: { pds_url } 93 + 94 + CLI->>ProposerPDS: getRecord (revision) 95 + ProposerPDS-->>CLI: Revision record { origin, blob, encryption } 96 + 97 + CLI->>Crypto: Decrypt revision blob (via grant key or GK) 98 + Crypto-->>CLI: new plaintext 99 + 100 + Note over CLI: Re-encrypt under owner's own keys 101 + CLI->>Crypto: generate_content_key() → K'' 102 + CLI->>Crypto: encrypt_blob(K'', plaintext) 103 + Crypto-->>CLI: { ciphertext, nonce } 104 + 105 + CLI->>OwnerPDS: uploadBlob (ciphertext) 106 + OwnerPDS-->>CLI: new blob ref 107 + 108 + CLI->>Crypto: Wrap K'' (to owner + keyring/grants as before) 109 + Crypto-->>CLI: new wrapped keys 110 + 111 + CLI->>OwnerPDS: putRecord (update document with new blob + keys) 112 + OwnerPDS-->>CLI: 200 OK 113 + 114 + CLI->>Owner: Accepted revision, document updated 115 + ``` 116 + 117 + The owner re-encrypts with a fresh content key rather than reusing the proposer's. This ensures the owner's document record remains self-consistent — all wrapped keys reference the same content key, and the blob is stored on the owner's PDS. 118 + 119 + ## Discovery via AppView 120 + 121 + Without an AppView, revision discovery requires polling known members' PDSes. The AppView automates this by watching firehose events. 122 + 123 + ```mermaid 124 + sequenceDiagram 125 + participant AppView 126 + participant MemberPDS as Member's PDS 127 + participant OwnerPDS as Owner's PDS 128 + 129 + MemberPDS->>AppView: Firehose event: new revision record 130 + AppView->>AppView: Index revision by origin URI 131 + 132 + Note over AppView: Later, owner queries pending revisions 133 + 134 + OwnerPDS->>AppView: GET /revisions?origin=at://owner/.../document/tid 135 + AppView-->>OwnerPDS: [{ revision_uri, proposer, created_at }, ...] 136 + ``` 137 + 138 + Without the AppView, `opake revisions <document>` can fall back to polling each keyring member's PDS for `app.opake.cloud.revision` records whose `origin` matches the document URI. Slow but functional, and zero-trust — no intermediary needed.
+91
docs/flows/sharing.md
··· 1 + # Sharing 2 + 3 + ## Resolve 4 + 5 + Resolves a handle or DID to its PDS and X25519 public key. Used internally by `share`, exposed as a standalone command for inspection. 6 + 7 + ```mermaid 8 + sequenceDiagram 9 + participant User 10 + participant CLI 11 + participant CallerPDS as Caller's PDS 12 + participant PLC as PLC Directory 13 + participant TargetPDS as Target's PDS 14 + 15 + User->>CLI: opake resolve alice.example.com 16 + 17 + alt Input is a handle 18 + CLI->>CallerPDS: com.atproto.identity.resolveHandle 19 + CallerPDS-->>CLI: did:plc:alice 20 + else Input is a DID 21 + CLI->>CLI: Use directly 22 + end 23 + 24 + CLI->>PLC: GET /did:plc:alice (DID document) 25 + PLC-->>CLI: { alsoKnownAs, service: [#atproto_pds → pds-url] } 26 + 27 + CLI->>TargetPDS: com.atproto.repo.getRecord (publicKey/self) 28 + TargetPDS-->>CLI: PublicKeyRecord { publicKey, algo } 29 + 30 + CLI->>User: DID, handle, PDS URL, public key, algorithm 31 + ``` 32 + 33 + ## Share 34 + 35 + Grants another user access to a document by wrapping the content key to their public key. 36 + 37 + ```mermaid 38 + sequenceDiagram 39 + participant User 40 + participant CLI 41 + participant PDS as Own PDS 42 + participant PLC as PLC Directory 43 + participant RecipientPDS as Recipient's PDS 44 + participant Crypto 45 + 46 + User->>CLI: opake share photo.jpg alice.example.com 47 + 48 + CLI->>CLI: Resolve filename → AT-URI 49 + 50 + Note over CLI,RecipientPDS: Resolve recipient identity 51 + CLI->>PLC: DID document for recipient 52 + PLC-->>CLI: { pds_url } 53 + CLI->>RecipientPDS: getRecord (publicKey/self) 54 + RecipientPDS-->>CLI: recipient's X25519 public key 55 + 56 + Note over CLI,PDS: Fetch content key from own document 57 + CLI->>PDS: getRecord (document) 58 + PDS-->>CLI: Document record with owner's wrappedKey 59 + CLI->>Crypto: unwrap_key(owner_wrappedKey, private_key) 60 + Crypto-->>CLI: content key K 61 + 62 + Note over CLI,PDS: Create grant 63 + CLI->>Crypto: wrap_key(K, recipient_pubkey, recipient_did) 64 + Crypto-->>CLI: wrappedKey for recipient 65 + 66 + CLI->>PDS: createRecord (grant) 67 + PDS-->>CLI: { uri, cid } 68 + 69 + CLI->>User: Shared: at://did/.../grant-tid 70 + ``` 71 + 72 + ## Revoke 73 + 74 + Deletes a grant record. The recipient loses network access to the wrapped key. 75 + 76 + ```mermaid 77 + sequenceDiagram 78 + participant User 79 + participant CLI 80 + participant PDS 81 + 82 + User->>CLI: opake revoke at://did/.../grant-tid 83 + 84 + CLI->>CLI: Validate URI is a grant collection 85 + CLI->>PDS: com.atproto.repo.deleteRecord (grant collection, rkey) 86 + PDS-->>CLI: 200 OK 87 + 88 + CLI->>User: Revoked 89 + ``` 90 + 91 + For true forward secrecy, the document should also be re-encrypted with a new content key — the schema supports this but the CLI doesn't automate it yet.
+1 -1
lexicons/README.md
··· 84 84 85 85 Any keyring member unwraps GK with their private key, then uses GK to unwrap each document's content key K. Removing a member archives the old rotation's member entries into `keyHistory`, then rotates GK and re-wraps to the remaining members — per-document content keys and blobs stay untouched. The history lets remaining members decrypt pre-rotation documents even on new devices. 86 86 87 - For detailed sequence diagrams of every CLI operation, see [docs/FLOWS.md](../docs/FLOWS.md). 87 + For detailed sequence diagrams of every CLI operation, see [docs/flows/](../docs/flows/).