Beta

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 #

NSIDTypePurpose
at.opake.accountConfigrecord (singleton)Per-account preferences, indexer URL pin
at.opake.publicKeyrecord (singleton)X25519 encryption public key for key discovery
at.opake.documentrecordAn encrypted file
at.opake.directoryrecordOrganisational grouping of documents + sub-directories
at.opake.grantrecordOne-to-one share grant
at.opake.keyringrecordWorkspace (named group with a shared symmetric key)
at.opake.pendingSharerecordQueued share waiting for recipient to publish their key
at.opake.pairRequestrecordNew-device identity-transfer request
at.opake.pairResponserecordExisting-device encrypted identity payload
at.opake.defsdefsShared types (wrappedKey, encryptionEnvelope, keyringRef, encryptedMetadata)
at.opake.authFullAccesspermission-setOAuth 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 #

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 #

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 #

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 #

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 #

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 #

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 #

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:

  1. Generates a new group key, bumps rotation.
  2. Archives the old members dict into keyHistory[rotation - 1].
  3. Writes the new members dict (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:

  1. Parses the commit and ignores anything outside those collections.
  2. 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.
  3. Writes the record to its own Postgres index, advancing the chain head in the same transaction.
  4. 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.