docs / quickstart
docs/quickstart

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:

  1. Recomputes the envelope id and verifies the sender's BIP-322 signature.
  2. Looks up your local device key by device_id.
  3. Derives shared = X25519(device_sk, eph_pk) from the recipient entry.
  4. Unwraps the content key under HKDF(shared, salt=nonce_ct).
  5. 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