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_addressis a mainnet Bitcoin address (P2WPKH, P2TR, or P2PKH)attestation_idis 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 newdevice_id, re-sign the binding statement, publish. Samedtag replaces. - Revoke — publish with
device_pk = "revoked"and a freshbinding_sigover 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)
- Generate random
content_key(32 bytes). - Generate random
nonce_ct(12 bytes). ciphertext = AEAD(content_key, nonce_ct, payload, aad=envelope_id_draft)whereenvelope_id_draftis SHA-256 of the canonical envelope withciphertext,sig, andrecipients[*].wrapped_keyelided.- 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_kekrandom 12 byteswrapped_key = AEAD(kek, nonce_kek, content_key, aad=device_id)- zeroize
eph_sk
- Canonicalize full envelope;
id = sha256(canonical_bytes_without_sig). sig.value = BIP322(sender_addr, id).- Zeroize
content_key.
§4.3 Decryption (identity mode)
- Recompute
id. Reject on mismatch. - Verify
sig.valueagainstidusing BIP-322. (Clients MAY skip for self-seals.) - Find recipient entry matching local
device_id. If none —E_NOT_ADDRESSED. shared = X25519(device_sk, eph_pk).kek = HKDF(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).- 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 bydevice_idascending. - Numbers: integers within IEEE 754 double range without fractional zeros or exponents.
- Strings:
\uXXXXonly for control chars and"/\. - Final byte is LF.
Reference: RFC 8785 JCS, with the additional recipients[] rule.
§6 Errors
| Code | Meaning |
|---|---|
E_NO_DEVICE | No device key found for this browser. |
E_NOT_ADDRESSED | Envelope does not include the recipient's device_id. |
E_BAD_SIG | Sender BIP-322 signature did not verify. |
E_BAD_TAG | AEAD authentication failed (tampered or wrong key). |
E_EXPIRED | expires_at is in the past. |
E_REVOKED | Recipient device record is revoked on Nostr. |
E_PAYMENT_UNMET | Relay could not verify required payment. |
E_RELAY_UNREACHABLE | Relay did not respond. |
§7 Security model
Proves:
- Authenticity (sender's BIP-322 signature over envelope id)
- Confidentiality (only holders of a listed
device_skcan derive the content key) - Identity binding (recipient's
device_pkbound tobtc_addressby BIP-322)
Does NOT prove:
- Order / freshness —
created_atis informational only. - Delivery — transport is your problem.
- Metadata privacy —
from.address,recipients[*].address,hint,payment.addressare 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_sknon-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
idacross 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_atwhen present. - Emits error codes per §6.
§10 External identifiers
- Nostr kind:
30078(addressable).d-tag namespaceoc-lock:device:*claimed by this spec. - File extension:
.lock - MIME:
application/vnd.oc-lock+json(self-allocated)