docs / specification
docs/specification

Specification

Status: stable · Version: 2.0 · Supersedes: LOCK v1.0 (adaptor signatures), v1.1 (Proof-of-Access)

This page is the normative OC Lock specification. Where this document and the reference SDK disagree, this document wins.

§0 Notation

  • Bytes are lowercase hex unless marked base64url.
  • || denotes byte concatenation.
  • KDF() is HKDF-SHA256 per RFC 5869.
  • AEAD() is AES-256-GCM per NIST SP 800-38D (12-byte IV, 16-byte tag).
  • ECDH(k, P) is X25519 per RFC 7748 (output 32 bytes).
  • BIP322(addr, msg) is a BIP-322 signature, encoded as base64.
  • Canonical JSON is UTF-8, lexicographically-sorted keys, no insignificant whitespace, LF-terminated. See §5.

§1 Actors

  • Sender — the party creating a vault.
  • Recipient — the party able to unseal the vault.
  • Device — a browser or app instance holding a device keypair bound to a Bitcoin identity.
  • Directory — a Nostr relay (or set of relays) storing device records.
  • Relay (optional) — a server holding payment-gated content keys, releasing them upon observing qualifying Bitcoin payments.

Sender and recipient may be the same person (self-vault).

§2 Identities

An OC identity is the pair (btc_address, attestation_id) where:

  • btc_address is a mainnet Bitcoin address (P2WPKH, P2TR, or P2PKH)
  • attestation_id is the SHA-256 of a canonical OrangeCheck message signed by that address

A recipient MUST have an OC identity known to the sender. OC Lock does not publish recipient pubkeys; it publishes device keys addressable by the recipient's Bitcoin address.

§3 Device keys

A device key is a long-lived X25519 keypair generated by a recipient in a particular client and bound to their Bitcoin identity by a BIP-322 signature.

§3.1 Generation

(device_sk, device_pk) := x25519_keygen()

device_sk MUST be stored in a confidential local store (IndexedDB with extractable=false via WebCrypto if available, else plaintext). device_sk MUST NOT leave the device.

§3.2 Binding statement

oc-lock:device-bind:v2
address: <btc_address>
device_pk: <hex(device_pk)>
device_id: <random 16-byte hex>
created_at: <iso8601 utc>

Each line terminated with LF (\n). binding_sig = BIP322(btc_address, binding_statement).

§3.3 Publication

Published as a Nostr addressable event (kind 30078):

kind:       30078
tags:       [
  ["d",           "oc-lock:device:<btc_address>"],
  ["addr",        "<btc_address>"],
  ["device_id",   "<device_id>"],
  ["device_pk",   "<hex(device_pk)>"],
  ["alg",         "x25519"],
  ["binding_sig", "<base64(binding_sig)>"]
]
content:    <exact bytes of binding_statement>
pubkey:     <ephemeral nostr pubkey, see §3.4>
created_at: <unix seconds>

§3.4 Nostr authorship

The Nostr pubkey isn't tied to the Bitcoin identity — authenticity is the BIP-322 binding_sig. A fresh ephemeral Nostr keypair SHOULD be derived deterministically from the device secret:

nostr_sk := HKDF(ikm=device_sk, salt="oc-lock/v2/nostr-key", info="nostr-sk", L=32)

§3.5 Rotation and revocation

  • Rotate — generate a new (device_sk, device_pk) with a new device_id, re-sign the binding statement, publish. Same d tag replaces.
  • Revoke — publish with device_pk = "revoked" and a fresh binding_sig over a revocation statement. Conforming senders MUST refuse to encrypt to revoked records.

§3.6 Multi-device

Publish one record per device under oc-lock:device:<addr>:<device_id>. Senders encrypt once per active device.

§4 Envelope format

A sealed vault is a canonical JSON object. File extension .lock, MIME application/vnd.oc-lock+json.

§4.1 Schema

