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)
dtag: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) andbinding_sig(the base64 BIP-322 signature). Tags are an index; a reader trusts only values that match the signed statement, which is whatparseDeviceEventchecks - author pubkey: derived deterministically from
device_skvia 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:
- Generate a new device keypair locally.
- Publish the new binding event.
- Publish a revocation event for the old
device_id. - (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
- How it works — the encrypt/decrypt flow these keys feed into
- Envelope format — how recipient device keys show up in the envelope
- Nostr kind-30078 — where directory events live
- Security model — device-compromise threat model