How OC Lock works
Four steps — two recipient-side, two sender-side. End-to-end.
1. Recipient registers a device
The recipient generates an X25519 keypair, builds a canonical binding statement, and signs it with BIP-322 from their Bitcoin address:
oc-lock-device-binding-v0
address: bc1qbob...
device_id: <16 random bytes, hex>
device_pk: <32-byte X25519 public key, hex>
created_at: 2026-04-24T06:47:29.977Z
ack: I bind this device key to this Bitcoin address.
Signature + binding are wrapped as a kind-30078 Nostr event with d tag
oc-lock:device:<bc1qbob...> (addressable, replaceable by the same pubkey) and
published.
Code:
import {
buildBindingStatement,
generateDeviceKey,
} from '@orangecheck/lock-device';
const { device_sk, device_pk, device_id, created_at } = generateDeviceKey();
const statement = buildBindingStatement({
address: 'bc1qbob...',
device_pk,
device_id,
created_at,
});
const signature = await walletSignBIP322(statement);
// Publish { statement, signature, ... } as a kind-30078 event
The device secret (device_sk) stays on the recipient's device. Never leaves.
If it does, that device's envelopes are compromised.
2. Sender looks up the recipient
import { queryDeviceRecord } from '@orangecheck/lock-device';
const record = await queryDeviceRecord('bc1qbob...', relays);
// record.device_pk is the 32-byte X25519 public key we'll encrypt to.
// record.signature proves bc1qbob... bound this key.
Before encrypting, the sender SHOULD verify the BIP-322 binding. If the signature is bad or the address doesn't match, the record is forged — refuse to send.
3. Sender seals
import { seal } from '@orangecheck/lock-core';
const envelope = await seal({
payload: new TextEncoder().encode('hello bob'),
sender: {
address: 'bc1qalice...',
signMessage: async (msg) => walletSignBIP322(msg),
},
recipients: [
{
address: record.address,
device_id: record.device_id,
device_pk: record.device_pk,
},
],
});
Inside seal():
- Generate an ephemeral X25519 keypair
(eph_sk, eph_pk)for this envelope. - For each recipient, compute
shared = X25519(eph_sk, recipient.device_pk). - Derive the symmetric key:
sym = HKDF-SHA256(shared, salt=envelope_id, info="oc-lock v0"). - Encrypt the payload:
AES-256-GCM(sym, nonce, plaintext, aad=envelope_id). - Wrap the ciphertext + ephemeral public key + per-recipient metadata in a canonicalized JSON envelope.
- Sender signs the envelope with BIP-322 (binds the envelope to the sender's Bitcoin address).
The result is a self-contained JSON blob. Send it over any transport.
4. Recipient unseals
import { unseal } from '@orangecheck/lock-core';
const { payload, sender, matchedDeviceId } = await unseal({
envelope,
device: { device_id, secretKey: device_sk },
verifyBip322: async (msg, sig, addr) => walletVerifyBIP322(msg, sig, addr),
});
Inside unseal():
- Find the recipient entry matching the local
device_id. - Recompute the shared secret:
shared = X25519(device_sk, envelope.eph_pk). - Derive
sym = HKDF-SHA256(shared, salt=envelope_id, info="oc-lock v0"). - Decrypt:
AES-256-GCM(sym, nonce, ciphertext, aad=envelope_id). - Verify the sender's BIP-322 signature over the envelope's canonical representation. Reject if invalid.
On success, the recipient gets the plaintext + a verified sender address.
Why ephemeral sender keys
Each envelope has a fresh (eph_sk, eph_pk) pair. The sender discards eph_sk
after sealing. Consequences:
- Forward secrecy between envelopes. If the sender's device is later compromised, old envelopes they sent can't be decrypted (the ephemeral secrets are gone).
- No sender-key rotation needed. The sender's Bitcoin address is the long-lived identifier; the X25519 key is throwaway.
There is NOT forward secrecy within a single envelope — if the recipient's
device_sk is compromised, every envelope sent to that device_id is
decryptable. Rotate device keys if you need that guarantee.
What's next
- Envelope format — the exact JSON shape, canonicalization rule, fields a validator must check
- Device keys — generation, binding, revocation, rotation
- Security — OC Lock specifics — envelope replay, compromised device recovery, forward secrecy tradeoffs