···11+# CLAUDE.md — Opake
22+33+**opake.app** — An encrypted personal cloud built on the AT Protocol.
44+55+## Project Overview
66+77+Opake uses a self-hosted PDS as the storage and identity layer, with custom lexicons for file management, encryption, and sharing. The encryption model follows the same hybrid pattern as git-crypt: content is encrypted with per-document symmetric keys (AES-256-GCM), and those keys are wrapped (asymmetrically encrypted) to authorized DIDs' public keys.
88+99+The PDS doesn't need modification — it stores encrypted blobs as opaque bytes and encryption metadata as standard atproto records. All crypto happens client-side. The name comes from the Dutch-flavored spelling of "opaque" — because that's exactly what your data is to everyone without the key.
1010+1111+## Core Concepts
1212+1313+### Why atproto?
1414+1515+A PDS is essentially cloud storage with an API. It stores signed, schema-validated records in a Merkle Search Tree, plus binary blobs. It doesn't understand or inspect the data — it just manages it. We define custom lexicons under `app.opake.cloud.*` to give structure to our files, encryption metadata, and sharing grants. The PDS handles identity (DID-based), authentication, blob storage (up to 50MB default), federation, and sync — all for free.
1616+1717+### Encryption Model
1818+1919+Every file is encrypted before upload. The PDS and the network only ever see ciphertext.
2020+2121+```
2222+Plaintext file
2323+ → encrypt with random AES-256-GCM key K → ciphertext blob (uploaded to PDS)
2424+ → wrap K with owner's DID public key → stored in document record
2525+ → to share: wrap K with recipient's DID public key → stored in grant record
2626+```
2727+2828+There are two sharing modes:
2929+3030+**Direct encryption** — the content key is wrapped individually to each authorized DID. Good for ad-hoc sharing of individual files.
3131+3232+**Keyring encryption** — a named group has a shared group key (GK), wrapped to each member's DID. Individual documents have their content key wrapped under GK. Adding a member to the keyring gives them access to all documents under it without per-document changes. This is the git-crypt named-key equivalent.
3333+3434+### Data Stays Put
3535+3636+When sharing a file with a user on another PDS, no data is copied. The recipient's client fetches the document record and blob directly from the owner's PDS via standard atproto APIs (`com.atproto.repo.getRecord`, `com.atproto.sync.getBlob`). The owner remains the single source of truth. Revocation means deleting the grant record (and optionally re-encrypting with a new key).
3737+3838+### Plaintext Metadata Tradeoff
3939+4040+File names, tags, MIME types, and descriptions are intentionally stored unencrypted in the document record. This allows a personal AppView to index and search files server-side without access to encryption keys. If full opacity is needed, these fields can be set to dummy values with real metadata stored inside the encrypted blob — the schema supports both approaches.
4141+4242+## Lexicon Schema
4343+4444+All lexicons live under the `app.opake.cloud.*` namespace (owner controls the `opake.app` domain for NSID authority).
4545+4646+### `app.opake.cloud.defs`
4747+Shared type definitions:
4848+- **wrappedKey** — a symmetric key encrypted to a specific DID's public key. Fields: `did`, `ciphertext` (bytes), `algo` (e.g. `ECDH-ES+A256KW`).
4949+- **encryptionEnvelope** — describes content encryption: `algo` (e.g. `aes-256-gcm`), `nonce` (bytes), and `keys` (array of wrappedKey).
5050+- **keyringRef** — reference to a keyring record plus the content key wrapped under the group key.
5151+- **visibility** — hint string: `private`, `shared`, or `public`.
5252+5353+### `app.opake.cloud.document`
5454+The core file record. Key type: `tid`.
5555+- `name` (string) — plaintext filename
5656+- `mimeType` (string) — original MIME type of unencrypted content
5757+- `size` (integer) — original unencrypted size in bytes
5858+- `blob` (blob) — the encrypted file content, uploaded as `application/octet-stream`
5959+- `encryption` (union) — either `directEncryption` (inline envelope with wrapped keys) or `keyringEncryption` (reference to a keyring + wrapped content key)
6060+- `tags` (array of strings) — plaintext tags for search/categorization
6161+- `parent` (at-uri, optional) — reference to parent document for folder hierarchy
6262+- `visibility`, `description`, `createdAt`, `modifiedAt`
6363+6464+### `app.opake.cloud.keyring`
6565+A named group for shared access. Key type: `tid`.
6666+- `name` (string) — human-readable group name (e.g. "family-photos")
6767+- `algo` (string) — symmetric algorithm the group key targets
6868+- `members` (array of wrappedKey) — the group key wrapped to each member's DID
6969+- `rotation` (integer) — incremented on key rotation after member removal
7070+- `createdAt`, `modifiedAt`
7171+7272+### `app.opake.cloud.grant`
7373+An ad-hoc share grant. Key type: `tid`.
7474+- `document` (at-uri) — the document being shared
7575+- `recipient` (did) — who gets access
7676+- `wrappedKey` (wrappedKey) — the document's content key wrapped to the recipient
7777+- `permissions` (string) — advisory: `read` or `read-write`
7878+- `expiresAt` (datetime, optional) — advisory expiration
7979+- `note` (string, optional) — message to recipient
8080+- `createdAt`
8181+8282+## Architecture
8383+8484+```
8585+┌──────────────────────────┐
8686+│ opake CLI (Rust) │ ← this is the project
8787+│ - encrypt/decrypt files │
8888+│ - key management │
8989+│ - DID key resolution │
9090+│ - keyring/grant CRUD │
9191+│ - upload/download blobs │
9292+└──────────┬───────────────┘
9393+ │ XRPC (HTTPS)
9494+ ▼
9595+ Your existing PDS ← external, already running
9696+ (any implementation)
9797+9898+┌──────────────────────────┐
9999+│ AppView + SPA (later) │ ← future phase
100100+│ - Rust/Axum JSON API │
101101+│ - indexes metadata │
102102+│ - TS or Yew frontend │
103103+│ - client-side crypto │
104104+└──────────────────────────┘
105105+```
106106+107107+The CLI talks directly to the PDS over XRPC. No middleware, no AppView needed for the core workflow. The AppView becomes relevant later when you want a web UI with search, file browsing, and "shared with me" views.
108108+109109+## Implementation Plan
110110+111111+### Phase 1: CLI Foundation
112112+- [ ] Project scaffold: Rust binary with clap, config file for PDS URL + credentials
113113+- [ ] Auth: create session via `com.atproto.server.createSession`, manage tokens
114114+- [ ] `upload <file>` — generate AES-256-GCM key, encrypt file, upload blob via `com.atproto.repo.uploadBlob`, create `app.opake.cloud.document` record
115115+- [ ] `download <at-uri>` — fetch document record, fetch blob via `com.atproto.sync.getBlob`, decrypt, write to disk
116116+- [ ] `ls` — list document records via `com.atproto.repo.listRecords`
117117+- [ ] `rm <at-uri>` — delete document record (and blob becomes orphaned/GC'd)
118118+- [ ] Local keystore for the user's own wrapped keys (so you can decrypt your own files)
119119+120120+### Phase 2: Sharing
121121+- [ ] `resolve <handle-or-did>` — resolve a DID, fetch DID document, extract public key
122122+- [ ] `share <at-uri> <did>` — wrap content key to recipient's pubkey, create grant record
123123+- [ ] `revoke <grant-at-uri>` — delete grant record
124124+- [ ] `shared` — list grants you've created
125125+- [ ] `inbox` — list grants where you are the recipient (queries your own PDS for grants pointing to your DID... or requires an AppView for cross-PDS discovery)
126126+127127+### Phase 3: Keyrings
128128+- [ ] `keyring create <name>` — generate group key, wrap to self, create keyring record
129129+- [ ] `keyring add-member <keyring> <did>` — wrap group key to new member's pubkey
130130+- [ ] `keyring remove-member <keyring> <did>` — rotate group key, re-wrap to remaining members
131131+- [ ] `keyring ls` — list keyrings
132132+- [ ] `upload <file> --keyring <name>` — encrypt under a keyring instead of direct keys
133133+134134+### Phase 4: Web UI (future)
135135+- [ ] Rust/Axum JSON API server (the AppView) that indexes document/grant/keyring records
136136+- [ ] SPA frontend (TypeScript or Yew) with client-side crypto via Web Crypto API
137137+- [ ] File browser, search, upload/download, grant management UI
138138+139139+### Phase 5: Stretch
140140+- [ ] Folder hierarchy via `parent` references
141141+- [ ] Versioning (new document records referencing previous versions)
142142+- [ ] Large file sidecar service if 50MB limit becomes a problem
143143+- [ ] Additional record types: notes, bookmarks, etc. under `app.opake.cloud.*`
144144+145145+## Technology
146146+147147+- **Rust CLI** is the primary deliverable. All core functionality (encrypt, upload, download, decrypt, keyring/grant management) lives here.
148148+- The atproto Rust ecosystem exists: see `atproto-crates` on Tangled for identity, OAuth, XRPC, record handling.
149149+- **PDS is external.** The project is PDS-agnostic — it talks to whatever PDS you point it at via XRPC. A PDS is already running; it's not part of this project.
150150+- **Web UI is a later phase.** Either a TypeScript SPA or a Yew (Rust/WASM) app, TBD. The Rust AppView would be a JSON API server (Axum) that the SPA talks to.
151151+- **Infrastructure (Caddy, DNS, VPS) is already in place** and not part of this project.
152152+153153+## Key Design Decisions
154154+155155+1. **Encryption is client-side only.** The PDS never sees plaintext content. This means no server-side processing (thumbnails, previews, full-text search over encrypted content). Tradeoff accepted.
156156+157157+2. **Grants are separate records**, not inline in the document. This allows independent creation/deletion, efficient querying ("what's shared with me?"), and matches the atproto pattern of small, independent records.
158158+159159+3. **Two-layer key for keyrings.** Documents under a keyring still have their own per-document content key, wrapped under the group key. This means rotating the group key doesn't require re-encrypting every document's blob — only re-wrapping the group key to remaining members.
160160+161161+4. **No revocation guarantee for historical access.** Same limitation as git-crypt. If someone had the key and cached the blob, they can still read it. True revocation requires re-encrypting the blob with a new content key and deleting the old blob. The schema supports this workflow but doesn't enforce it.
162162+163163+5. **Plaintext metadata is opt-in transparency.** Names and tags are unencrypted by default for usability. Users who need full opacity can use dummy values and embed real metadata in the encrypted payload.
164164+165165+6. **50MB blob limit is fine for now.** Covers documents, photos, and short media. Large file support (video, archives) can come later via a sidecar service similar to how Tangled uses "knots" alongside the PDS.
166166+167167+## File Structure
168168+169169+```
170170+lexicons/
171171+├── README.md # Architecture overview and flow diagrams
172172+├── EXAMPLES.md # Concrete example records with annotations
173173+├── app.opake.cloud.defs.json # Shared type definitions
174174+├── app.opake.cloud.document.json # File/document record
175175+├── app.opake.cloud.keyring.json # Group access keyring
176176+└── app.opake.cloud.grant.json # Ad-hoc share grant
177177+```
178178+179179+## References
180180+181181+- AT Protocol specs: https://atproto.com/specs
182182+- Lexicon spec: https://atproto.com/specs/lexicon
183183+- PDS self-hosting: https://github.com/bluesky-social/pds
184184+- Custom schemas guide: https://docs.bsky.app/docs/advanced-guides/custom-schemas
185185+- Data model (blob format): https://atproto.com/specs/data-model
186186+- atproto Rust crates: https://tangled.org/ngerakines.me/atproto-crates
187187+- Tranquil PDS (Rust): mentioned in atproto self-hosting docs
188188+- Lexicon community registry: https://github.com/lexicon-community/awesome-lexicons
···11+// opake-core: encryption, record types, and XRPC client.
22+//
33+// This crate is the shared foundation for both the CLI (opake-cli) and the
44+// browser client (opake-web, via wasm-pack). Nothing in here depends on a
55+// specific async runtime, filesystem, or platform API.
66+//
77+// Network I/O is injected via the `client::Transport` trait — the CLI provides
88+// a reqwest-based implementation, the SPA provides one using browser fetch.
99+// Crypto is synchronous and pure. Records are just types.
1010+1111+pub mod client;
1212+pub mod crypto;
1313+pub mod error;
1414+pub mod records;
···11+# Example Records
22+33+## 1. Alice creates a private encrypted document
44+55+```json
66+{
77+ "$type": "app.opake.cloud.document",
88+ "name": "tax-return-2025.pdf",
99+ "mimeType": "application/pdf",
1010+ "size": 284619,
1111+ "blob": {
1212+ "$type": "blob",
1313+ "ref": { "$link": "bafkrei..." },
1414+ "mimeType": "application/octet-stream",
1515+ "size": 284640
1616+ },
1717+ "encryption": {
1818+ "$type": "app.opake.cloud.document#directEncryption",
1919+ "envelope": {
2020+ "algo": "aes-256-gcm",
2121+ "nonce": { "$bytes": "base64-encoded-12-byte-nonce" },
2222+ "keys": [
2323+ {
2424+ "did": "did:plc:alice123",
2525+ "ciphertext": { "$bytes": "base64-wrapped-content-key-for-alice" },
2626+ "algo": "ECDH-ES+A256KW"
2727+ }
2828+ ]
2929+ }
3030+ },
3131+ "tags": ["tax", "finance", "2025"],
3232+ "visibility": "private",
3333+ "createdAt": "2026-02-27T10:30:00.000Z"
3434+}
3535+```
3636+3737+**What the PDS sees:** a record with some plaintext metadata (name, tags, timestamps)
3838+and an opaque blob. The `keys` array only contains Alice's wrapped key — only she
3939+can decrypt.
4040+4141+4242+## 2. Alice shares the document with Bob via a grant
4343+4444+```json
4545+{
4646+ "$type": "app.opake.cloud.grant",
4747+ "document": "at://did:plc:alice123/app.opake.cloud.document/3k...",
4848+ "recipient": "did:plc:bob456",
4949+ "wrappedKey": {
5050+ "did": "did:plc:bob456",
5151+ "ciphertext": { "$bytes": "base64-wrapped-content-key-for-bob" },
5252+ "algo": "ECDH-ES+A256KW"
5353+ },
5454+ "permissions": "read",
5555+ "note": "Here's the tax doc you asked about",
5656+ "createdAt": "2026-02-27T11:00:00.000Z"
5757+}
5858+```
5959+6060+**How Bob decrypts:**
6161+1. His client/AppView discovers this grant (firehose, query, or notification)
6262+2. Fetches the document record via the `document` AT URI
6363+3. Uses his private key to decrypt `wrappedKey.ciphertext` → gets AES-256 content key
6464+4. Fetches the blob via `com.atproto.sync.getBlob`
6565+5. Decrypts the blob using the content key + nonce from the document's encryption envelope
6666+6767+**To revoke:** Alice deletes the grant record. Bob's copy of the wrapped key is gone
6868+from the network (eventually). For true forward secrecy, Alice would also re-encrypt
6969+the document with a fresh content key.
7070+7171+7272+## 3. Keyring-based group sharing (family photos)
7373+7474+### First, the keyring:
7575+7676+```json
7777+{
7878+ "$type": "app.opake.cloud.keyring",
7979+ "name": "family-photos",
8080+ "description": "Shared photo collection for the family",
8181+ "algo": "aes-256-gcm",
8282+ "members": [
8383+ {
8484+ "did": "did:plc:alice123",
8585+ "ciphertext": { "$bytes": "base64-group-key-wrapped-for-alice" },
8686+ "algo": "ECDH-ES+A256KW"
8787+ },
8888+ {
8989+ "did": "did:plc:bob456",
9090+ "ciphertext": { "$bytes": "base64-group-key-wrapped-for-bob" },
9191+ "algo": "ECDH-ES+A256KW"
9292+ },
9393+ {
9494+ "did": "did:plc:carol789",
9595+ "ciphertext": { "$bytes": "base64-group-key-wrapped-for-carol" },
9696+ "algo": "ECDH-ES+A256KW"
9797+ }
9898+ ],
9999+ "rotation": 0,
100100+ "createdAt": "2026-01-15T09:00:00.000Z"
101101+}
102102+```
103103+104104+### Then, a document using the keyring:
105105+106106+```json
107107+{
108108+ "$type": "app.opake.cloud.document",
109109+ "name": "beach-sunset.jpg",
110110+ "mimeType": "image/jpeg",
111111+ "size": 3841029,
112112+ "blob": {
113113+ "$type": "blob",
114114+ "ref": { "$link": "bafkrei..." },
115115+ "mimeType": "application/octet-stream",
116116+ "size": 3841056
117117+ },
118118+ "encryption": {
119119+ "$type": "app.opake.cloud.document#keyringEncryption",
120120+ "keyringRef": {
121121+ "keyring": "at://did:plc:alice123/app.opake.cloud.keyring/3k...",
122122+ "wrappedContentKey": { "$bytes": "base64-content-key-encrypted-with-group-key" },
123123+ "rotation": 0
124124+ },
125125+ "algo": "aes-256-gcm",
126126+ "nonce": { "$bytes": "base64-encoded-12-byte-nonce" }
127127+ },
128128+ "tags": ["family", "vacation", "beach"],
129129+ "visibility": "shared",
130130+ "createdAt": "2026-02-20T16:45:00.000Z"
131131+}
132132+```
133133+134134+**How any family member decrypts:**
135135+1. Fetch the keyring record from the `keyring` AT URI
136136+2. Find their own entry in `members`, decrypt with their private key → get group key GK
137137+3. Decrypt `wrappedContentKey` with GK → get the per-document content key
138138+4. Fetch + decrypt the blob with content key + nonce
139139+140140+**Adding a new family member (did:plc:dave):**
141141+- Wrap GK to Dave's pubkey
142142+- Update the keyring record to add Dave to `members`
143143+- Dave can now decrypt *all* documents under this keyring. No per-document changes needed.
144144+145145+**Removing a member:**
146146+- Increment `rotation`, generate new GK, re-wrap to remaining members
147147+- New documents use the new GK
148148+- Old documents remain readable with old GK (same limitation as git-crypt)
149149+- For true revocation of old content: re-encrypt affected documents with new content keys
150150+151151+152152+## Design Decisions & Notes
153153+154154+### Why plaintext metadata?
155155+The `name`, `tags`, `mimeType`, and `description` fields are intentionally unencrypted.
156156+This allows your personal AppView to index and search your files server-side without
157157+needing access to the content encryption keys. It's a conscious tradeoff: someone
158158+inspecting your repo can see *that* you have a file called "tax-return-2025.pdf" tagged
159159+with "finance", but they can't read the actual PDF.
160160+161161+If you want fully opaque storage, you can encrypt the name/tags too and handle
162162+search purely client-side. The schema supports this — just put garbage/generic
163163+strings in the plaintext fields and store the real metadata inside the encrypted blob.
164164+165165+### Why separate grant records?
166166+Instead of adding recipients directly to the document record (like adding to the
167167+`keys` array), grants are separate records because:
168168+- The document owner might not want to update the document record every time they share
169169+- Grants can be deleted independently (for revocation)
170170+- An AppView can efficiently query "what's shared with me?" across all documents
171171+- It matches the atproto pattern of small, independent records
172172+173173+### Why the two-layer key for keyrings?
174174+Documents under a keyring still have their own per-document content key, just
175175+wrapped under the group key instead of individual pubkeys. This means:
176176+- Rotating the group key doesn't require re-encrypting every document's content
177177+- Individual documents can be selectively re-encrypted without touching the keyring
178178+- The content key acts as a per-document nonce for the group key
+60
lexicons/README.md
···11+# app.opake.cloud.* Lexicon Schemas
22+33+An encrypted personal cloud built on AT Protocol.
44+55+## Architecture
66+77+The encryption model follows the same hybrid pattern as git-crypt:
88+- Each file/record is encrypted with a **random symmetric key** (AES-256-GCM)
99+- That symmetric key is **wrapped** (encrypted) to each authorized DID's public key
1010+- Wrapped keys are stored as atproto records, publicly visible but useless without the private key
1111+- File content is uploaded as a PDS blob (opaque encrypted bytes)
1212+1313+## Lexicon Overview
1414+1515+| NSID | Type | Purpose |
1616+|------|------|---------|
1717+| `app.opake.cloud.defs` | defs | Shared type definitions (encryption envelope, wrapped key, etc.) |
1818+| `app.opake.cloud.document` | record | An encrypted file/document with metadata |
1919+| `app.opake.cloud.keyring` | record | A named group with a shared symmetric key, wrapped to each member |
2020+| `app.opake.cloud.grant` | record | A share grant — gives a DID access to a specific document's key |
2121+2222+## Flow: Sharing a file with another DID
2323+2424+```
2525+1. Alice creates a document:
2626+ - Generates random AES-256-GCM key K
2727+ - Encrypts file content with K → uploads as blob
2828+ - Wraps K to her own DID pubkey → stores in document record
2929+3030+2. Alice shares with Bob (did:plc:bob):
3131+ - Resolves did:plc:bob → gets public key from DID document
3232+ - Wraps K to Bob's pubkey
3333+ - Creates a grant record pointing to the document, containing Bob's wrapped key
3434+3535+3. Bob's client:
3636+ - Discovers grant record (via AppView query, or notification)
3737+ - Fetches the referenced document record
3838+ - Finds his wrapped key in the grant
3939+ - Decrypts K with his private key
4040+ - Fetches the encrypted blob via com.atproto.sync.getBlob
4141+ - Decrypts blob with K
4242+```
4343+4444+## Flow: Group sharing via keyring
4545+4646+```
4747+1. Alice creates a keyring "family-photos":
4848+ - Generates group symmetric key GK
4949+ - Wraps GK to each member's DID pubkey
5050+ - Stores as keyring record
5151+5252+2. Alice creates documents referencing the keyring:
5353+ - Each document's content key is encrypted with GK (not individual pubkeys)
5454+ - Any keyring member can derive K from GK
5555+5656+3. Adding a new member:
5757+ - Alice wraps GK to the new member's pubkey
5858+ - Updates the keyring record
5959+ - New member can now decrypt all documents in the group — no per-document re-encryption needed
6060+```
+89
lexicons/app.opake.cloud.defs.json
···11+{
22+ "lexicon": 1,
33+ "id": "app.opake.cloud.defs",
44+ "defs": {
55+ "wrappedKey": {
66+ "type": "object",
77+ "description": "A symmetric key encrypted (wrapped) to a specific DID's public key. The recipient can decrypt this with their private key to recover the underlying symmetric key.",
88+ "required": ["did", "ciphertext", "algo"],
99+ "properties": {
1010+ "did": {
1111+ "type": "string",
1212+ "format": "did",
1313+ "description": "The DID whose public key was used to wrap this key."
1414+ },
1515+ "ciphertext": {
1616+ "type": "bytes",
1717+ "description": "The symmetric key, encrypted to the DID's public key. Base64-encoded in JSON.",
1818+ "maxLength": 512
1919+ },
2020+ "algo": {
2121+ "type": "string",
2222+ "description": "Asymmetric algorithm used for key wrapping.",
2323+ "knownValues": [
2424+ "ECDH-ES+A256KW",
2525+ "x25519-xsalsa20-poly1305"
2626+ ]
2727+ }
2828+ }
2929+ },
3030+3131+ "encryptionEnvelope": {
3232+ "type": "object",
3333+ "description": "Describes how a blob's content was encrypted. Contains the algorithm, nonce/IV, and one or more wrapped copies of the content encryption key.",
3434+ "required": ["algo", "nonce", "keys"],
3535+ "properties": {
3636+ "algo": {
3737+ "type": "string",
3838+ "description": "Symmetric encryption algorithm used on the blob content.",
3939+ "knownValues": ["aes-256-gcm"]
4040+ },
4141+ "nonce": {
4242+ "type": "bytes",
4343+ "description": "The nonce / initialization vector used for encryption.",
4444+ "maxLength": 24
4545+ },
4646+ "keys": {
4747+ "type": "array",
4848+ "description": "One or more wrapped copies of the content encryption key, each encrypted to a different DID.",
4949+ "items": { "type": "ref", "ref": "#wrappedKey" },
5050+ "minLength": 1,
5151+ "maxLength": 100
5252+ }
5353+ }
5454+ },
5555+5656+ "keyringRef": {
5757+ "type": "object",
5858+ "description": "Instead of per-document key wrapping, references a keyring whose group key was used to encrypt the content key. The content key is stored encrypted under the group key.",
5959+ "required": ["keyring", "wrappedContentKey", "rotation"],
6060+ "properties": {
6161+ "keyring": {
6262+ "type": "string",
6363+ "format": "at-uri",
6464+ "description": "AT URI of the app.opake.cloud.keyring record."
6565+ },
6666+ "wrappedContentKey": {
6767+ "type": "bytes",
6868+ "description": "The content encryption key, encrypted with the keyring's group symmetric key (using the same algo as the content encryption).",
6969+ "maxLength": 256
7070+ },
7171+ "rotation": {
7272+ "type": "integer",
7373+ "description": "The keyring rotation counter at the time this content key was wrapped. Clients use this to identify which generation of the group key to use for decryption.",
7474+ "minimum": 0
7575+ }
7676+ }
7777+ },
7878+7979+ "visibility": {
8080+ "type": "string",
8181+ "description": "Hint for AppViews and clients about intended visibility of the content.",
8282+ "knownValues": [
8383+ "private",
8484+ "shared",
8585+ "public"
8686+ ]
8787+ }
8888+ }
8989+}
+108
lexicons/app.opake.cloud.document.json
···11+{
22+ "lexicon": 1,
33+ "id": "app.opake.cloud.document",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "An encrypted file or document stored in the personal cloud. The actual content is an encrypted blob; this record holds the metadata, encryption envelope, and optional plaintext metadata for discoverability.",
88+ "key": "tid",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["name", "blob", "encryption", "createdAt"],
1212+ "properties": {
1313+ "name": {
1414+ "type": "string",
1515+ "description": "Human-readable filename or title. Plaintext — intentionally unencrypted for indexing/search by your own AppView.",
1616+ "maxLength": 512
1717+ },
1818+ "mimeType": {
1919+ "type": "string",
2020+ "description": "Original MIME type of the unencrypted content (e.g. 'application/pdf', 'image/png'). Plaintext metadata.",
2121+ "maxLength": 128
2222+ },
2323+ "size": {
2424+ "type": "integer",
2525+ "description": "Size of the original unencrypted content in bytes.",
2626+ "minimum": 0
2727+ },
2828+ "blob": {
2929+ "type": "blob",
3030+ "description": "The encrypted file content, uploaded via com.atproto.repo.uploadBlob. The PDS stores this as opaque bytes. MimeType on the blob itself will be 'application/octet-stream'.",
3131+ "accept": ["*/*"],
3232+ "maxSize": 52428800
3333+ },
3434+ "encryption": {
3535+ "type": "union",
3636+ "description": "How to decrypt the blob. Either a full encryption envelope (with per-document wrapped keys) or a keyring reference (for group-based access).",
3737+ "refs": [
3838+ "#directEncryption",
3939+ "#keyringEncryption"
4040+ ]
4141+ },
4242+ "tags": {
4343+ "type": "array",
4444+ "description": "Optional plaintext tags for categorization and search. Keep these non-sensitive — they're public.",
4545+ "items": { "type": "string", "maxLength": 128 },
4646+ "maxLength": 32
4747+ },
4848+ "parent": {
4949+ "type": "string",
5050+ "format": "at-uri",
5151+ "description": "Optional reference to a parent document (for folder-like hierarchy). Points to another app.opake.cloud.document record."
5252+ },
5353+ "description": {
5454+ "type": "string",
5555+ "description": "Optional plaintext description or summary.",
5656+ "maxLength": 1024
5757+ },
5858+ "visibility": {
5959+ "type": "ref",
6060+ "ref": "app.opake.cloud.defs#visibility"
6161+ },
6262+ "createdAt": {
6363+ "type": "string",
6464+ "format": "datetime"
6565+ },
6666+ "modifiedAt": {
6767+ "type": "string",
6868+ "format": "datetime"
6969+ }
7070+ }
7171+ }
7272+ },
7373+7474+ "directEncryption": {
7575+ "type": "object",
7676+ "description": "Encryption where the content key is wrapped directly to one or more DIDs. Used for private files or ad-hoc sharing.",
7777+ "required": ["envelope"],
7878+ "properties": {
7979+ "envelope": {
8080+ "type": "ref",
8181+ "ref": "app.opake.cloud.defs#encryptionEnvelope"
8282+ }
8383+ }
8484+ },
8585+8686+ "keyringEncryption": {
8787+ "type": "object",
8888+ "description": "Encryption where the content key is wrapped under a keyring's group key. Anyone with access to the keyring can decrypt.",
8989+ "required": ["keyringRef", "algo", "nonce"],
9090+ "properties": {
9191+ "keyringRef": {
9292+ "type": "ref",
9393+ "ref": "app.opake.cloud.defs#keyringRef"
9494+ },
9595+ "algo": {
9696+ "type": "string",
9797+ "description": "Symmetric algorithm used for content encryption.",
9898+ "knownValues": ["aes-256-gcm"]
9999+ },
100100+ "nonce": {
101101+ "type": "bytes",
102102+ "description": "Nonce/IV for the content encryption.",
103103+ "maxLength": 24
104104+ }
105105+ }
106106+ }
107107+ }
108108+}
+54
lexicons/app.opake.cloud.grant.json
···11+{
22+ "lexicon": 1,
33+ "id": "app.opake.cloud.grant",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "A share grant — gives a specific DID access to a specific document's content encryption key. Created by the document owner when sharing a file ad-hoc (outside of a keyring). The recipient discovers this via their AppView or a notification mechanism. To revoke: delete this record and optionally re-encrypt the document with a new key.",
88+ "key": "tid",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["document", "recipient", "wrappedKey", "createdAt"],
1212+ "properties": {
1313+ "document": {
1414+ "type": "string",
1515+ "format": "at-uri",
1616+ "description": "AT URI of the app.opake.cloud.document record being shared."
1717+ },
1818+ "recipient": {
1919+ "type": "string",
2020+ "format": "did",
2121+ "description": "The DID being granted access."
2222+ },
2323+ "wrappedKey": {
2424+ "type": "ref",
2525+ "ref": "app.opake.cloud.defs#wrappedKey",
2626+ "description": "The document's content encryption key, wrapped to the recipient's DID public key."
2727+ },
2828+ "permissions": {
2929+ "type": "string",
3030+ "description": "What the recipient is allowed to do. Note: this is advisory — actual enforcement depends on the AppView / client. Cryptographically, anyone with the key can read.",
3131+ "knownValues": [
3232+ "read",
3333+ "read-write"
3434+ ]
3535+ },
3636+ "expiresAt": {
3737+ "type": "string",
3838+ "format": "datetime",
3939+ "description": "Optional expiration. After this time, clients should stop serving the content (advisory — the wrapped key remains valid unless the document is re-encrypted)."
4040+ },
4141+ "note": {
4242+ "type": "string",
4343+ "description": "Optional message to the recipient (plaintext).",
4444+ "maxLength": 512
4545+ },
4646+ "createdAt": {
4747+ "type": "string",
4848+ "format": "datetime"
4949+ }
5050+ }
5151+ }
5252+ }
5353+ }
5454+}
+52
lexicons/app.opake.cloud.keyring.json
···11+{
22+ "lexicon": 1,
33+ "id": "app.opake.cloud.keyring",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "A named keyring for group-based access control. Contains a group symmetric key wrapped to each member's DID public key. Analogous to git-crypt's named keys — documents encrypted under this keyring are accessible to all members without per-document key management.",
88+ "key": "tid",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["name", "algo", "members", "createdAt"],
1212+ "properties": {
1313+ "name": {
1414+ "type": "string",
1515+ "description": "Human-readable name for this keyring (e.g. 'family-photos', 'work-projects').",
1616+ "maxLength": 256
1717+ },
1818+ "description": {
1919+ "type": "string",
2020+ "description": "Optional description of this keyring's purpose.",
2121+ "maxLength": 1024
2222+ },
2323+ "algo": {
2424+ "type": "string",
2525+ "description": "The symmetric algorithm the group key is intended for.",
2626+ "knownValues": ["aes-256-gcm"]
2727+ },
2828+ "members": {
2929+ "type": "array",
3030+ "description": "The group key wrapped to each member's DID public key. Each entry allows that DID to recover the group key.",
3131+ "items": { "type": "ref", "ref": "app.opake.cloud.defs#wrappedKey" },
3232+ "minLength": 1,
3333+ "maxLength": 256
3434+ },
3535+ "rotation": {
3636+ "type": "integer",
3737+ "description": "Key rotation counter. Increment when the group key is rotated (e.g. after revoking a member). Documents should reference the rotation they were encrypted under.",
3838+ "minimum": 0
3939+ },
4040+ "createdAt": {
4141+ "type": "string",
4242+ "format": "datetime"
4343+ },
4444+ "modifiedAt": {
4545+ "type": "string",
4646+ "format": "datetime"
4747+ }
4848+ }
4949+ }
5050+ }
5151+ }
5252+}