Lexicon reference
Opake publishes at.opake.* records to the caller's PDS. This page is a working
reference for the schemas — what a document record looks like on the wire, how the
encryption envelope is shaped, what fields the indexer relies on. The authoritative
schemas live in /lexicons
in the repo.
Collections #
| NSID | Type | Purpose |
|---|---|---|
at.opake.accountConfig | record (singleton) | Per-account preferences, indexer URL pin |
at.opake.publicKey | record (singleton) | X25519 encryption public key for key discovery |
at.opake.document | record | An encrypted file |
at.opake.directory | record | Organisational grouping of documents + sub-directories |
at.opake.grant | record | One-to-one share grant |
at.opake.keyring | record | Workspace (named group with a shared symmetric key) |
at.opake.pendingShare | record | Queued share waiting for recipient to publish their key |
at.opake.pairRequest | record | New-device identity-transfer request |
at.opake.pairResponse | record | Existing-device encrypted identity payload |
at.opake.defs | defs | Shared types (wrappedKey, encryptionEnvelope, keyringRef, encryptedMetadata) |
at.opake.authFullAccess | permission-set | OAuth scope bundle covering every at.opake.* collection |
Everything record-typed lives in the PDS's repo. The only blobs are the encrypted file
contents referenced from at.opake.document.blob — up to 50 MB per the default PDS
config.
Encryption primitives #
Three small shapes underpin every opaque field on every record. They live in
at.opake.defs
as shared refs.
wrappedKey #
{
"did": "did:plc:alice...", // recipient DID
"ciphertext": "<bytes>", // content key, encrypted to their pubkey
"algo": "x25519-mlkem768-hkdf-a256kw-v2"
}A symmetric key encrypted to a specific recipient's hybrid public key. algo is
x25519-mlkem768-hkdf-a256kw-v2 — X25519 + ML-KEM-768 combined via HKDF, AES-KeyWrap
over the content key (BSI TR-02102 / ANSSI hybrid construction). ciphertext is
[32-byte X25519 pubkey || 1088-byte ML-KEM ciphertext || 40-byte wrapped key].
encryptionEnvelope #
{
"algo": "aes-256-gcm",
"nonce": "<12-byte IV>",
"keys": [ // one wrapped copy per recipient
{ "did": "did:plc:alice", "ciphertext": "...", "algo": "x25519-mlkem768-hkdf-a256kw-v2" },
{ "did": "did:plc:bob", "ciphertext": "...", "algo": "x25519-mlkem768-hkdf-a256kw-v2" }
]
}Full shape for direct-sharing: the content key is wrapped once per recipient, all
copies listed in keys. AES-256-GCM on the blob with the given nonce. One content
key, many wrapped copies. Rotating a recipient's wrap doesn't affect the others.
keyringRef #
{
"keyring": "at://did:plc:owner/at.opake.keyring/abc123",
"wrappedContentKey": "<bytes>", // content key encrypted under group key
"rotation": 3 // which generation of the group key
}The workspace alternative to encryptionEnvelope. Instead of wrapping the content
key to each member directly, wrap it under the keyring's group key once. Members
unwrap the group key from their keyring entry, then use the group key to unwrap the
content key. rotation identifies which generation of the group key was used — the
keyring record keeps historical rotations in keyHistory so remaining members can
still decrypt pre-rotation documents.
encryptedMetadata #
{
"ciphertext": "<AES-256-GCM ciphertext>",
"nonce": "<12-byte IV>"
}
// Plaintext (after decryption) is a JSON object:
{
"name": "budget.pdf",
"mimeType": "application/pdf",
"size": 4821,
"description": "Q4 planning doc",
"tags": ["finance", "q4"],
"createdAt": "2026-04-24T10:00:00Z"
}Record-level metadata (filename, MIME type, size, tags, description) is encrypted in
one blob with the same key protecting the record's payload. The PDS only ever sees
ciphertext for these fields — there's no server-side search over real filenames.
Record-level fields that ARE in plaintext (the blob.mimeType, a placeholder name)
are always dummy values.
Record shapes #
at.opake.document #
{
"$type": "at.opake.document",
"opakeVersion": 1,
"blob": {
"$type": "blob",
"ref": { "$link": "bafyreib..." },
"mimeType": "application/octet-stream",
"size": 4821
},
"encryption": {
// Cabinet / direct-share case:
"$type": "at.opake.document#directEncryption",
"envelope": { /* encryptionEnvelope, see above */ }
// ...or workspace case:
// "$type": "at.opake.document#keyringEncryption",
// "keyringRef": { /* keyringRef, see above */ },
// "algo": "aes-256-gcm",
// "nonce": "<bytes>"
},
"encryptedMetadata": { /* see above */ },
"createdAt": "2026-04-24T10:00:00Z"
}The encryption field is a tagged union. directEncryption carries a full
encryptionEnvelope — used for cabinet documents and one-to-one shares. Workspace
documents use keyringEncryption instead: just the keyringRef, the algo, and the
nonce. The content key isn't stored on the document at all; it's protected by the
keyring's group key.
opakeVersion lets clients reject records they don't understand. Bumping it on a
schema change means old clients see a decryption failure, not a silent mis-parse.
at.opake.grant #
{
"$type": "at.opake.grant",
"opakeVersion": 1,
"document": "at://did:plc:alice/at.opake.document/xyz789",
"recipient": "did:plc:bob",
"wrappedKey": {
"did": "did:plc:bob",
"ciphertext": "<content key, re-wrapped to Bob>",
"algo": "x25519-mlkem768-hkdf-a256kw-v2"
},
"createdAt": "2026-04-24T10:05:00Z"
}One grant per (document, recipient) pair. The grant lives on the sharer's PDS,
not the recipient's — the recipient discovers the grant by subscribing to the indexer.
Grants reference the document by AT-URI; there's no keyring involved. Revoking a
grant is a deleteRecord call; there's no key rotation because the document was
already decryptable by the sharer's content key.
at.opake.keyring #
{
"$type": "at.opake.keyring",
"opakeVersion": 1,
"members": {
"did:plc:alice": { "wrappedKey": {...}, "role": "manager" },
"did:plc:bob": { "wrappedKey": {...}, "role": "editor" },
"did:plc:carol": { "wrappedKey": {...}, "role": "viewer" }
},
"rotation": 3,
"keyHistory": {
"0": { /* prior rotation's members, for historical decryption */ },
"1": { /* ... */ },
"2": { /* ... */ }
},
"encryptedMetadata": { /* name, description, icon */ },
"createdAt": "2026-04-20T15:00:00Z"
}A workspace. members is keyed by DID, with each entry carrying a wrapped copy of
the current group key plus the member's role (manager / editor / viewer).
keyHistory retains the wrapped copies from prior rotations so members still in the
workspace can decrypt documents uploaded under older generations of the group key.
Removing a member atomically:
- Generates a new group key, bumps
rotation. - Archives the old
membersdict intokeyHistory[rotation - 1]. - Writes the new
membersdict (without the removed member, re-wrapped to the rest).
Per-document content keys and blobs aren't touched. The removed member could still decrypt anything uploaded under a prior rotation with a cached copy of the old group key — "no revocation guarantee for historical access" is called out up front; apps that need forward-secret removal must re-upload documents after rotation.
Indexing #
The indexer subscribes to the atproto firehose and consumes five at.opake.*
collections: keyring, directory, document, grant, and accountConfig — the
last only as a proof-of-life heartbeat, never stored. For each event, it:
- Parses the commit and ignores anything outside those collections.
- Checks authority if the record supersedes another on a tracked chain — a keyring supersede from a non-manager (bar self-removal), or a directory supersede that isn't additive from an editor, is rejected and never indexed.
- Writes the record to its own Postgres index, advancing the chain head in the same transaction.
- Fans out to SSE subscribers via Phoenix PubSub.
The indexer never decrypts anything. All the filtering is over plaintext metadata (DIDs, AT-URIs, timestamps, roles) — the encrypted payload passes through opaque.
See Live updates for the client-side stream consumer, and docs/indexer.md for the indexer's own configuration and API.
Schema versioning #
Every record carries opakeVersion: integer. Current version is 1 across the board.
The compatibility contract:
- Minor additions (optional fields, new known values for string enums) don't bump the version. Old clients ignore unknown fields.
- Breaking changes (required field additions, envelope restructures, algorithm swaps) bump the version. Old clients are expected to reject.
- Decryption-relevant changes always bump the version, even if the wire format looks backward-compatible. We'd rather an old client fail fast than silently decrypt under the wrong assumption.
If you're building a client that needs to survive across Opake versions, validate
opakeVersion up front and fall back to "please upgrade" messaging for anything past
your supported set.
Getting the authoritative schemas #
The JSON schema files in /lexicons
are the canonical source. Every field description, constraint, and knownValues list is
there. Clone the repo and wire them into your codegen:
git clone https://github.com/Opake-at/Opake
# then point your lexicon codegen at opake/lexicons/
The Rust core uses atproto-rs codegen over these same files. The TypeScript SDK
re-exports the field-level types via @opake/sdk — check
Files & directories and Sharing for how they
surface in the high-level API.