docs / envelope format
docs/envelope format

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

FieldBytes / typePurpose
vinteger (literal 2)Format version. Clients MUST reject unknown versions.
kind"identity" | "payment"Identity = local decrypt; payment = relay-held key released after tx.
id64-char lowercase hexSHA-256 of the canonical envelope with sig.value emptied.
algobjectAlgorithm identifiers. Fixed in v2; reserved for forward-compat.
from.addressstringSender's mainnet Bitcoin address.
from.attestation_idhex, optionalSHA-256 of the sender's OrangeCheck canonical message.
recipients[].device_pk32-byte hexRecipient's long-lived X25519 pubkey.
recipients[].eph_pk32-byte hexSender's ephemeral X25519 pubkey for this recipient.
recipients[].wrapped_keybase64urlContent key encrypted under the derived KEK.
recipients[].nonce_kek12-byte hexNonce used when wrapping the content key.
ciphertextbase64urlAEAD(content_key, nonce_ct, payload, aad=envelope_id_draft).
nonce_ct12-byte hexNonce used when encrypting ciphertext. Also the HKDF salt for KEKs.
hintstring ≤ 200 bytes, optionalPlaintext hint (filename, subject). Not required; leaks what you choose.
sig.valuebase64BIP-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:

  1. UTF-8 JSON with keys sorted lexicographically at every level.
  2. No insignificant whitespace.
  3. Arrays preserve order — except recipients[], which is sorted by device_id ascending.
  4. Integers within IEEE 754 double range are serialized without fractional zeros or exponents.
  5. Strings use \uXXXX escape only for control chars and " / \.
  6. 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.jsonv04-with-hint.json).

Encryption procedure (identity mode)

Given payload: Uint8Array and one or more recipient { address, device_id, device_pk } records:

  1. content_key := random(32) — the only key that ever touches the payload.
  2. nonce_ct := random(12).
  3. Build the draft envelope — every field filled in except ciphertext, sig.value, and recipients[*].wrapped_key, which are empty strings.
  4. envelope_id_draft := SHA-256(canonical(draft)).
  5. ciphertext := AEAD(content_key, nonce_ct, payload, aad = envelope_id_draft).
  6. 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.
  7. Canonicalize the now-complete envelope (with ciphertext and all wrapped_key values).
  8. id := SHA-256(canonical(envelope with sig.value empty)).
  9. sig.value := BIP322(sender_addr, id).
  10. Zeroize content_key.

Decryption procedure (identity mode)

Given an envelope and a local (device_id, device_sk):

  1. Recompute id from the envelope and compare against envelope.id. Reject on mismatch.
  2. Verify sig.value using BIP-322 against sig.pubkey and envelope.id. (Self-seals may skip.)
  3. Find the recipients[] entry matching your local device_id. If none — E_NOT_ADDRESSED.
  4. shared := X25519(device_sk, eph_pk).
  5. kek := HKDF(ikm = shared, salt = nonce_ct, info = "oc-lock/v2/kek:" || device_id, L = 32).
  6. content_key := AEAD.decrypt(kek, nonce_kek, wrapped_key, aad = device_id).
  7. payload := AEAD.decrypt(content_key, nonce_ct, ciphertext, aad = envelope_id_draft) where envelope_id_draft is recomputed exactly as in step 3 of the encryption procedure.
  8. Zeroize shared, kek, content_key.

Size budget

A single envelope typically weighs:

ComponentApprox bytes
Fixed fields (v, kind, alg, from, sig, timestamps)~220
Per-recipient entry~200
Ciphertextpayload + 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, MIME application/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-addressed id. 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).
  • payment metadata if in payment mode.
  • created_at and expires_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.