Quickstart
Three things to do — in order, once. After this, sending and receiving are one click.
1. Register your device
Visit /app in a browser, enter your Bitcoin address, pick your wallet (UniSat, Xverse, Leather — or paste a signature from any signmessage-capable wallet), and sign the binding statement. The browser stores a 32-byte X25519 secret in IndexedDB and publishes a public kind-30078 event to four Nostr relays.
You never do this step again for this address in this browser.
2. Seal a message
On /app, open the compose tab. Enter the recipient's Bitcoin address, click lookup — the app fetches their device record from Nostr and verifies the BIP-322 binding. Type your message. Click seal.
You get back a share URL. The envelope is encoded in the URL fragment, so it never touches a server.
https://oc-lock-web.vercel.app/unlock#eyJ2Ijoy…
Share the URL through any channel: Signal, email, paper QR code, carrier pigeon.
3. Receive a message
Open the share URL. The app:
- Recomputes the envelope id and verifies the sender's BIP-322 signature.
- Looks up your local device key by
device_id. - Derives
shared = X25519(device_sk, eph_pk)from the recipient entry. - Unwraps the content key under
HKDF(shared, salt=nonce_ct). - Decrypts the payload with AES-256-GCM.
Total on-screen time: < 3 seconds.
4. Chat (optional)
The third tab in /app is chat — the same envelope primitive applied continuously over a long-lived Nostr subscription. Open it, click + new, paste a counterpart's Bitcoin address, and start sending. Messages arrive without a share URL; both sides keep local scrollback keyed by a deterministic thread id. Same crypto, same wallet, same device — no extra onboarding. See chat transport for the full rationale and trust model.
Using the SDK
If you want to embed OC Lock in another app — Node, a browser extension, a Nostr client — the three packages are published from the oc-packages monorepo.
yarn add @orangecheck/lock-core @orangecheck/lock-crypto @orangecheck/lock-device
Seal:
import { seal } from '@orangecheck/lock-core/seal';
import { utf8Encode } from '@orangecheck/lock-crypto';
const envelope = await seal({
payload: utf8Encode('hi bob'),
sender: {
address: 'bc1qalice…',
signMessage: async (msg) => walletSignBIP322(msg),
},
recipients: [
{ address: 'bc1qbob…', device_id: '…', device_pk: '…' },
],
});
Unseal:
import { unseal } from '@orangecheck/lock-core/seal';
const result = await unseal({
envelope,
device: { device_id: '…', secretKey: localDeviceSecret },
verifyBip322: async (msg, sig, addr) => myVerifier(msg, sig, addr),
});
const plaintext = new TextDecoder().decode(result.payload);
Device-key lifecycle:
import {
buildBindingStatement,
finalizeDeviceEvent,
generateDeviceKey,
} from '@orangecheck/lock-device';
const kp = generateDeviceKey();
const statement = buildBindingStatement({
address: btcAddress,
device_pk: kp.device_pk,
device_id: kp.device_id,
created_at: kp.created_at,
});
const signature = await wallet.signBIP322(statement);
const event = finalizeDeviceEvent({
deviceSk: kp.device_sk,
address: btcAddress,
device_id: kp.device_id,
device_pk: kp.device_pk,
bindingStatement: statement,
bindingSigBase64: signature,
});
// publish `event` to Nostr relays of your choice
Layered with OrangeCheck
Need a sybil filter on your inbox? Gate unseal on an OrangeCheck check:
import { check } from '@orangecheck/sdk';
import { unseal } from '@orangecheck/lock-core/seal';
const ok = await check({
addr: envelope.from.address,
minSats: 100_000,
minDays: 30,
});
if (!ok.ok) throw new Error('stake too low');
const out = await unseal({ envelope, device, verifyBip322 });
Next
- Protocol walkthrough — narrative version, flow diagrams.
- Specification — normative rules, canonicalization, error codes.
- FAQ — common questions.