oc · docs
docs / device keys

Device keys

A device key is an X25519 keypair that represents one client device (a browser, a phone, a desktop) belonging to the holder of a specific Bitcoin address. Multiple devices can bind to the same address; envelopes are wrapped to every bound device so each can decrypt independently.

Generate

import { generateDeviceKey } from '@orangecheck/lock-device';

const {
    device_sk, // 32-byte X25519 secret — never leaves this machine
    device_pk, // 32-byte X25519 public key
    device_id, // 16 random bytes, hex
    created_at, // ISO timestamp
} = generateDeviceKey();

Store device_sk in whatever local secure storage your client provides (IndexedDB + WebCrypto non-extractable keys in browsers; SecKeyCreateRandomKey on iOS; AndroidKeystore on Android).

Bind to a Bitcoin address

import {
    buildBindingStatement,
    deriveNostrKey,
} from '@orangecheck/lock-device';

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

const signature = await walletSignBIP322(statement); // base64

The statement is a canonical message with header oc-lock:device-bind:v3. It binds (address, device_id, device_pk, nostr_pk) together — a verifier who sees the signature and the statement knows that bc1qbob… has attested that this device_pk is theirs, and which Nostr key may publish it. Omitting nostr_pk produces the older v2 statement, which does not sign the publishing key; readers trust a v2 record's author only while no other key carries the same device (authorizedDevices).

Publish to the Nostr directory

import { finalizeDeviceEvent } from '@orangecheck/lock-device';
import { DEFAULT_RELAYS, publishEvent } from '@orangecheck/nostr-core';

const event = finalizeDeviceEvent({
    deviceSk: device_sk,
    address: 'bc1qbob...',
    device_id,
    device_pk,
    bindingStatement: statement,
    bindingSigBase64: signature,
});

const results = await publishEvent(event, DEFAULT_RELAYS); // one { relay, ok } per relay

@orangecheck/lock-device builds and signs the event; it does no networking. Publish it with any Nostr client — @orangecheck/nostr-core is the family's.

The event:

  • kind: 30078 (NIP-78 addressable replaceable)
  • d tag: oc-lock:device:<bc1qbob...> — one device record per Bitcoin address per pubkey
  • content: the binding statement itself, exactly the bytes the wallet signed
  • tags: addr, device_id, device_pk, alg (x25519) and binding_sig (the base64 BIP-322 signature). Tags are an index; a reader trusts only values that match the signed statement, which is what parseDeviceEvent checks
  • author pubkey: derived deterministically from device_sk via HKDF, so the same device always publishes under the same Nostr pubkey without needing to manage a separate Nostr keypair

Replacing the event (same d tag) updates the device record. Useful when rotating keys or fixing a mistake.

Multiple devices per address

Each device publishes its own kind-30078 event. They share the d tag oc-lock:device:bc1qbob... but not an author: every device signs with its own derived Nostr key, so each keeps its own replaceable slot.

The sender queries all events matching #d:oc-lock:device:bc1qbob… and wraps the envelope for every device it finds. Each device can independently decrypt.

Revocation

Publish a revocation event:

import {
    buildRevocationStatement,
    finalizeDeviceEvent,
} from '@orangecheck/lock-device';
import { publishEvent } from '@orangecheck/nostr-core';

const revocation = buildRevocationStatement({
    address: 'bc1qbob...',
    device_id: oldDeviceId,
    revoked_at: new Date().toISOString(),
});

const sig = await walletSignBIP322(revocation);

// Same d tag, signed by the old device's key, so it replaces that device's record.
await publishEvent(
    finalizeDeviceEvent({
        deviceSk: oldDeviceSk,
        address: 'bc1qbob...',
        device_id: oldDeviceId,
        device_pk: 'revoked',
        bindingStatement: revocation,
        bindingSigBase64: sig,
    })
);

A recipient finding both a binding and a revocation for the same (address, device_id) pair MUST treat the device as revoked and refuse to use that device_pk for future envelopes.

Limitation: Nostr doesn't guarantee all relays have seen the revocation. For anything high-stakes, query multiple relays for the most recent event and fail-closed if any relay disagrees.

Rotation

To rotate a device key cleanly:

  1. Generate a new device keypair locally.
  2. Publish the new binding event.
  3. Publish a revocation event for the old device_id.
  4. (Optional) Re-decrypt historical envelopes with the old key, then securely wipe the old device_sk.

For forward secrecy guarantees (old envelopes unrecoverable if old key leaks later), don't keep the old device_sk after rotation.

Deterministic Nostr key derivation

The directory event is authored by a Nostr pubkey derived from the device secret via HKDF:

nostr_sk = HKDF-SHA256(device_sk, salt="oc-lock/v2/nostr-key", info="nostr-sk", length=32)
nostr_pk = schnorr.getPublicKey(nostr_sk)   // BIP-340 x-only

deriveNostrKey(device_sk) in @orangecheck/lock-device returns both.

This lets the same device publish under the same Nostr pubkey across sessions without the user managing a separate Nostr keypair. It also prevents one device from spoofing the directory record of another (different device secrets → different Nostr pubkeys).

See also