Device keys
A device key is a long-lived X25519 keypair bound to a Bitcoin identity by a BIP-322 signature. The secret half lives in one browser; the public half is published so any sender can encrypt to you. One click at registration, reusable forever.
Generation
(device_sk, device_pk) := x25519_keygen()
device_id := random 16 bytes (hex)
created_at := iso8601 utc
device_sk MUST stay on the device. The reference client stores it in IndexedDB; a more paranoid client can wrap it under a user passphrase via PBKDF2 + AES-GCM (see Encryption at rest below).
Binding statement
The wallet signs this exact byte sequence — one LF per line, no extra whitespace:
oc-lock:device-bind:v2
address: <btc_address>
device_pk: <hex(device_pk)>
device_id: <device_id>
created_at: <iso8601 utc>
binding_sig := BIP322(btc_address, binding_statement).
Conforming verifiers reconstruct the statement byte-for-byte and check binding_sig against btc_address. Any discrepancy — stray whitespace, capitalization, reordered fields — fails verification.
Publication
The binding is published as a Nostr addressable event (kind 30078). Addressable events (NIP-33) let the same (author, kind, d-tag) tuple replace prior events, which is exactly what we want for key rotation.
kind: 30078
tags: [
["d", "oc-lock:device:<btc_address>"],
["addr", "<btc_address>"],
["device_id", "<device_id>"],
["device_pk", "<hex(device_pk)>"],
["alg", "x25519"],
["binding_sig", "<base64(binding_sig)>"]
]
content: <exact binding_statement bytes, including LFs>
pubkey: <ephemeral nostr pubkey — see below>
created_at: <unix seconds>
Why addressable kind 30078? It's in the Nostr "addressable parameterized replaceable" range (30000-39999 per NIP-33). A new event with the same d tag atomically replaces the previous one at conforming relays — ideal for rotation and revocation. 30078 is unreserved and we claim the oc-lock:device:* namespace here.
Nostr authorship — a small trick
The Nostr event needs a pubkey and Schnorr signature. But we don't want to require users to manage a Nostr keypair — the authenticity proof is the embedded BIP-322 signature, not the Nostr pubkey.
The workaround: derive the Nostr signing key deterministically from the device secret.
nostr_sk := HKDF(ikm = device_sk, salt = "oc-lock/v2/nostr-key", info = "nostr-sk", L = 32)
nostr_pk := schnorr_pubkey(nostr_sk)
Same device key → same Nostr pubkey across relays. Different device → different Nostr pubkey, independent publication. The device record is self-authenticating; the Nostr layer is just a discovery substrate.
Discovery
A sender looks up a recipient by Bitcoin address:
REQ { "kinds": [30078], "#d": ["oc-lock:device:bc1q…"] }
The relay returns any matching event. The sender verifies the embedded binding_sig against bc1q… before encrypting. If the BIP-322 check fails, refuse to encrypt — a malicious relay serving forged events cannot produce a valid BIP-322 signature for an address they don't control, so the verification is the whole trust anchor.
Rotation
Rotation is identical to registration but the new event's d tag matches the old one, so conforming relays replace rather than append:
- Generate a new
(device_sk, device_pk)with a newdevice_id. - Sign a fresh binding statement.
- Publish the new kind-30078 event.
Pending envelopes addressed to the old device_pk remain decryptable only if you still hold old_device_sk. Otherwise they're inaccessible from the new device. Rotate only when you're ready to let go of in-flight messages, or when you believe a device is compromised.
Revocation
When a device is lost or compromised, publish a revocation. The statement format is parallel to the binding:
oc-lock:device-revoke:v2
address: <btc_address>
device_id: <device_id>
revoked_at: <iso8601 utc>
Ideally the revocation is signed by the same Bitcoin address via BIP-322 (from another device, if one exists). If no wallet is available, the revocation can be published unsigned — conforming senders treat it as advisory. Either way, the kind-30078 event has device_pk: "revoked" so senders learn to stop encrypting to that key.
tags: [
["d", "oc-lock:device:<btc_address>"],
["addr", "<btc_address>"],
["device_id", "<device_id>"],
["device_pk", "revoked"],
["binding_sig", "<base64(revocation_sig) | empty>"]
]
content: <revocation_statement>
Conforming senders MUST refuse to encrypt to a record where device_pk === "revoked".
Multi-device
Publish one record per device. Use a compound d tag:
oc-lock:device:<btc_address>:<device_id>
Senders fetching the address get all active device records and encrypt once per device. Each device decrypts from its own entry in recipients[]. Losing one device doesn't lose the others' vaults.
Export and import
Device secrets can be exported as JSON for migration:
{
"$schema": "oc-lock/device-export/v1",
"exported_at": "2026-04-22T…",
"device": {
"address": "bc1q…",
"device_id": "…",
"device_pk": "…",
"device_sk_b64url": "…", // base64url of the 32-byte secret
"created_at": "…",
"binding_statement": "oc-lock:device-bind:v2\naddress: …\n…",
"binding_sig_base64": "…",
"published": ["wss://…"]
}
}
Plaintext exports must be treated like a wallet backup. Anyone with the file can decrypt every envelope addressed to that device.
The web client offers two export flavours at the device manager:
- Plaintext — the v1 JSON described above. Convenient for piping into other tools, dangerous at rest on disk.
- Passphrase-encrypted (
oc-lock/device-export/v2) — the v1 JSON bytes are wrapped under a passphrase-derived key (same PBKDF2 + AES-GCM used by the device-lock flow). The exported file reveals onlyaddressin plaintext; everything else requires the passphrase to unlock. Safe at rest; the right default for backups.
Import is the inverse: drop a JSON file on the device manager. The manager auto-detects v1 vs v2 and prompts for a passphrase when needed.
Encryption at rest
The device secret sits unwrapped in IndexedDB by default. A "lock this device" flow wraps it under a user-chosen passphrase via PBKDF2-SHA256 (600,000 iterations, the OWASP 2023 baseline) + AES-256-GCM; unlocking requires the passphrase. This is opt-in because always-on passphrase prompts defeat the "one click" UX promise — but for long-lived devices or shared laptops it's the right tradeoff.
A passphrase-encrypted device record stores the per-vault iteration count alongside the ciphertext, so old vaults remain readable after the protocol-wide default is raised. The alg identifier is versioned (pbkdf2-sha256-aes256gcm/v1) so a future parameter family change (e.g. switch to argon2id) produces a distinct tag rather than silently changing semantics.
Compliance checklist
A conforming device-key implementation MUST:
- Generate
device_skvia X25519 keypair generation; never log or transmit it. - Build binding statements byte-for-byte per the canonical LF-terminated format.
- Publish kind-30078 with all six required tags (
d,addr,device_id,device_pk,alg,binding_sig). - Derive Nostr authorship from HKDF(device_sk, salt="oc-lock/v2/nostr-key") for determinism.
- Replace rather than append on rotation (same
dtag). - Refuse to encrypt to any record where
device_pk === "revoked". - Verify
binding_sigagainstaddrbefore using the record to encrypt. This is load-bearing: without it, a hostile Nostr relay can substitute an attacker'sdevice_pkfor the recipient's real one and the sender encrypts to the attacker. The reference web client runs every fetched record through a BIP-322 verifier and rejects any record whose signature doesn't match the claimed address.
See the specification §3 for the normative rules and SECURITY.md for the threat model.