Envelope format
A sealed vault is a single canonical JSON object called an envelope. It's self-contained — you can transport it in a URL fragment, a file, a QR code, a Nostr event, or a pigeon. This page walks the format end-to-end. For the normative rules, see the specification.
Shape
{
"v": 2,
"kind": "identity" | "payment",
"id": "<32-byte hex envelope id>",
"alg": {
"kem": "x25519",
"aead": "aes-256-gcm",
"kdf": "hkdf-sha256"
},
"from": {
"address": "<sender btc address>",
"attestation_id": "<sha256 of sender's OC canonical message, optional>"
},
"recipients": [
{
"address": "<recipient btc address>",
"device_id": "<recipient device id>",
"device_pk": "<hex X25519 pubkey>",
"eph_pk": "<hex X25519 ephemeral pubkey>",
"wrapped_key": "<base64url(AEAD(content_key, kek, nonce_kek))>",
"nonce_kek": "<12-byte hex>"
}
],
"ciphertext": "<base64url(AEAD(payload, content_key, nonce_ct))>",
"nonce_ct": "<12-byte hex>",
"hint": "<optional plaintext hint, <= 200 bytes>",
"created_at": "<iso8601 utc>",
"expires_at": "<iso8601 utc | null>",
"payment": null | {
"amount_sats": <positive integer>,
"address": "<btc address to pay>",
"confirmations": <int>,
"relay": "<https url of OC Lock relay>"
},
"sig": {
"alg": "bip322",
"pubkey": "<sender btc address>",
"value": "<base64 BIP-322 signature over `id`>"
}
}
Field reference
| Field | Bytes / type | Purpose |
|---|---|---|
v | integer (literal 2) | Format version. Clients MUST reject unknown versions. |
kind | "identity" | "payment" | Identity = local decrypt; payment = relay-held key released after tx. |
id | 64-char lowercase hex | SHA-256 of the canonical envelope with sig.value emptied. |
alg | object | Algorithm identifiers. Fixed in v2; reserved for forward-compat. |
from.address | string | Sender's mainnet Bitcoin address. |
from.attestation_id | hex, optional | SHA-256 of the sender's OrangeCheck canonical message. |
recipients[].device_pk | 32-byte hex | Recipient's long-lived X25519 pubkey. |
recipients[].eph_pk | 32-byte hex | Sender's ephemeral X25519 pubkey for this recipient. |
recipients[].wrapped_key | base64url | Content key encrypted under the derived KEK. |
recipients[].nonce_kek | 12-byte hex | Nonce used when wrapping the content key. |
ciphertext | base64url | AEAD(content_key, nonce_ct, payload, aad=envelope_id_draft). |
nonce_ct | 12-byte hex | Nonce used when encrypting ciphertext. Also the HKDF salt for KEKs. |
hint | string ≤ 200 bytes, optional | Plaintext hint (filename, subject). Not required; leaks what you choose. |
sig.value | base64 | BIP-322 signature over id, by sig.pubkey. |
Canonicalization
The envelope must be byte-identical across clients or the id won't match and verification fails. Rules per SPEC §5:
- UTF-8 JSON with keys sorted lexicographically at every level.
- No insignificant whitespace.
- Arrays preserve order — except
recipients[], which is sorted bydevice_idascending. - Integers within IEEE 754 double range are serialized without fractional zeros or exponents.
- Strings use
\uXXXXescape only for control chars and"/\. - File ends in a single LF.
This is RFC 8785 JCS plus the one recipients[] sort rule. @orangecheck/lock-core ships the canonicalizer; if you implement one in a new language, cross-check against the committed test vectors (v01-minimal.json … v04-with-hint.json).
Encryption procedure (identity mode)
Given payload: Uint8Array and one or more recipient { address, device_id, device_pk } records:
content_key := random(32)— the only key that ever touches the payload.nonce_ct := random(12).- Build the draft envelope — every field filled in except
ciphertext,sig.value, andrecipients[*].wrapped_key, which are empty strings. envelope_id_draft := SHA-256(canonical(draft)).ciphertext := AEAD(content_key, nonce_ct, payload, aad = envelope_id_draft).- For each recipient:
(eph_sk, eph_pk) := x25519_keygen()shared := X25519(eph_sk, device_pk)(32 bytes)kek := HKDF(ikm = shared, salt = nonce_ct, info = "oc-lock/v2/kek:" || device_id, L = 32)nonce_kek := random(12)wrapped_key := AEAD(kek, nonce_kek, content_key, aad = device_id)- Zeroize
eph_sk,shared,kek.
- Canonicalize the now-complete envelope (with
ciphertextand allwrapped_keyvalues). id := SHA-256(canonical(envelope with sig.value empty)).sig.value := BIP322(sender_addr, id).- Zeroize
content_key.
Decryption procedure (identity mode)
Given an envelope and a local (device_id, device_sk):
- Recompute
idfrom the envelope and compare againstenvelope.id. Reject on mismatch. - Verify
sig.valueusing BIP-322 againstsig.pubkeyandenvelope.id. (Self-seals may skip.) - Find the
recipients[]entry matching your localdevice_id. If none —E_NOT_ADDRESSED. shared := X25519(device_sk, eph_pk).kek := HKDF(ikm = shared, salt = nonce_ct, info = "oc-lock/v2/kek:" || device_id, L = 32).content_key := AEAD.decrypt(kek, nonce_kek, wrapped_key, aad = device_id).payload := AEAD.decrypt(content_key, nonce_ct, ciphertext, aad = envelope_id_draft)whereenvelope_id_draftis recomputed exactly as in step 3 of the encryption procedure.- Zeroize
shared,kek,content_key.
Size budget
A single envelope typically weighs:
| Component | Approx bytes |
|---|---|
| Fixed fields (v, kind, alg, from, sig, timestamps) | ~220 |
| Per-recipient entry | ~200 |
| Ciphertext | payload + 16 (GCM tag) |
| Base64url overhead on ciphertext | × 1.33 |
A 10-recipient envelope with a 1 KiB payload is roughly 3.5 KiB canonical. The web client encodes envelopes in URL fragments; browsers handle fragments up to ~2 MiB reliably but we cap payloads at 256 KiB to keep URLs shareable.
Transport
Any lossless UTF-8 channel works:
- URL fragment. The canonical JSON → UTF-8 → base64url →
#…. Never sent to a server. - File. Save as
.lock, MIMEapplication/vnd.oc-lock+json. - QR code. For envelopes ≤ ~2.9 KiB at error-correction M — rendered as a single code. Larger payloads are split via the
oc-lock:mp/v1:<id>:<index>:<total>:<slice>multipart framing. Each part is itself a QR; scanners pick them up in any order and reassemble by the content-addressedid. The reference composer produces the grid automatically when a share URL exceeds the single-QR budget, and the reader's built-in camera scanner tracks multi-part progress (N / total parts captured) and auto-parses the envelope when every piece is in.
Multi-file payloads
The envelope carries a single opaque payload byte array. To send multiple files in one envelope, the reference client bundles them in an oc-lock/archive/v1 wrapper before encryption:
magic = "OCLA" (4 bytes)
version = 0x01 (1 byte)
file_count = uint16 BE (2 bytes)
for each file:
name_len = uint16 BE
name = <name_len> UTF-8 bytes
mime_len = uint16 BE
mime = <mime_len> ASCII bytes
data_len = uint32 BE
data = <data_len> raw bytes
The wrapper is applied before the envelope's AEAD encryption, so the archive framing is only visible after a successful unseal. The reference reader detects the OCLA magic at byte 0 and offers per-file download plus a "download all" action. Single-file envelopes skip the archive wrapper entirely — back-compat with receivers that only understand the single-file case. If a sender includes both a text note and file attachments, the composer puts the note at files[0].name = "message.txt" and the receiver renders it inline as the accompanying message.
- Nostr event. Put the canonical JSON in
content; any kind works (we don't use Nostr for envelope transport by default — only for the device directory). - Paper / SD card. Self-contained bytes; decrypt when you're back online.
What's plaintext on the wire
Every envelope leaks these by design:
- Version, algorithm identifiers.
- Sender address (
from.address). - Recipient addresses and device ids.
hint(you chose what to include).paymentmetadata if in payment mode.created_atandexpires_at.
Everything else is ciphertext. If you want to hide the recipient set, strip addresses and transport the envelope out-of-band to known recipients only. The device_id still links across envelopes; for perfect unlinkability between messages, rotate device keys between sends.