oc · docs
docs / how it works

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-bind:v3
address: bc1qbob...
device_pk: <32-byte X25519 public key, hex>
nostr_pk: <the device's derived Nostr pubkey, hex>
device_id: <16 random bytes, hex>
created_at: 2026-04-24T06:47:29.977Z

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,
    deriveNostrKey,
    generateDeviceKey,
} from '@orangecheck/lock-device';

const { device_sk, device_pk, device_id, created_at } = generateDeviceKey();

const statement = buildBindingStatement({
    address: 'bc1qbob...',
    device_pk,
    nostr_pk: deriveNostrKey(device_sk).nostrPk,
    device_id,
    created_at,
});

const signature = await walletSignBIP322(statement);
// Sign and publish as a kind-30078 event: see Device keys

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 {
    authorizedDevices,
    DEVICE_KIND,
    parseDeviceEvent,
} from '@orangecheck/lock-device';
import { queryEvents } from '@orangecheck/nostr-core';

const { events } = await queryEvents({
    kinds: [DEVICE_KIND],
    '#d': ['oc-lock:device:bc1qbob...'],
});
const verified = [];
for (const event of events) {
    let record;
    try {
        record = parseDeviceEvent(event); // throws if tags disagree with the signed statement
    } catch {
        continue;
    }
    if (
        await verifyBip322(
            record.bindingStatement,
            record.bindingSigBase64,
            record.address
        )
    ) {
        verified.push(record);
    }
}
const devices = authorizedDevices(verified); // drops revoked and ambiguous records
// Each device.device_pk is a 32-byte X25519 public key we'll encrypt to.

The sender MUST verify each record's BIP-322 signature before encrypting to it; a record whose signature fails is forged. authorizedDevices expects records that already passed that check.

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: devices.map((d) => ({
        address: d.address,
        device_id: d.device_id,
        device_pk: d.device_pk,
    })),
});

Inside seal():

  1. Generate an ephemeral X25519 keypair (eph_sk, eph_pk) for this envelope.
  2. For each recipient, compute shared = X25519(eph_sk, recipient.device_pk).
  3. Derive the symmetric key: sym = HKDF-SHA256(shared, salt=envelope_id, info="oc-lock v0").
  4. Encrypt the payload: AES-256-GCM(sym, nonce, plaintext, aad=envelope_id).
  5. Wrap the ciphertext + ephemeral public key + per-recipient metadata in a canonicalized JSON envelope.
  6. 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():

  1. Find the recipient entry matching the local device_id.
  2. Recompute the shared secret: shared = X25519(device_sk, envelope.eph_pk).
  3. Derive sym = HKDF-SHA256(shared, salt=envelope_id, info="oc-lock v0").
  4. Decrypt: AES-256-GCM(sym, nonce, ciphertext, aad=envelope_id).
  5. 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