docs / device keys
docs/device keys

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:

  1. Generate a new (device_sk, device_pk) with a new device_id.
  2. Sign a fresh binding statement.
  3. 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:

  1. Plaintext — the v1 JSON described above. Convenient for piping into other tools, dangerous at rest on disk.
  2. 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 only address in 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_sk via 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 d tag).
  • Refuse to encrypt to any record where device_pk === "revoked".
  • Verify binding_sig against addr before using the record to encrypt. This is load-bearing: without it, a hostile Nostr relay can substitute an attacker's device_pk for 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.