···11# Opake — Operation Flows
2233-Sequence diagrams for every CLI operation. All crypto happens client-side — the PDS only stores and serves opaque bytes.
44-55-## Authentication
66-77-### Login
88-99-Authenticates with a PDS, persists session + identity, and publishes the encryption public key as a singleton record.
1010-1111-```mermaid
1212-sequenceDiagram
1313- participant User
1414- participant CLI
1515- participant PDS
1616-1717- User->>CLI: opake login --pds <url> --identifier <handle>
1818- CLI->>User: Password prompt (or OPAKE_PASSWORD env)
1919- User-->>CLI: password
2020-2121- CLI->>PDS: com.atproto.server.createSession
2222- PDS-->>CLI: { did, handle, accessJwt, refreshJwt }
2323-2424- CLI->>CLI: Save account config + session tokens
2525- CLI->>CLI: Load or generate X25519 keypair
2626-2727- CLI->>PDS: com.atproto.repo.putRecord (publicKey/self)
2828- PDS-->>CLI: { uri, cid }
2929-3030- CLI->>User: Logged in as <handle>
3131-```
3232-3333-The `putRecord` call is idempotent — same key, same record. Safe to call on every login.
3434-3535-### Token Refresh
3636-3737-Transparent to the user. The XRPC client detects expired tokens and refreshes automatically.
3838-3939-```mermaid
4040-sequenceDiagram
4141- participant CLI
4242- participant PDS
4343-4444- CLI->>PDS: Any XRPC call (expired accessJwt)
4545- PDS-->>CLI: 400 ExpiredToken
4646-4747- CLI->>PDS: com.atproto.server.refreshSession (refreshJwt)
4848- PDS-->>CLI: { accessJwt, refreshJwt } (new tokens)
4949-5050- CLI->>CLI: Update stored session
5151-5252- CLI->>PDS: Retry original XRPC call (new accessJwt)
5353- PDS-->>CLI: Success
5454-```
5555-5656-## Document Operations
5757-5858-### Upload
5959-6060-Encrypts a file and uploads it as an opaque blob with a metadata record.
6161-6262-```mermaid
6363-sequenceDiagram
6464- participant User
6565- participant CLI
6666- participant Crypto
6767- participant PDS
6868-6969- User->>CLI: opake upload photo.jpg --tags vacation
7070-7171- CLI->>CLI: Read file from disk, detect MIME type
7272- CLI->>Crypto: generate_content_key()
7373- Crypto-->>CLI: random AES-256-GCM key K
7474-7575- CLI->>Crypto: encrypt_blob(K, plaintext)
7676- Crypto-->>CLI: { ciphertext, nonce }
7777-7878- CLI->>PDS: com.atproto.repo.uploadBlob (ciphertext)
7979- PDS-->>CLI: blob ref { $link, size }
8080-8181- CLI->>Crypto: wrap_key(K, owner_pubkey, owner_did)
8282- Crypto-->>CLI: wrappedKey (x25519-hkdf-a256kw)
8383-8484- CLI->>PDS: com.atproto.repo.createRecord (document)
8585- PDS-->>CLI: { uri, cid }
8686-8787- CLI->>User: Uploaded: at://did/app.opake.cloud.document/<tid>
8888-```
8989-9090-### Download (Own Files)
9191-9292-Fetches a document you own, unwraps the content key, and decrypts.
9393-9494-```mermaid
9595-sequenceDiagram
9696- participant User
9797- participant CLI
9898- participant PDS
9999- participant Crypto
100100-101101- User->>CLI: opake download photo.jpg
102102-103103- CLI->>CLI: Resolve filename → AT-URI (via listRecords if needed)
104104-105105- CLI->>PDS: com.atproto.repo.getRecord (document)
106106- PDS-->>CLI: Document record (envelope, blob ref)
107107-108108- CLI->>CLI: Find wrappedKey matching own DID
109109- CLI->>Crypto: unwrap_key(wrappedKey, private_key)
110110- Crypto-->>CLI: content key K
111111-112112- CLI->>PDS: com.atproto.sync.getBlob (did, cid)
113113- PDS-->>CLI: ciphertext bytes
114114-115115- CLI->>Crypto: decrypt_blob(K, nonce, ciphertext)
116116- Crypto-->>CLI: plaintext
117117-118118- CLI->>CLI: Write plaintext to disk
119119- CLI->>User: Saved to ./photo.jpg
120120-```
121121-122122-### Download (Shared Files — Cross-PDS)
123123-124124-Downloads a file shared with you by another user. Requires the grant URI (auto-discovery via `inbox` is not yet implemented).
125125-126126-```mermaid
127127-sequenceDiagram
128128- participant User
129129- participant CLI
130130- participant PLC as PLC Directory
131131- participant OwnerPDS as Owner's PDS
132132- participant Crypto
133133-134134- User->>CLI: opake download --grant at://did:plc:owner/.../grant-tid
135135-136136- CLI->>CLI: Parse grant URI, extract owner DID
137137-138138- CLI->>PLC: GET /did:plc:owner (DID document)
139139- PLC-->>CLI: { service: [{ #atproto_pds: owner-pds-url }] }
140140-141141- CLI->>OwnerPDS: com.atproto.repo.getRecord (grant)
142142- OwnerPDS-->>CLI: Grant record { document, wrappedKey }
143143-144144- CLI->>Crypto: unwrap_key(grant.wrappedKey, private_key)
145145- Crypto-->>CLI: content key K
146146-147147- CLI->>OwnerPDS: com.atproto.repo.getRecord (document)
148148- OwnerPDS-->>CLI: Document record { blob, encryption.nonce }
149149-150150- CLI->>OwnerPDS: com.atproto.sync.getBlob (did, cid)
151151- OwnerPDS-->>CLI: ciphertext bytes
152152-153153- CLI->>Crypto: decrypt_blob(K, nonce, ciphertext)
154154- Crypto-->>CLI: plaintext
155155-156156- CLI->>CLI: Write to disk
157157- CLI->>User: Saved to ./shared-file.txt
158158-```
159159-160160-Data never leaves the owner's PDS. The recipient fetches everything directly from the source.
161161-162162-### List
163163-164164-Lists document records on your PDS with optional tag filtering.
165165-166166-```mermaid
167167-sequenceDiagram
168168- participant User
169169- participant CLI
170170- participant PDS
171171-172172- User->>CLI: opake ls --tag vacation --long
173173-174174- loop Paginate until no cursor
175175- CLI->>PDS: com.atproto.repo.listRecords (collection, cursor)
176176- PDS-->>CLI: { records: [...], cursor? }
177177- end
178178-179179- CLI->>CLI: Parse documents, filter by tag
180180- CLI->>User: Display table (name, size, tags, URI)
181181-```
182182-183183-### Delete
184184-185185-Deletes a document record. The blob becomes orphaned and is eventually garbage-collected by the PDS.
186186-187187-```mermaid
188188-sequenceDiagram
189189- participant User
190190- participant CLI
191191- participant PDS
192192-193193- User->>CLI: opake rm photo.jpg
194194-195195- CLI->>CLI: Resolve filename → AT-URI
196196- CLI->>User: Delete photo.jpg? [y/N]
197197- User-->>CLI: y
198198-199199- CLI->>PDS: com.atproto.repo.deleteRecord (collection, rkey)
200200- PDS-->>CLI: 200 OK
201201-202202- CLI->>User: Deleted
203203-```
204204-205205-## Sharing
206206-207207-### Resolve
208208-209209-Resolves a handle or DID to its PDS and X25519 public key. Used internally by `share`, exposed as a standalone command for inspection.
210210-211211-```mermaid
212212-sequenceDiagram
213213- participant User
214214- participant CLI
215215- participant CallerPDS as Caller's PDS
216216- participant PLC as PLC Directory
217217- participant TargetPDS as Target's PDS
218218-219219- User->>CLI: opake resolve alice.example.com
220220-221221- alt Input is a handle
222222- CLI->>CallerPDS: com.atproto.identity.resolveHandle
223223- CallerPDS-->>CLI: did:plc:alice
224224- else Input is a DID
225225- CLI->>CLI: Use directly
226226- end
227227-228228- CLI->>PLC: GET /did:plc:alice (DID document)
229229- PLC-->>CLI: { alsoKnownAs, service: [#atproto_pds → pds-url] }
230230-231231- CLI->>TargetPDS: com.atproto.repo.getRecord (publicKey/self)
232232- TargetPDS-->>CLI: PublicKeyRecord { publicKey, algo }
233233-234234- CLI->>User: DID, handle, PDS URL, public key, algorithm
235235-```
236236-237237-### Share
238238-239239-Grants another user access to a document by wrapping the content key to their public key.
240240-241241-```mermaid
242242-sequenceDiagram
243243- participant User
244244- participant CLI
245245- participant PDS as Own PDS
246246- participant PLC as PLC Directory
247247- participant RecipientPDS as Recipient's PDS
248248- participant Crypto
249249-250250- User->>CLI: opake share photo.jpg alice.example.com
251251-252252- CLI->>CLI: Resolve filename → AT-URI
253253-254254- Note over CLI,RecipientPDS: Resolve recipient identity
255255- CLI->>PLC: DID document for recipient
256256- PLC-->>CLI: { pds_url }
257257- CLI->>RecipientPDS: getRecord (publicKey/self)
258258- RecipientPDS-->>CLI: recipient's X25519 public key
259259-260260- Note over CLI,PDS: Fetch content key from own document
261261- CLI->>PDS: getRecord (document)
262262- PDS-->>CLI: Document record with owner's wrappedKey
263263- CLI->>Crypto: unwrap_key(owner_wrappedKey, private_key)
264264- Crypto-->>CLI: content key K
265265-266266- Note over CLI,PDS: Create grant
267267- CLI->>Crypto: wrap_key(K, recipient_pubkey, recipient_did)
268268- Crypto-->>CLI: wrappedKey for recipient
269269-270270- CLI->>PDS: createRecord (grant)
271271- PDS-->>CLI: { uri, cid }
272272-273273- CLI->>User: Shared: at://did/.../grant-tid
274274-```
275275-276276-### Revoke
277277-278278-Deletes a grant record. The recipient loses network access to the wrapped key.
279279-280280-```mermaid
281281-sequenceDiagram
282282- participant User
283283- participant CLI
284284- participant PDS
285285-286286- User->>CLI: opake revoke at://did/.../grant-tid
287287-288288- CLI->>CLI: Validate URI is a grant collection
289289- CLI->>PDS: com.atproto.repo.deleteRecord (grant collection, rkey)
290290- PDS-->>CLI: 200 OK
291291-292292- CLI->>User: Revoked
293293-```
294294-295295-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.
296296-297297-## Encryption Primitives
298298-299299-### Key Wrapping (x25519-hkdf-a256kw)
300300-301301-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.
302302-303303-```mermaid
304304-flowchart LR
305305- subgraph Wrap ["wrap_key()"]
306306- direction TB
307307- EphKey["Generate ephemeral<br/>X25519 keypair"] --> ECDH
308308- RecipPub["Recipient's<br/>X25519 public key"] --> ECDH
309309- ECDH["X25519 ECDH<br/>shared secret"] --> HKDF
310310- HKDF["HKDF-SHA256<br/>info = 'opake-v1-x25519-hkdf-a256kw-{did}'"] --> KEK
311311- KEK["256-bit key<br/>encryption key"] --> AESKW
312312- ContentKey["Content key K<br/>(AES-256)"] --> AESKW
313313- AESKW["AES-256-KW"] --> Ciphertext
314314- end
315315-316316- Ciphertext["wrappedKey.ciphertext:<br/>[32B ephemeral pubkey ‖ 40B wrapped key]"]
317317-318318- style Wrap fill:#1a1a2e,color:#eee
319319- style Ciphertext fill:#16213e,color:#eee
320320-```
321321-322322-### Keyring Key Wrapping (AES-256-KW)
323323-324324-How a content key gets wrapped under a keyring's group key. Symmetric wrap — no ECDH, no ephemeral keys.
325325-326326-```mermaid
327327-flowchart LR
328328- subgraph Wrap ["wrap_content_key_for_keyring()"]
329329- direction TB
330330- GK["Group key GK<br/>(AES-256)"] --> KEK["AES-256-KW<br/>(RFC 3394)"]
331331- ContentKey["Content key K<br/>(AES-256)"] --> KEK
332332- end
333333-334334- KEK --> Wrapped["40 bytes<br/>(32B key + 8B integrity)"]
335335-336336- style Wrap fill:#1a1a2e,color:#eee
337337- style Wrapped fill:#16213e,color:#eee
338338-```
339339-340340-The group key itself is wrapped to each member's X25519 public key using the asymmetric wrapping scheme above.
341341-342342-### Content Encryption (AES-256-GCM)
343343-344344-```mermaid
345345-flowchart LR
346346- subgraph Encrypt ["encrypt_blob()"]
347347- direction TB
348348- K["Content key K"] --> GCM
349349- Nonce["Random 12-byte nonce"] --> GCM
350350- Plaintext["File bytes"] --> GCM
351351- GCM["AES-256-GCM"]
352352- end
353353-354354- GCM --> Ciphertext["Ciphertext + auth tag"]
355355- GCM --> StoredNonce["Nonce stored in<br/>document record"]
356356-357357- style Encrypt fill:#1a1a2e,color:#eee
358358-```
359359-360360-## Keyrings
361361-362362-### Keyring Model
363363-364364-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.
365365-366366-```mermaid
367367-flowchart TB
368368- subgraph Keyring ["Keyring Record"]
369369- GK["Group Key GK"]
370370- GK -->|wrapped to| Alice["Alice's pubkey"]
371371- GK -->|wrapped to| Bob["Bob's pubkey"]
372372- GK -->|wrapped to| Carol["Carol's pubkey"]
373373- end
374374-375375- subgraph Doc1 ["Document 1"]
376376- K1["Content Key K₁"] -->|wrapped under GK| GK
377377- end
378378-379379- subgraph Doc2 ["Document 2"]
380380- K2["Content Key K₂"] -->|wrapped under GK| GK
381381- end
382382-383383- Alice -.->|unwrap GK, then K₁| K1
384384- Bob -.->|unwrap GK, then K₂| K2
385385-386386- style Keyring fill:#1a1a2e,color:#eee
387387- style Doc1 fill:#16213e,color:#eee
388388- style Doc2 fill:#16213e,color:#eee
389389-```
390390-391391-### Create Keyring
392392-393393-Generates a group key, wraps it to the owner, creates the keyring record, and stores the group key locally.
394394-395395-```mermaid
396396-sequenceDiagram
397397- participant User
398398- participant CLI
399399- participant Crypto
400400- participant PDS
401401- participant Disk as Local Storage
402402-403403- User->>CLI: opake keyring create family-photos
404404-405405- CLI->>Crypto: create_group_key()
406406- Crypto-->>CLI: group key GK + wrappedKey (GK → owner pubkey)
407407-408408- CLI->>PDS: com.atproto.repo.createRecord (keyring)
409409- PDS-->>CLI: { uri, cid }
410410-411411- CLI->>Disk: Save GK to ~/.config/opake/accounts/<did>/keyrings/<rkey>.json
412412-413413- CLI->>User: family-photos → at://did/.../keyring-tid
414414-```
415415-416416-The group key is never stored in plaintext on the PDS — only the wrapped copies live in the keyring record.
417417-418418-### List Keyrings
419419-420420-```mermaid
421421-sequenceDiagram
422422- participant User
423423- participant CLI
424424- participant PDS
425425-426426- User->>CLI: opake keyring ls --long
427427-428428- loop Paginate until no cursor
429429- CLI->>PDS: com.atproto.repo.listRecords (keyring collection, cursor)
430430- PDS-->>CLI: { records: [...], cursor? }
431431- end
432432-433433- CLI->>User: Display table (name, members, rotation, URI)
434434-```
435435-436436-### Add Member
437437-438438-Resolves the new member's identity, wraps the group key to their public key, and appends them to the keyring record.
439439-440440-```mermaid
441441-sequenceDiagram
442442- participant User
443443- participant CLI
444444- participant PDS as Own PDS
445445- participant PLC as PLC Directory
446446- participant MemberPDS as Member's PDS
447447- participant Crypto
448448- participant Disk as Local Storage
449449-450450- User->>CLI: opake keyring add-member family-photos alice.example.com
451451-452452- CLI->>PDS: listRecords → resolve "family-photos" to keyring URI
453453- PDS-->>CLI: keyring URI + rkey
454454-455455- CLI->>Disk: Load group key GK for this keyring
456456- Disk-->>CLI: GK
457457-458458- Note over CLI,MemberPDS: Resolve new member identity
459459- CLI->>PLC: DID document for alice
460460- PLC-->>CLI: { pds_url }
461461- CLI->>MemberPDS: getRecord (publicKey/self)
462462- MemberPDS-->>CLI: Alice's X25519 public key
463463-464464- CLI->>Crypto: wrap_key(GK, alice_pubkey, alice_did)
465465- Crypto-->>CLI: wrappedKey for Alice
466466-467467- CLI->>PDS: getRecord (keyring) → append Alice → putRecord
468468- PDS-->>CLI: 200 OK
469469-470470- CLI->>User: added alice.example.com to family-photos
471471-```
472472-473473-### Remove Member
474474-475475-Removes the member, generates a new group key, re-wraps to all remaining members, and increments the rotation counter.
476476-477477-```mermaid
478478-sequenceDiagram
479479- participant User
480480- participant CLI
481481- participant PDS as Own PDS
482482- participant PLC as PLC Directory
483483- participant Crypto
484484- participant Disk as Local Storage
485485-486486- User->>CLI: opake keyring remove-member family-photos bob.example.com
487487-488488- CLI->>PDS: Resolve keyring URI + fetch keyring record
489489- PDS-->>CLI: Keyring with members [Alice, Bob, Carol]
490490-491491- CLI->>User: Removing bob will rotate the group key. Continue? [y/N]
492492- User-->>CLI: y
493493-494494- Note over CLI,PLC: Resolve remaining members' public keys
495495- CLI->>PLC: DID documents for Alice, Carol
496496- PLC-->>CLI: PDS URLs
497497- CLI->>PDS: getRecord (publicKey/self) for each
498498- PDS-->>CLI: Public keys for Alice, Carol
499499-500500- CLI->>Crypto: create_group_key() → new GK'
501501- Crypto-->>CLI: GK' + wrappedKeys for [Alice, Carol]
502502-503503- CLI->>PDS: putRecord (keyring: members=[Alice, Carol], rotation++, keyHistory appended)
504504- PDS-->>CLI: 200 OK
505505-506506- CLI->>Disk: Save new GK' alongside old GK (keyed by rotation)
507507-508508- CLI->>User: removed bob from family-photos (key rotated)
509509-```
510510-511511-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.
512512-513513-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.
514514-515515-### Upload with Keyring
516516-517517-Encrypts a file and wraps the content key under the keyring's group key instead of individual public keys.
518518-519519-```mermaid
520520-sequenceDiagram
521521- participant User
522522- participant CLI
523523- participant Crypto
524524- participant PDS
525525- participant Disk as Local Storage
526526-527527- User->>CLI: opake upload photo.jpg --keyring family-photos
528528-529529- CLI->>PDS: listRecords → resolve "family-photos" to keyring URI
530530- PDS-->>CLI: keyring URI + rkey + rotation
531531-532532- CLI->>Disk: Load group key GK
533533- Disk-->>CLI: GK
534534-535535- CLI->>CLI: Read file from disk, detect MIME type
536536- CLI->>Crypto: generate_content_key() → K
537537- CLI->>Crypto: encrypt_blob(K, plaintext)
538538- Crypto-->>CLI: { ciphertext, nonce }
539539-540540- CLI->>PDS: com.atproto.repo.uploadBlob (ciphertext)
541541- PDS-->>CLI: blob ref
542542-543543- CLI->>Crypto: wrap_content_key_for_keyring(K, GK)
544544- Crypto-->>CLI: AES-KW wrapped content key
545545-546546- CLI->>PDS: createRecord (document with keyringEncryption)
547547- PDS-->>CLI: { uri, cid }
548548-549549- CLI->>User: Uploaded: at://did/.../document-tid
550550-```
551551-552552-The document record references the keyring URI and stores `wrappedContentKey` (content key wrapped under GK) instead of per-DID wrapped keys.
553553-554554-### Download Keyring-Encrypted Document
555555-556556-Automatically detected — the CLI peeks at the document's encryption type and loads the group key if needed.
557557-558558-```mermaid
559559-sequenceDiagram
560560- participant User
561561- participant CLI
562562- participant PDS
563563- participant Crypto
564564- participant Disk as Local Storage
33+This document has been split into per-topic files for maintainability. See [flows/README.md](flows/README.md) for the index.
5654566566- User->>CLI: opake download photo.jpg
567567-568568- CLI->>CLI: Resolve filename → AT-URI
569569-570570- CLI->>PDS: com.atproto.repo.getRecord (document)
571571- PDS-->>CLI: Document record (keyringEncryption variant)
572572-573573- CLI->>CLI: Detect keyring encryption, extract keyring rkey + rotation
574574-575575- CLI->>Disk: Load group key GK for this keyring at document's rotation
576576- Disk-->>CLI: GK
577577-578578- CLI->>Crypto: unwrap_content_key_from_keyring(wrappedContentKey, GK)
579579- Crypto-->>CLI: content key K
580580-581581- CLI->>PDS: com.atproto.sync.getBlob (did, cid)
582582- PDS-->>CLI: ciphertext bytes
583583-584584- CLI->>Crypto: decrypt_blob(K, nonce, ciphertext)
585585- Crypto-->>CLI: plaintext
586586-587587- CLI->>CLI: Write plaintext to disk
588588- CLI->>User: Saved to ./photo.jpg
589589-```
590590-591591-## Revisions (Planned)
592592-593593-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.
594594-595595-### Propose Revision (Direct Share)
596596-597597-A grant recipient uploads a revised version of a shared document to their own PDS.
598598-599599-```mermaid
600600-sequenceDiagram
601601- participant Recipient
602602- participant CLI as Recipient's CLI
603603- participant Crypto
604604- participant RecipientPDS as Recipient's PDS
605605-606606- Recipient->>CLI: opake propose at://owner/.../document/tid photo-edited.jpg
607607-608608- CLI->>CLI: Read new file, detect MIME type
609609- CLI->>Crypto: generate_content_key() → K'
610610- CLI->>Crypto: encrypt_blob(K', plaintext)
611611- Crypto-->>CLI: { ciphertext, nonce }
612612-613613- CLI->>RecipientPDS: uploadBlob (ciphertext)
614614- RecipientPDS-->>CLI: blob ref
615615-616616- CLI->>Crypto: wrap_key(K', recipient_own_pubkey)
617617- Crypto-->>CLI: wrappedKey (self-wrap)
618618-619619- CLI->>RecipientPDS: createRecord (revision)
620620- Note right of RecipientPDS: origin: at://owner/.../document/tid<br/>blob, encryption, nonce
621621- RecipientPDS-->>CLI: { uri, cid }
622622-623623- CLI->>Recipient: Proposed: at://recipient/.../revision/tid
624624-```
625625-626626-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.
627627-628628-### Propose Revision (Keyring Member)
629629-630630-A keyring member uploads a revised version, encrypted under the shared group key.
631631-632632-```mermaid
633633-sequenceDiagram
634634- participant Member
635635- participant CLI as Member's CLI
636636- participant Crypto
637637- participant MemberPDS as Member's PDS
638638- participant Disk as Local Storage
639639-640640- Member->>CLI: opake propose at://owner/.../document/tid recipe-v2.pdf --keyring family
641641-642642- CLI->>Disk: Load group key GK for keyring
643643- Disk-->>CLI: GK
644644-645645- CLI->>CLI: Read new file, detect MIME type
646646- CLI->>Crypto: generate_content_key() → K'
647647- CLI->>Crypto: encrypt_blob(K', plaintext)
648648- Crypto-->>CLI: { ciphertext, nonce }
649649-650650- CLI->>MemberPDS: uploadBlob (ciphertext)
651651- MemberPDS-->>CLI: blob ref
652652-653653- CLI->>Crypto: wrap_content_key_for_keyring(K', GK)
654654- Crypto-->>CLI: AES-KW wrapped content key
655655-656656- CLI->>MemberPDS: createRecord (revision with keyringEncryption)
657657- Note right of MemberPDS: origin: at://owner/.../document/tid<br/>keyring ref from original document
658658- MemberPDS-->>CLI: { uri, cid }
659659-660660- CLI->>Member: Proposed: at://member/.../revision/tid
661661-```
662662-663663-Any keyring member can decrypt the revision — they already have GK.
664664-665665-### Accept Revision (Owner)
666666-667667-The document owner reviews a proposed revision and applies it by replacing their blob.
668668-669669-```mermaid
670670-sequenceDiagram
671671- participant Owner
672672- participant CLI as Owner's CLI
673673- participant PLC as PLC Directory
674674- participant ProposerPDS as Proposer's PDS
675675- participant Crypto
676676- participant OwnerPDS as Owner's PDS
677677-678678- Owner->>CLI: opake accept at://proposer/.../revision/tid
679679-680680- CLI->>CLI: Parse revision URI, extract proposer DID
681681- CLI->>PLC: DID document for proposer
682682- PLC-->>CLI: { pds_url }
683683-684684- CLI->>ProposerPDS: getRecord (revision)
685685- ProposerPDS-->>CLI: Revision record { origin, blob, encryption }
686686-687687- CLI->>Crypto: Decrypt revision blob (via grant key or GK)
688688- Crypto-->>CLI: new plaintext
689689-690690- Note over CLI: Re-encrypt under owner's own keys
691691- CLI->>Crypto: generate_content_key() → K''
692692- CLI->>Crypto: encrypt_blob(K'', plaintext)
693693- Crypto-->>CLI: { ciphertext, nonce }
694694-695695- CLI->>OwnerPDS: uploadBlob (ciphertext)
696696- OwnerPDS-->>CLI: new blob ref
697697-698698- CLI->>Crypto: Wrap K'' (to owner + keyring/grants as before)
699699- Crypto-->>CLI: new wrapped keys
700700-701701- CLI->>OwnerPDS: putRecord (update document with new blob + keys)
702702- OwnerPDS-->>CLI: 200 OK
703703-704704- CLI->>Owner: Accepted revision, document updated
705705-```
706706-707707-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.
708708-709709-### Discovery via AppView
710710-711711-Without an AppView, revision discovery requires polling known members' PDSes. The AppView automates this by watching firehose events.
712712-713713-```mermaid
714714-sequenceDiagram
715715- participant AppView
716716- participant MemberPDS as Member's PDS
717717- participant OwnerPDS as Owner's PDS
718718-719719- MemberPDS->>AppView: Firehose event: new revision record
720720- AppView->>AppView: Index revision by origin URI
721721-722722- Note over AppView: Later, owner queries pending revisions
723723-724724- OwnerPDS->>AppView: GET /revisions?origin=at://owner/.../document/tid
725725- AppView-->>OwnerPDS: [{ revision_uri, proposer, created_at }, ...]
726726-```
727727-728728-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.
729729-730730-## Multi-Device Identity (Planned)
731731-732732-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`.
733733-734734-### Keypair Derivation
735735-736736-```mermaid
737737-flowchart TB
738738- subgraph Generate ["First-time setup (opake init or first login)"]
739739- direction TB
740740- Entropy["128 bits of entropy<br/>(from OS CSPRNG)"] --> Mnemonic
741741- Mnemonic["BIP-39 mnemonic<br/>(12 words)"]
742742- end
743743-744744- subgraph Derive ["Keypair derivation (every login)"]
745745- direction TB
746746- Mnemonic --> PBKDF["BIP-39 seed derivation<br/>PBKDF2-HMAC-SHA512<br/>2048 rounds, salt = 'mnemonic'"]
747747- PBKDF --> Seed["512-bit master seed"]
748748- Seed --> HKDF["HKDF-SHA256<br/>info = 'opake-v1-x25519-identity'"]
749749- HKDF --> PrivKey["32-byte X25519<br/>private key"]
750750- PrivKey --> PubKey["X25519 public key<br/>(clamped, published to PDS)"]
751751- end
752752-753753- style Generate fill:#1a1a2e,color:#eee
754754- style Derive fill:#16213e,color:#eee
755755-```
756756-757757-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.
758758-759759-### First Login (New Device)
760760-761761-```mermaid
762762-sequenceDiagram
763763- participant User
764764- participant CLI
765765- participant Crypto
766766- participant PDS
767767-768768- User->>CLI: opake login --pds <url> --identifier <handle>
769769- CLI->>User: No identity found. Enter seed phrase or generate new?
770770- User-->>CLI: "abandon ability able about above absent ..."
771771-772772- CLI->>Crypto: BIP-39 validate (checksum, wordlist)
773773- Crypto-->>CLI: valid
774774-775775- CLI->>Crypto: mnemonic → PBKDF2 → 512-bit seed
776776- CLI->>Crypto: seed → HKDF-SHA256 → X25519 private key
777777- Crypto-->>CLI: keypair (private + public)
778778-779779- CLI->>CLI: Save identity (private key to disk)
780780-781781- CLI->>PDS: com.atproto.server.createSession
782782- PDS-->>CLI: { did, handle, accessJwt, refreshJwt }
783783-784784- CLI->>PDS: putRecord (publicKey/self)
785785- PDS-->>CLI: { uri, cid }
786786-787787- CLI->>User: Logged in as <handle>
788788-```
789789-790790-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.
791791-792792-### Generate New Identity
793793-794794-```mermaid
795795-sequenceDiagram
796796- participant User
797797- participant CLI
798798- participant Crypto
799799-800800- User->>CLI: opake init
801801-802802- CLI->>Crypto: Generate 128 bits entropy (CSPRNG)
803803- Crypto-->>CLI: entropy
804804- CLI->>Crypto: BIP-39 encode (entropy → 12 words)
805805- Crypto-->>CLI: mnemonic
806806-807807- CLI->>User: Your seed phrase (write this down):<br/>"abandon ability able about ..."
808808-809809- CLI->>User: Confirm by entering words 3, 7, 11
810810- User-->>CLI: "able", "absent", "above"
811811- CLI->>CLI: Verify matches
812812-813813- CLI->>Crypto: Derive keypair from mnemonic
814814- Crypto-->>CLI: keypair
815815-816816- CLI->>CLI: Save identity to disk
817817- CLI->>User: Identity created. Run `opake login` to connect to a PDS.
818818-```
819819-820820-The confirmation step guards against clipboard-and-forget. The seed phrase is shown exactly once — the CLI never stores or displays it again.
821821-822822-### Key Mismatch Recovery
823823-824824-```mermaid
825825-sequenceDiagram
826826- participant User
827827- participant CLI
828828- participant PDS
829829-830830- User->>CLI: opake login --pds <url> --identifier <handle>
831831- CLI->>CLI: Derive keypair from seed phrase
832832-833833- CLI->>PDS: getRecord (publicKey/self)
834834- PDS-->>CLI: Published public key ≠ derived public key
835835-836836- 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
837837-838838- CLI->>User: Overwrite published key? [y/N]
839839- User-->>CLI: y
840840-841841- CLI->>PDS: putRecord (publicKey/self) with derived key
842842- PDS-->>CLI: { uri, cid }
843843-844844- CLI->>User: Public key updated. Previous grants may be unreadable.
845845-```
846846-847847-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.
55+| File | Topic |
66+|------|-------|
77+| [flows/authentication.md](flows/authentication.md) | Login, token refresh |
88+| [flows/documents.md](flows/documents.md) | Upload, download, list, delete |
99+| [flows/sharing.md](flows/sharing.md) | Resolve, share, revoke |
1010+| [flows/crypto.md](flows/crypto.md) | Key wrapping, content encryption primitives |
1111+| [flows/keyrings.md](flows/keyrings.md) | Create, list, add/remove member, keyring upload/download |
1212+| [flows/revisions.md](flows/revisions.md) | Collaborative editing via revision records (planned) |
1313+| [flows/multi-device.md](flows/multi-device.md) | Seed phrase identity, BIP-39 derivation (planned) |
···11+# Authentication
22+33+## Login
44+55+Authenticates with a PDS, persists session + identity, and publishes the encryption public key as a singleton record.
66+77+```mermaid
88+sequenceDiagram
99+ participant User
1010+ participant CLI
1111+ participant PDS
1212+1313+ User->>CLI: opake login --pds <url> --identifier <handle>
1414+ CLI->>User: Password prompt (or OPAKE_PASSWORD env)
1515+ User-->>CLI: password
1616+1717+ CLI->>PDS: com.atproto.server.createSession
1818+ PDS-->>CLI: { did, handle, accessJwt, refreshJwt }
1919+2020+ CLI->>CLI: Save account config + session tokens
2121+ CLI->>CLI: Load or generate X25519 keypair
2222+2323+ CLI->>PDS: com.atproto.repo.putRecord (publicKey/self)
2424+ PDS-->>CLI: { uri, cid }
2525+2626+ CLI->>User: Logged in as <handle>
2727+```
2828+2929+The `putRecord` call is idempotent — same key, same record. Safe to call on every login.
3030+3131+## Token Refresh
3232+3333+Transparent to the user. The XRPC client detects expired tokens and refreshes automatically.
3434+3535+```mermaid
3636+sequenceDiagram
3737+ participant CLI
3838+ participant PDS
3939+4040+ CLI->>PDS: Any XRPC call (expired accessJwt)
4141+ PDS-->>CLI: 400 ExpiredToken
4242+4343+ CLI->>PDS: com.atproto.server.refreshSession (refreshJwt)
4444+ PDS-->>CLI: { accessJwt, refreshJwt } (new tokens)
4545+4646+ CLI->>CLI: Update stored session
4747+4848+ CLI->>PDS: Retry original XRPC call (new accessJwt)
4949+ PDS-->>CLI: Success
5050+```
+62
docs/flows/crypto.md
···11+# Encryption Primitives
22+33+## Key Wrapping (x25519-hkdf-a256kw)
44+55+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.
66+77+```mermaid
88+flowchart LR
99+ subgraph Wrap ["wrap_key()"]
1010+ direction TB
1111+ EphKey["Generate ephemeral<br/>X25519 keypair"] --> ECDH
1212+ RecipPub["Recipient's<br/>X25519 public key"] --> ECDH
1313+ ECDH["X25519 ECDH<br/>shared secret"] --> HKDF
1414+ HKDF["HKDF-SHA256<br/>info = 'opake-v1-x25519-hkdf-a256kw-{did}'"] --> KEK
1515+ KEK["256-bit key<br/>encryption key"] --> AESKW
1616+ ContentKey["Content key K<br/>(AES-256)"] --> AESKW
1717+ AESKW["AES-256-KW"] --> Ciphertext
1818+ end
1919+2020+ Ciphertext["wrappedKey.ciphertext:<br/>[32B ephemeral pubkey ‖ 40B wrapped key]"]
2121+2222+ style Wrap fill:#1a1a2e,color:#eee
2323+ style Ciphertext fill:#16213e,color:#eee
2424+```
2525+2626+## Keyring Key Wrapping (AES-256-KW)
2727+2828+How a content key gets wrapped under a keyring's group key. Symmetric wrap — no ECDH, no ephemeral keys.
2929+3030+```mermaid
3131+flowchart LR
3232+ subgraph Wrap ["wrap_content_key_for_keyring()"]
3333+ direction TB
3434+ GK["Group key GK<br/>(AES-256)"] --> KEK["AES-256-KW<br/>(RFC 3394)"]
3535+ ContentKey["Content key K<br/>(AES-256)"] --> KEK
3636+ end
3737+3838+ KEK --> Wrapped["40 bytes<br/>(32B key + 8B integrity)"]
3939+4040+ style Wrap fill:#1a1a2e,color:#eee
4141+ style Wrapped fill:#16213e,color:#eee
4242+```
4343+4444+The group key itself is wrapped to each member's X25519 public key using the asymmetric wrapping scheme above.
4545+4646+## Content Encryption (AES-256-GCM)
4747+4848+```mermaid
4949+flowchart LR
5050+ subgraph Encrypt ["encrypt_blob()"]
5151+ direction TB
5252+ K["Content key K"] --> GCM
5353+ Nonce["Random 12-byte nonce"] --> GCM
5454+ Plaintext["File bytes"] --> GCM
5555+ GCM["AES-256-GCM"]
5656+ end
5757+5858+ GCM --> Ciphertext["Ciphertext + auth tag"]
5959+ GCM --> StoredNonce["Nonce stored in<br/>document record"]
6060+6161+ style Encrypt fill:#1a1a2e,color:#eee
6262+```
+148
docs/flows/documents.md
···11+# Document Operations
22+33+## Upload
44+55+Encrypts a file and uploads it as an opaque blob with a metadata record.
66+77+```mermaid
88+sequenceDiagram
99+ participant User
1010+ participant CLI
1111+ participant Crypto
1212+ participant PDS
1313+1414+ User->>CLI: opake upload photo.jpg --tags vacation
1515+1616+ CLI->>CLI: Read file from disk, detect MIME type
1717+ CLI->>Crypto: generate_content_key()
1818+ Crypto-->>CLI: random AES-256-GCM key K
1919+2020+ CLI->>Crypto: encrypt_blob(K, plaintext)
2121+ Crypto-->>CLI: { ciphertext, nonce }
2222+2323+ CLI->>PDS: com.atproto.repo.uploadBlob (ciphertext)
2424+ PDS-->>CLI: blob ref { $link, size }
2525+2626+ CLI->>Crypto: wrap_key(K, owner_pubkey, owner_did)
2727+ Crypto-->>CLI: wrappedKey (x25519-hkdf-a256kw)
2828+2929+ CLI->>PDS: com.atproto.repo.createRecord (document)
3030+ PDS-->>CLI: { uri, cid }
3131+3232+ CLI->>User: Uploaded: at://did/app.opake.cloud.document/<tid>
3333+```
3434+3535+## Download (Own Files)
3636+3737+Fetches a document you own, unwraps the content key, and decrypts.
3838+3939+```mermaid
4040+sequenceDiagram
4141+ participant User
4242+ participant CLI
4343+ participant PDS
4444+ participant Crypto
4545+4646+ User->>CLI: opake download photo.jpg
4747+4848+ CLI->>CLI: Resolve filename → AT-URI (via listRecords if needed)
4949+5050+ CLI->>PDS: com.atproto.repo.getRecord (document)
5151+ PDS-->>CLI: Document record (envelope, blob ref)
5252+5353+ CLI->>CLI: Find wrappedKey matching own DID
5454+ CLI->>Crypto: unwrap_key(wrappedKey, private_key)
5555+ Crypto-->>CLI: content key K
5656+5757+ CLI->>PDS: com.atproto.sync.getBlob (did, cid)
5858+ PDS-->>CLI: ciphertext bytes
5959+6060+ CLI->>Crypto: decrypt_blob(K, nonce, ciphertext)
6161+ Crypto-->>CLI: plaintext
6262+6363+ CLI->>CLI: Write plaintext to disk
6464+ CLI->>User: Saved to ./photo.jpg
6565+```
6666+6767+## Download (Shared Files — Cross-PDS)
6868+6969+Downloads a file shared with you by another user. Requires the grant URI (auto-discovery via `inbox` is not yet implemented).
7070+7171+```mermaid
7272+sequenceDiagram
7373+ participant User
7474+ participant CLI
7575+ participant PLC as PLC Directory
7676+ participant OwnerPDS as Owner's PDS
7777+ participant Crypto
7878+7979+ User->>CLI: opake download --grant at://did:plc:owner/.../grant-tid
8080+8181+ CLI->>CLI: Parse grant URI, extract owner DID
8282+8383+ CLI->>PLC: GET /did:plc:owner (DID document)
8484+ PLC-->>CLI: { service: [{ #atproto_pds: owner-pds-url }] }
8585+8686+ CLI->>OwnerPDS: com.atproto.repo.getRecord (grant)
8787+ OwnerPDS-->>CLI: Grant record { document, wrappedKey }
8888+8989+ CLI->>Crypto: unwrap_key(grant.wrappedKey, private_key)
9090+ Crypto-->>CLI: content key K
9191+9292+ CLI->>OwnerPDS: com.atproto.repo.getRecord (document)
9393+ OwnerPDS-->>CLI: Document record { blob, encryption.nonce }
9494+9595+ CLI->>OwnerPDS: com.atproto.sync.getBlob (did, cid)
9696+ OwnerPDS-->>CLI: ciphertext bytes
9797+9898+ CLI->>Crypto: decrypt_blob(K, nonce, ciphertext)
9999+ Crypto-->>CLI: plaintext
100100+101101+ CLI->>CLI: Write to disk
102102+ CLI->>User: Saved to ./shared-file.txt
103103+```
104104+105105+Data never leaves the owner's PDS. The recipient fetches everything directly from the source.
106106+107107+## List
108108+109109+Lists document records on your PDS with optional tag filtering.
110110+111111+```mermaid
112112+sequenceDiagram
113113+ participant User
114114+ participant CLI
115115+ participant PDS
116116+117117+ User->>CLI: opake ls --tag vacation --long
118118+119119+ loop Paginate until no cursor
120120+ CLI->>PDS: com.atproto.repo.listRecords (collection, cursor)
121121+ PDS-->>CLI: { records: [...], cursor? }
122122+ end
123123+124124+ CLI->>CLI: Parse documents, filter by tag
125125+ CLI->>User: Display table (name, size, tags, URI)
126126+```
127127+128128+## Delete
129129+130130+Deletes a document record. The blob becomes orphaned and is eventually garbage-collected by the PDS.
131131+132132+```mermaid
133133+sequenceDiagram
134134+ participant User
135135+ participant CLI
136136+ participant PDS
137137+138138+ User->>CLI: opake rm photo.jpg
139139+140140+ CLI->>CLI: Resolve filename → AT-URI
141141+ CLI->>User: Delete photo.jpg? [y/N]
142142+ User-->>CLI: y
143143+144144+ CLI->>PDS: com.atproto.repo.deleteRecord (collection, rkey)
145145+ PDS-->>CLI: 200 OK
146146+147147+ CLI->>User: Deleted
148148+```
+230
docs/flows/keyrings.md
···11+# Keyrings
22+33+## Keyring Model
44+55+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.
66+77+```mermaid
88+flowchart TB
99+ subgraph Keyring ["Keyring Record"]
1010+ GK["Group Key GK"]
1111+ GK -->|wrapped to| Alice["Alice's pubkey"]
1212+ GK -->|wrapped to| Bob["Bob's pubkey"]
1313+ GK -->|wrapped to| Carol["Carol's pubkey"]
1414+ end
1515+1616+ subgraph Doc1 ["Document 1"]
1717+ K1["Content Key K₁"] -->|wrapped under GK| GK
1818+ end
1919+2020+ subgraph Doc2 ["Document 2"]
2121+ K2["Content Key K₂"] -->|wrapped under GK| GK
2222+ end
2323+2424+ Alice -.->|unwrap GK, then K₁| K1
2525+ Bob -.->|unwrap GK, then K₂| K2
2626+2727+ style Keyring fill:#1a1a2e,color:#eee
2828+ style Doc1 fill:#16213e,color:#eee
2929+ style Doc2 fill:#16213e,color:#eee
3030+```
3131+3232+## Create Keyring
3333+3434+Generates a group key, wraps it to the owner, creates the keyring record, and stores the group key locally.
3535+3636+```mermaid
3737+sequenceDiagram
3838+ participant User
3939+ participant CLI
4040+ participant Crypto
4141+ participant PDS
4242+ participant Disk as Local Storage
4343+4444+ User->>CLI: opake keyring create family-photos
4545+4646+ CLI->>Crypto: create_group_key()
4747+ Crypto-->>CLI: group key GK + wrappedKey (GK → owner pubkey)
4848+4949+ CLI->>PDS: com.atproto.repo.createRecord (keyring)
5050+ PDS-->>CLI: { uri, cid }
5151+5252+ CLI->>Disk: Save GK to ~/.config/opake/accounts/<did>/keyrings/<rkey>.json
5353+5454+ CLI->>User: family-photos → at://did/.../keyring-tid
5555+```
5656+5757+The group key is never stored in plaintext on the PDS — only the wrapped copies live in the keyring record.
5858+5959+## List Keyrings
6060+6161+```mermaid
6262+sequenceDiagram
6363+ participant User
6464+ participant CLI
6565+ participant PDS
6666+6767+ User->>CLI: opake keyring ls --long
6868+6969+ loop Paginate until no cursor
7070+ CLI->>PDS: com.atproto.repo.listRecords (keyring collection, cursor)
7171+ PDS-->>CLI: { records: [...], cursor? }
7272+ end
7373+7474+ CLI->>User: Display table (name, members, rotation, URI)
7575+```
7676+7777+## Add Member
7878+7979+Resolves the new member's identity, wraps the group key to their public key, and appends them to the keyring record.
8080+8181+```mermaid
8282+sequenceDiagram
8383+ participant User
8484+ participant CLI
8585+ participant PDS as Own PDS
8686+ participant PLC as PLC Directory
8787+ participant MemberPDS as Member's PDS
8888+ participant Crypto
8989+ participant Disk as Local Storage
9090+9191+ User->>CLI: opake keyring add-member family-photos alice.example.com
9292+9393+ CLI->>PDS: listRecords → resolve "family-photos" to keyring URI
9494+ PDS-->>CLI: keyring URI + rkey
9595+9696+ CLI->>Disk: Load group key GK for this keyring
9797+ Disk-->>CLI: GK
9898+9999+ Note over CLI,MemberPDS: Resolve new member identity
100100+ CLI->>PLC: DID document for alice
101101+ PLC-->>CLI: { pds_url }
102102+ CLI->>MemberPDS: getRecord (publicKey/self)
103103+ MemberPDS-->>CLI: Alice's X25519 public key
104104+105105+ CLI->>Crypto: wrap_key(GK, alice_pubkey, alice_did)
106106+ Crypto-->>CLI: wrappedKey for Alice
107107+108108+ CLI->>PDS: getRecord (keyring) → append Alice → putRecord
109109+ PDS-->>CLI: 200 OK
110110+111111+ CLI->>User: added alice.example.com to family-photos
112112+```
113113+114114+## Remove Member
115115+116116+Removes the member, generates a new group key, re-wraps to all remaining members, and increments the rotation counter.
117117+118118+```mermaid
119119+sequenceDiagram
120120+ participant User
121121+ participant CLI
122122+ participant PDS as Own PDS
123123+ participant PLC as PLC Directory
124124+ participant Crypto
125125+ participant Disk as Local Storage
126126+127127+ User->>CLI: opake keyring remove-member family-photos bob.example.com
128128+129129+ CLI->>PDS: Resolve keyring URI + fetch keyring record
130130+ PDS-->>CLI: Keyring with members [Alice, Bob, Carol]
131131+132132+ CLI->>User: Removing bob will rotate the group key. Continue? [y/N]
133133+ User-->>CLI: y
134134+135135+ Note over CLI,PLC: Resolve remaining members' public keys
136136+ CLI->>PLC: DID documents for Alice, Carol
137137+ PLC-->>CLI: PDS URLs
138138+ CLI->>PDS: getRecord (publicKey/self) for each
139139+ PDS-->>CLI: Public keys for Alice, Carol
140140+141141+ CLI->>Crypto: create_group_key() → new GK'
142142+ Crypto-->>CLI: GK' + wrappedKeys for [Alice, Carol]
143143+144144+ CLI->>PDS: putRecord (keyring: members=[Alice, Carol], rotation++, keyHistory appended)
145145+ PDS-->>CLI: 200 OK
146146+147147+ CLI->>Disk: Save new GK' alongside old GK (keyed by rotation)
148148+149149+ CLI->>User: removed bob from family-photos (key rotated)
150150+```
151151+152152+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.
153153+154154+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.
155155+156156+## Upload with Keyring
157157+158158+Encrypts a file and wraps the content key under the keyring's group key instead of individual public keys.
159159+160160+```mermaid
161161+sequenceDiagram
162162+ participant User
163163+ participant CLI
164164+ participant Crypto
165165+ participant PDS
166166+ participant Disk as Local Storage
167167+168168+ User->>CLI: opake upload photo.jpg --keyring family-photos
169169+170170+ CLI->>PDS: listRecords → resolve "family-photos" to keyring URI
171171+ PDS-->>CLI: keyring URI + rkey + rotation
172172+173173+ CLI->>Disk: Load group key GK
174174+ Disk-->>CLI: GK
175175+176176+ CLI->>CLI: Read file from disk, detect MIME type
177177+ CLI->>Crypto: generate_content_key() → K
178178+ CLI->>Crypto: encrypt_blob(K, plaintext)
179179+ Crypto-->>CLI: { ciphertext, nonce }
180180+181181+ CLI->>PDS: com.atproto.repo.uploadBlob (ciphertext)
182182+ PDS-->>CLI: blob ref
183183+184184+ CLI->>Crypto: wrap_content_key_for_keyring(K, GK)
185185+ Crypto-->>CLI: AES-KW wrapped content key
186186+187187+ CLI->>PDS: createRecord (document with keyringEncryption)
188188+ PDS-->>CLI: { uri, cid }
189189+190190+ CLI->>User: Uploaded: at://did/.../document-tid
191191+```
192192+193193+The document record references the keyring URI and stores `wrappedContentKey` (content key wrapped under GK) instead of per-DID wrapped keys.
194194+195195+## Download Keyring-Encrypted Document
196196+197197+Automatically detected — the CLI peeks at the document's encryption type and loads the group key if needed.
198198+199199+```mermaid
200200+sequenceDiagram
201201+ participant User
202202+ participant CLI
203203+ participant PDS
204204+ participant Crypto
205205+ participant Disk as Local Storage
206206+207207+ User->>CLI: opake download photo.jpg
208208+209209+ CLI->>CLI: Resolve filename → AT-URI
210210+211211+ CLI->>PDS: com.atproto.repo.getRecord (document)
212212+ PDS-->>CLI: Document record (keyringEncryption variant)
213213+214214+ CLI->>CLI: Detect keyring encryption, extract keyring rkey + rotation
215215+216216+ CLI->>Disk: Load group key GK for this keyring at document's rotation
217217+ Disk-->>CLI: GK
218218+219219+ CLI->>Crypto: unwrap_content_key_from_keyring(wrappedContentKey, GK)
220220+ Crypto-->>CLI: content key K
221221+222222+ CLI->>PDS: com.atproto.sync.getBlob (did, cid)
223223+ PDS-->>CLI: ciphertext bytes
224224+225225+ CLI->>Crypto: decrypt_blob(K, nonce, ciphertext)
226226+ Crypto-->>CLI: plaintext
227227+228228+ CLI->>CLI: Write plaintext to disk
229229+ CLI->>User: Saved to ./photo.jpg
230230+```
+118
docs/flows/multi-device.md
···11+# Multi-Device Identity (Planned)
22+33+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`.
44+55+## Keypair Derivation
66+77+```mermaid
88+flowchart TB
99+ subgraph Generate ["First-time setup (opake init or first login)"]
1010+ direction TB
1111+ Entropy["128 bits of entropy<br/>(from OS CSPRNG)"] --> Mnemonic
1212+ Mnemonic["BIP-39 mnemonic<br/>(12 words)"]
1313+ end
1414+1515+ subgraph Derive ["Keypair derivation (every login)"]
1616+ direction TB
1717+ Mnemonic --> PBKDF["BIP-39 seed derivation<br/>PBKDF2-HMAC-SHA512<br/>2048 rounds, salt = 'mnemonic'"]
1818+ PBKDF --> Seed["512-bit master seed"]
1919+ Seed --> HKDF["HKDF-SHA256<br/>info = 'opake-v1-x25519-identity'"]
2020+ HKDF --> PrivKey["32-byte X25519<br/>private key"]
2121+ PrivKey --> PubKey["X25519 public key<br/>(clamped, published to PDS)"]
2222+ end
2323+2424+ style Generate fill:#1a1a2e,color:#eee
2525+ style Derive fill:#16213e,color:#eee
2626+```
2727+2828+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.
2929+3030+## First Login (New Device)
3131+3232+```mermaid
3333+sequenceDiagram
3434+ participant User
3535+ participant CLI
3636+ participant Crypto
3737+ participant PDS
3838+3939+ User->>CLI: opake login --pds <url> --identifier <handle>
4040+ CLI->>User: No identity found. Enter seed phrase or generate new?
4141+ User-->>CLI: "abandon ability able about above absent ..."
4242+4343+ CLI->>Crypto: BIP-39 validate (checksum, wordlist)
4444+ Crypto-->>CLI: valid
4545+4646+ CLI->>Crypto: mnemonic → PBKDF2 → 512-bit seed
4747+ CLI->>Crypto: seed → HKDF-SHA256 → X25519 private key
4848+ Crypto-->>CLI: keypair (private + public)
4949+5050+ CLI->>CLI: Save identity (private key to disk)
5151+5252+ CLI->>PDS: com.atproto.server.createSession
5353+ PDS-->>CLI: { did, handle, accessJwt, refreshJwt }
5454+5555+ CLI->>PDS: putRecord (publicKey/self)
5656+ PDS-->>CLI: { uri, cid }
5757+5858+ CLI->>User: Logged in as <handle>
5959+```
6060+6161+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.
6262+6363+## Generate New Identity
6464+6565+```mermaid
6666+sequenceDiagram
6767+ participant User
6868+ participant CLI
6969+ participant Crypto
7070+7171+ User->>CLI: opake init
7272+7373+ CLI->>Crypto: Generate 128 bits entropy (CSPRNG)
7474+ Crypto-->>CLI: entropy
7575+ CLI->>Crypto: BIP-39 encode (entropy → 12 words)
7676+ Crypto-->>CLI: mnemonic
7777+7878+ CLI->>User: Your seed phrase (write this down):<br/>"abandon ability able about ..."
7979+8080+ CLI->>User: Confirm by entering words 3, 7, 11
8181+ User-->>CLI: "able", "absent", "above"
8282+ CLI->>CLI: Verify matches
8383+8484+ CLI->>Crypto: Derive keypair from mnemonic
8585+ Crypto-->>CLI: keypair
8686+8787+ CLI->>CLI: Save identity to disk
8888+ CLI->>User: Identity created. Run `opake login` to connect to a PDS.
8989+```
9090+9191+The confirmation step guards against clipboard-and-forget. The seed phrase is shown exactly once — the CLI never stores or displays it again.
9292+9393+## Key Mismatch Recovery
9494+9595+```mermaid
9696+sequenceDiagram
9797+ participant User
9898+ participant CLI
9999+ participant PDS
100100+101101+ User->>CLI: opake login --pds <url> --identifier <handle>
102102+ CLI->>CLI: Derive keypair from seed phrase
103103+104104+ CLI->>PDS: getRecord (publicKey/self)
105105+ PDS-->>CLI: Published public key ≠ derived public key
106106+107107+ 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
108108+109109+ CLI->>User: Overwrite published key? [y/N]
110110+ User-->>CLI: y
111111+112112+ CLI->>PDS: putRecord (publicKey/self) with derived key
113113+ PDS-->>CLI: { uri, cid }
114114+115115+ CLI->>User: Public key updated. Previous grants may be unreadable.
116116+```
117117+118118+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
···11+# Revisions (Planned)
22+33+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.
44+55+## Propose Revision (Direct Share)
66+77+A grant recipient uploads a revised version of a shared document to their own PDS.
88+99+```mermaid
1010+sequenceDiagram
1111+ participant Recipient
1212+ participant CLI as Recipient's CLI
1313+ participant Crypto
1414+ participant RecipientPDS as Recipient's PDS
1515+1616+ Recipient->>CLI: opake propose at://owner/.../document/tid photo-edited.jpg
1717+1818+ CLI->>CLI: Read new file, detect MIME type
1919+ CLI->>Crypto: generate_content_key() → K'
2020+ CLI->>Crypto: encrypt_blob(K', plaintext)
2121+ Crypto-->>CLI: { ciphertext, nonce }
2222+2323+ CLI->>RecipientPDS: uploadBlob (ciphertext)
2424+ RecipientPDS-->>CLI: blob ref
2525+2626+ CLI->>Crypto: wrap_key(K', recipient_own_pubkey)
2727+ Crypto-->>CLI: wrappedKey (self-wrap)
2828+2929+ CLI->>RecipientPDS: createRecord (revision)
3030+ Note right of RecipientPDS: origin: at://owner/.../document/tid<br/>blob, encryption, nonce
3131+ RecipientPDS-->>CLI: { uri, cid }
3232+3333+ CLI->>Recipient: Proposed: at://recipient/.../revision/tid
3434+```
3535+3636+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.
3737+3838+## Propose Revision (Keyring Member)
3939+4040+A keyring member uploads a revised version, encrypted under the shared group key.
4141+4242+```mermaid
4343+sequenceDiagram
4444+ participant Member
4545+ participant CLI as Member's CLI
4646+ participant Crypto
4747+ participant MemberPDS as Member's PDS
4848+ participant Disk as Local Storage
4949+5050+ Member->>CLI: opake propose at://owner/.../document/tid recipe-v2.pdf --keyring family
5151+5252+ CLI->>Disk: Load group key GK for keyring
5353+ Disk-->>CLI: GK
5454+5555+ CLI->>CLI: Read new file, detect MIME type
5656+ CLI->>Crypto: generate_content_key() → K'
5757+ CLI->>Crypto: encrypt_blob(K', plaintext)
5858+ Crypto-->>CLI: { ciphertext, nonce }
5959+6060+ CLI->>MemberPDS: uploadBlob (ciphertext)
6161+ MemberPDS-->>CLI: blob ref
6262+6363+ CLI->>Crypto: wrap_content_key_for_keyring(K', GK)
6464+ Crypto-->>CLI: AES-KW wrapped content key
6565+6666+ CLI->>MemberPDS: createRecord (revision with keyringEncryption)
6767+ Note right of MemberPDS: origin: at://owner/.../document/tid<br/>keyring ref from original document
6868+ MemberPDS-->>CLI: { uri, cid }
6969+7070+ CLI->>Member: Proposed: at://member/.../revision/tid
7171+```
7272+7373+Any keyring member can decrypt the revision — they already have GK.
7474+7575+## Accept Revision (Owner)
7676+7777+The document owner reviews a proposed revision and applies it by replacing their blob.
7878+7979+```mermaid
8080+sequenceDiagram
8181+ participant Owner
8282+ participant CLI as Owner's CLI
8383+ participant PLC as PLC Directory
8484+ participant ProposerPDS as Proposer's PDS
8585+ participant Crypto
8686+ participant OwnerPDS as Owner's PDS
8787+8888+ Owner->>CLI: opake accept at://proposer/.../revision/tid
8989+9090+ CLI->>CLI: Parse revision URI, extract proposer DID
9191+ CLI->>PLC: DID document for proposer
9292+ PLC-->>CLI: { pds_url }
9393+9494+ CLI->>ProposerPDS: getRecord (revision)
9595+ ProposerPDS-->>CLI: Revision record { origin, blob, encryption }
9696+9797+ CLI->>Crypto: Decrypt revision blob (via grant key or GK)
9898+ Crypto-->>CLI: new plaintext
9999+100100+ Note over CLI: Re-encrypt under owner's own keys
101101+ CLI->>Crypto: generate_content_key() → K''
102102+ CLI->>Crypto: encrypt_blob(K'', plaintext)
103103+ Crypto-->>CLI: { ciphertext, nonce }
104104+105105+ CLI->>OwnerPDS: uploadBlob (ciphertext)
106106+ OwnerPDS-->>CLI: new blob ref
107107+108108+ CLI->>Crypto: Wrap K'' (to owner + keyring/grants as before)
109109+ Crypto-->>CLI: new wrapped keys
110110+111111+ CLI->>OwnerPDS: putRecord (update document with new blob + keys)
112112+ OwnerPDS-->>CLI: 200 OK
113113+114114+ CLI->>Owner: Accepted revision, document updated
115115+```
116116+117117+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.
118118+119119+## Discovery via AppView
120120+121121+Without an AppView, revision discovery requires polling known members' PDSes. The AppView automates this by watching firehose events.
122122+123123+```mermaid
124124+sequenceDiagram
125125+ participant AppView
126126+ participant MemberPDS as Member's PDS
127127+ participant OwnerPDS as Owner's PDS
128128+129129+ MemberPDS->>AppView: Firehose event: new revision record
130130+ AppView->>AppView: Index revision by origin URI
131131+132132+ Note over AppView: Later, owner queries pending revisions
133133+134134+ OwnerPDS->>AppView: GET /revisions?origin=at://owner/.../document/tid
135135+ AppView-->>OwnerPDS: [{ revision_uri, proposer, created_at }, ...]
136136+```
137137+138138+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
···11+# Sharing
22+33+## Resolve
44+55+Resolves a handle or DID to its PDS and X25519 public key. Used internally by `share`, exposed as a standalone command for inspection.
66+77+```mermaid
88+sequenceDiagram
99+ participant User
1010+ participant CLI
1111+ participant CallerPDS as Caller's PDS
1212+ participant PLC as PLC Directory
1313+ participant TargetPDS as Target's PDS
1414+1515+ User->>CLI: opake resolve alice.example.com
1616+1717+ alt Input is a handle
1818+ CLI->>CallerPDS: com.atproto.identity.resolveHandle
1919+ CallerPDS-->>CLI: did:plc:alice
2020+ else Input is a DID
2121+ CLI->>CLI: Use directly
2222+ end
2323+2424+ CLI->>PLC: GET /did:plc:alice (DID document)
2525+ PLC-->>CLI: { alsoKnownAs, service: [#atproto_pds → pds-url] }
2626+2727+ CLI->>TargetPDS: com.atproto.repo.getRecord (publicKey/self)
2828+ TargetPDS-->>CLI: PublicKeyRecord { publicKey, algo }
2929+3030+ CLI->>User: DID, handle, PDS URL, public key, algorithm
3131+```
3232+3333+## Share
3434+3535+Grants another user access to a document by wrapping the content key to their public key.
3636+3737+```mermaid
3838+sequenceDiagram
3939+ participant User
4040+ participant CLI
4141+ participant PDS as Own PDS
4242+ participant PLC as PLC Directory
4343+ participant RecipientPDS as Recipient's PDS
4444+ participant Crypto
4545+4646+ User->>CLI: opake share photo.jpg alice.example.com
4747+4848+ CLI->>CLI: Resolve filename → AT-URI
4949+5050+ Note over CLI,RecipientPDS: Resolve recipient identity
5151+ CLI->>PLC: DID document for recipient
5252+ PLC-->>CLI: { pds_url }
5353+ CLI->>RecipientPDS: getRecord (publicKey/self)
5454+ RecipientPDS-->>CLI: recipient's X25519 public key
5555+5656+ Note over CLI,PDS: Fetch content key from own document
5757+ CLI->>PDS: getRecord (document)
5858+ PDS-->>CLI: Document record with owner's wrappedKey
5959+ CLI->>Crypto: unwrap_key(owner_wrappedKey, private_key)
6060+ Crypto-->>CLI: content key K
6161+6262+ Note over CLI,PDS: Create grant
6363+ CLI->>Crypto: wrap_key(K, recipient_pubkey, recipient_did)
6464+ Crypto-->>CLI: wrappedKey for recipient
6565+6666+ CLI->>PDS: createRecord (grant)
6767+ PDS-->>CLI: { uri, cid }
6868+6969+ CLI->>User: Shared: at://did/.../grant-tid
7070+```
7171+7272+## Revoke
7373+7474+Deletes a grant record. The recipient loses network access to the wrapped key.
7575+7676+```mermaid
7777+sequenceDiagram
7878+ participant User
7979+ participant CLI
8080+ participant PDS
8181+8282+ User->>CLI: opake revoke at://did/.../grant-tid
8383+8484+ CLI->>CLI: Validate URI is a grant collection
8585+ CLI->>PDS: com.atproto.repo.deleteRecord (grant collection, rkey)
8686+ PDS-->>CLI: 200 OK
8787+8888+ CLI->>User: Revoked
8989+```
9090+9191+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
···84848585Any 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.
86868787-For detailed sequence diagrams of every CLI operation, see [docs/FLOWS.md](../docs/FLOWS.md).
8787+For detailed sequence diagrams of every CLI operation, see [docs/flows/](../docs/flows/).