{
  "v": 2,
  "kind": "identity" | "payment",
  "id": "<32-byte hex>",
  "alg": { "kem": "x25519", "aead": "aes-256-gcm", "kdf": "hkdf-sha256" },
  "from": {
    "address": "<btc address>",
    "attestation_id": "<sha256 of sender's OC message, optional>"
  },
  "recipients": [
    {
      "address":     "<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": <int>, "address": "<btc addr>", "confirmations": <int>, "relay": "<url>" },
  "sig": { "alg": "bip322", "pubkey": "<sender btc address>", "value": "<base64 sig>" }
}

§4.2 Encryption (identity mode)

  1. Generate random content_key (32 bytes).
  2. Generate random nonce_ct (12 bytes).
  3. ciphertext = AEAD(content_key, nonce_ct, payload, aad=envelope_id_draft) where envelope_id_draft is SHA-256 of the canonical envelope with ciphertext, sig, and recipients[*].wrapped_key elided.
  4. For each recipient device:
    • (eph_sk, eph_pk) := x25519_keygen()
    • shared = X25519(eph_sk, device_pk)
    • kek = HKDF(shared, salt=nonce_ct, info="oc-lock/v2/kek:" || device_id, L=32)
    • nonce_kek random 12 bytes
    • wrapped_key = AEAD(kek, nonce_kek, content_key, aad=device_id)
    • zeroize eph_sk
  5. Canonicalize full envelope; id = sha256(canonical_bytes_without_sig).
  6. sig.value = BIP322(sender_addr, id).
  7. Zeroize content_key.

§4.3 Decryption (identity mode)

  1. Recompute id. Reject on mismatch.
  2. Verify sig.value against id using BIP-322. (Clients MAY skip for self-seals.)
  3. Find recipient entry matching local device_id. If none — E_NOT_ADDRESSED.
  4. shared = X25519(device_sk, eph_pk).
  5. kek = HKDF(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).
  8. Zeroize shared, kek, content_key.

§4.4 Payment mode

Replaces step 4 in §4.2 with a single "recipient" that is the named relay. The unseal flow requires the recipient to authenticate to the relay (via OrangeCheck sign-in) and submit a confirmed payment tx; the relay then unwraps the content key and re-wraps for the recipient's device.

The relay is a trust anchor. Clients MUST display the relay URL prominently before accepting payment-mode vaults.

§5 Canonicalization

  • UTF-8 JSON with keys sorted lexicographically at every level.
  • No insignificant whitespace.
  • Arrays preserve order. Within recipients[], entries MUST be sorted by device_id ascending.
  • Numbers: integers within IEEE 754 double range without fractional zeros or exponents.
  • Strings: \uXXXX only for control chars and " / \.
  • Final byte is LF.

Reference: RFC 8785 JCS, with the additional recipients[] rule.

§6 Errors

CodeMeaning
E_NO_DEVICENo device key found for this browser.
E_NOT_ADDRESSEDEnvelope does not include the recipient's device_id.
E_BAD_SIGSender BIP-322 signature did not verify.
E_BAD_TAGAEAD authentication failed (tampered or wrong key).
E_EXPIREDexpires_at is in the past.
E_REVOKEDRecipient device record is revoked on Nostr.
E_PAYMENT_UNMETRelay could not verify required payment.
E_RELAY_UNREACHABLERelay did not respond.

§7 Security model

Proves:

  • Authenticity (sender's BIP-322 signature over envelope id)
  • Confidentiality (only holders of a listed device_sk can derive the content key)
  • Identity binding (recipient's device_pk bound to btc_address by BIP-322)

Does NOT prove:

  • Order / freshness — created_at is informational only.
  • Delivery — transport is your problem.
  • Metadata privacy — from.address, recipients[*].address, hint, payment.address are plaintext.

Trust assumptions:

  • Identity mode: trustless. Nostr relays can censor but cannot forge.
  • Payment mode: explicit trust in the named relay.

§8 Versioning

v is an integer. Clients MUST reject envelopes whose v they don't support. Minor additions handled via unknown-field tolerance.

§9 Compliance checklist

A client is OC Lock v2 compliant iff:

  • Generates device keys as X25519, stores device_sk non-extractable when WebCrypto permits.
  • Produces binding statements byte-for-byte per §3.2.
  • Publishes kind-30078 records per §3.3 with BIP-322 binding signature.
  • Canonicalizes envelopes per §5 — identical id across implementations.
  • Accepts and produces all required envelope fields per §4.1.
  • Verifies sender BIP-322 signatures before accepting envelope contents.
  • Refuses encryption to revoked device records.
  • Enforces expires_at when present.
  • Emits error codes per §6.

§10 External identifiers

  • Nostr kind: 30078 (addressable). d-tag namespace oc-lock:device:* claimed by this spec.
  • File extension: .lock
  • MIME: application/vnd.oc-lock+json (self-allocated)