@orangecheck/lock-device 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,8 @@ This package is WebCrypto-free: it works in Node and in the browser.
24
24
  ## Exports
25
25
 
26
26
  - `generateDeviceKey()` — new `(device_sk, device_pk, device_id, created_at)`.
27
- - `buildBindingStatement({ address, device_pk, device_id, created_at })` — exact bytes per SPEC §3.2.
27
+ - `buildBindingStatement({ address, device_pk, device_id, created_at, nostr_pk? })` — exact bytes per SPEC §3.2 (v3 when `nostr_pk` is given).
28
+ - `authorizedDevices(records)` — the verified records whose Nostr pubkey may act for their address (SPEC §3.4).
28
29
  - `buildRevocationStatement({ address, device_id, revoked_at })` — for explicit revocation.
29
30
  - `deriveNostrKey(deviceSk)` — deterministic `nostr_sk` from `device_sk` (HKDF).
30
31
  - `finalizeDeviceEvent({ ... bindingSigBase64, ... })` — returns a fully signed kind-30078 `NostrEvent`.
package/dist/index.d.mts CHANGED
@@ -1,5 +1,8 @@
1
+ declare function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[];
2
+
1
3
  declare const DEVICE_KIND = 30078;
2
4
  declare const BINDING_VERSION = "v2";
5
+ declare const BINDING_VERSION_V3 = "v3";
3
6
  interface DeviceKeyPair {
4
7
  device_id: string;
5
8
  device_pk: string;
@@ -12,6 +15,7 @@ declare function buildBindingStatement(params: {
12
15
  device_pk: string;
13
16
  device_id: string;
14
17
  created_at: string;
18
+ nostr_pk?: string;
15
19
  }): string;
16
20
  declare function buildRevocationStatement(params: {
17
21
  address: string;
@@ -62,7 +66,8 @@ interface ParsedDeviceEvent {
62
66
  createdAtUnix: number;
63
67
  nostrPubkey: string;
64
68
  eventId: string;
69
+ bindingVersion: 'v2' | 'v3';
65
70
  }
66
71
  declare function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent;
67
72
 
68
- export { BINDING_VERSION, DEVICE_KIND, type DeviceKeyPair, type NostrEvent, type ParsedDeviceEvent, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
73
+ export { BINDING_VERSION, BINDING_VERSION_V3, DEVICE_KIND, type DeviceKeyPair, type NostrEvent, type ParsedDeviceEvent, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
+ declare function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[];
2
+
1
3
  declare const DEVICE_KIND = 30078;
2
4
  declare const BINDING_VERSION = "v2";
5
+ declare const BINDING_VERSION_V3 = "v3";
3
6
  interface DeviceKeyPair {
4
7
  device_id: string;
5
8
  device_pk: string;
@@ -12,6 +15,7 @@ declare function buildBindingStatement(params: {
12
15
  device_pk: string;
13
16
  device_id: string;
14
17
  created_at: string;
18
+ nostr_pk?: string;
15
19
  }): string;
16
20
  declare function buildRevocationStatement(params: {
17
21
  address: string;
@@ -62,7 +66,8 @@ interface ParsedDeviceEvent {
62
66
  createdAtUnix: number;
63
67
  nostrPubkey: string;
64
68
  eventId: string;
69
+ bindingVersion: 'v2' | 'v3';
65
70
  }
66
71
  declare function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent;
67
72
 
68
- export { BINDING_VERSION, DEVICE_KIND, type DeviceKeyPair, type NostrEvent, type ParsedDeviceEvent, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
73
+ export { BINDING_VERSION, BINDING_VERSION_V3, DEVICE_KIND, type DeviceKeyPair, type NostrEvent, type ParsedDeviceEvent, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
package/dist/index.js CHANGED
@@ -3,9 +3,34 @@
3
3
  var secp256k1 = require('@noble/curves/secp256k1');
4
4
  var lockCrypto = require('@orangecheck/lock-crypto');
5
5
 
6
+ // src/index.ts
7
+
8
+ // src/authorize.ts
9
+ function authorizedDevices(records) {
10
+ const v3Ids = /* @__PURE__ */ new Set();
11
+ const v2Pubkeys = /* @__PURE__ */ new Map();
12
+ for (const r of records) {
13
+ if (r.revoked) continue;
14
+ if (r.bindingVersion === "v3") {
15
+ v3Ids.add(r.device_id);
16
+ } else {
17
+ const set = v2Pubkeys.get(r.device_id) ?? /* @__PURE__ */ new Set();
18
+ set.add(r.nostrPubkey);
19
+ v2Pubkeys.set(r.device_id, set);
20
+ }
21
+ }
22
+ return records.filter((r) => {
23
+ if (r.revoked) return false;
24
+ if (r.bindingVersion === "v3") return true;
25
+ if (v3Ids.has(r.device_id)) return false;
26
+ return v2Pubkeys.get(r.device_id)?.size === 1;
27
+ });
28
+ }
29
+
6
30
  // src/index.ts
7
31
  var DEVICE_KIND = 30078;
8
32
  var BINDING_VERSION = "v2";
33
+ var BINDING_VERSION_V3 = "v3";
9
34
  function generateDeviceKey() {
10
35
  const kp = lockCrypto.generateX25519KeyPair();
11
36
  const deviceIdBytes = lockCrypto.randomBytesN(16);
@@ -17,7 +42,14 @@ function generateDeviceKey() {
17
42
  };
18
43
  }
19
44
  function buildBindingStatement(params) {
20
- const lines = [
45
+ const lines = params.nostr_pk ? [
46
+ `oc-lock:device-bind:${BINDING_VERSION_V3}`,
47
+ `address: ${params.address}`,
48
+ `device_pk: ${params.device_pk}`,
49
+ `nostr_pk: ${params.nostr_pk}`,
50
+ `device_id: ${params.device_id}`,
51
+ `created_at: ${params.created_at}`
52
+ ] : [
21
53
  `oc-lock:device-bind:${BINDING_VERSION}`,
22
54
  `address: ${params.address}`,
23
55
  `device_pk: ${params.device_pk}`,
@@ -115,13 +147,19 @@ function parseDeviceEvent(event) {
115
147
  }
116
148
  const signed = parseBindingStatement(event.content);
117
149
  if (!signed) {
118
- throw new Error("device event content is not a v2 bind or revoke statement");
150
+ throw new Error("device event content is not a v2/v3 bind or revoke statement");
119
151
  }
120
152
  if (signed.address !== address) {
121
153
  throw new Error(
122
154
  `device event addr tag (${address}) does not match the signed statement (${signed.address})`
123
155
  );
124
156
  }
157
+ const d = tag("d");
158
+ if (d !== `oc-lock:device:${signed.address}` && d !== `oc-lock:device:${signed.address}:${signed.device_id}`) {
159
+ throw new Error(
160
+ `device event d tag (${d}) is not the slot for the signed address (${signed.address})`
161
+ );
162
+ }
125
163
  if (signed.device_id !== device_id) {
126
164
  throw new Error(
127
165
  `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`
@@ -133,6 +171,11 @@ function parseDeviceEvent(event) {
133
171
  `device event device_pk tag does not match the signed statement \u2014 tag says ${device_pk}, the signature covers ${signed.device_pk}`
134
172
  );
135
173
  }
174
+ if (signed.nostr_pk !== null && signed.nostr_pk !== event.pubkey) {
175
+ throw new Error(
176
+ `device event pubkey (${event.pubkey}) does not match the signed nostr_pk (${signed.nostr_pk})`
177
+ );
178
+ }
136
179
  } else if (device_pk !== "revoked") {
137
180
  throw new Error(
138
181
  `device event carries a revocation statement but its device_pk tag is ${device_pk}, not "revoked"`
@@ -148,7 +191,8 @@ function parseDeviceEvent(event) {
148
191
  revoked: signed.kind === "revoke",
149
192
  createdAtUnix: event.created_at,
150
193
  nostrPubkey: event.pubkey,
151
- eventId: event.id
194
+ eventId: event.id,
195
+ bindingVersion: signed.kind === "bind" && signed.nostr_pk !== null ? "v3" : "v2"
152
196
  };
153
197
  }
154
198
  function parseBindingStatement(content) {
@@ -167,7 +211,13 @@ function parseBindingStatement(content) {
167
211
  if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {
168
212
  const device_pk = field("device_pk");
169
213
  if (!device_pk) return null;
170
- return { kind: "bind", address, device_pk, device_id };
214
+ return { kind: "bind", address, device_pk, device_id, nostr_pk: null };
215
+ }
216
+ if (header === `oc-lock:device-bind:${BINDING_VERSION_V3}`) {
217
+ const device_pk = field("device_pk");
218
+ const nostr_pk = field("nostr_pk");
219
+ if (!device_pk || !nostr_pk || !/^[0-9a-f]{64}$/.test(nostr_pk)) return null;
220
+ return { kind: "bind", address, device_pk, device_id, nostr_pk };
171
221
  }
172
222
  if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {
173
223
  return { kind: "revoke", address, device_id };
@@ -176,7 +226,9 @@ function parseBindingStatement(content) {
176
226
  }
177
227
 
178
228
  exports.BINDING_VERSION = BINDING_VERSION;
229
+ exports.BINDING_VERSION_V3 = BINDING_VERSION_V3;
179
230
  exports.DEVICE_KIND = DEVICE_KIND;
231
+ exports.authorizedDevices = authorizedDevices;
180
232
  exports.buildBindingStatement = buildBindingStatement;
181
233
  exports.buildDeviceEvent = buildDeviceEvent;
182
234
  exports.buildRevocationStatement = buildRevocationStatement;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["generateX25519KeyPair","randomBytesN","hexEncode","hkdfSha256","utf8Encode","schnorr","hexDecode","sha256Bytes"],"mappings":"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","file":"index.js","sourcesContent":["// Device key binding and Nostr directory publication.\n// See SPEC.md §3.\n\nimport { schnorr } from '@noble/curves/secp256k1';\nimport {\n generateX25519KeyPair,\n hexDecode,\n hexEncode,\n hkdfSha256,\n randomBytesN,\n sha256Bytes,\n utf8Encode,\n} from '@orangecheck/lock-crypto';\n\nexport const DEVICE_KIND = 30078;\nexport const BINDING_VERSION = 'v2';\n\nexport interface DeviceKeyPair {\n device_id: string;\n device_pk: string; // 32-byte hex\n device_sk: Uint8Array; // 32 bytes\n created_at: string; // iso8601\n}\n\nexport function generateDeviceKey(): DeviceKeyPair {\n const kp = generateX25519KeyPair();\n const deviceIdBytes = randomBytesN(16);\n return {\n device_id: hexEncode(deviceIdBytes),\n device_pk: hexEncode(kp.public),\n device_sk: kp.secret,\n created_at: new Date().toISOString(),\n };\n}\n\nexport function buildBindingStatement(params: {\n address: string;\n device_pk: string;\n device_id: string;\n created_at: string;\n}): string {\n // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF\n // beyond the last field's. Order is fixed.\n const lines = [\n `oc-lock:device-bind:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\nexport function buildRevocationStatement(params: {\n address: string;\n device_id: string;\n revoked_at: string;\n}): string {\n const lines = [\n `oc-lock:device-revoke:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_id: ${params.device_id}`,\n `revoked_at: ${params.revoked_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\n// ---------------------------------------------------------------------------\n// Nostr event helpers\n// ---------------------------------------------------------------------------\n\nexport interface NostrEvent {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/**\n * Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.\n * Returns a 32-byte private key (hex).\n */\nexport function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {\n const sk = hkdfSha256(\n deviceSk,\n utf8Encode('oc-lock/v2/nostr-key'),\n utf8Encode('nostr-sk'),\n 32\n );\n const pk = schnorr.getPublicKey(sk);\n return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };\n}\n\n/**\n * Build a kind-30078 addressable event announcing a device binding.\n * The caller must supply the BIP-322 signature produced by the user's Bitcoin\n * wallet (we cannot sign BIP-322 in this package — that requires a wallet).\n */\nexport function buildDeviceEvent(params: {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {\n const tags: string[][] = [\n ['d', `oc-lock:device:${params.address}`],\n ['addr', params.address],\n ['device_id', params.device_id],\n ['device_pk', params.device_pk],\n ['alg', 'x25519'],\n ['binding_sig', params.bindingSigBase64],\n ];\n const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);\n return {\n kind: DEVICE_KIND,\n tags,\n content: params.bindingStatement,\n created_at,\n } as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };\n}\n\n/**\n * Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces\n * the full NostrEvent with id and sig.\n */\nexport function signNostrEvent(\n event: Omit<NostrEvent, 'id' | 'sig'>,\n nostrSkHex: string\n): NostrEvent {\n const id = computeNostrEventId(event);\n const sk = hexDecode(nostrSkHex);\n const sig = schnorr.sign(hexDecode(id), sk);\n return {\n ...event,\n id,\n sig: hexEncode(sig),\n };\n}\n\nexport function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {\n // Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).\n // The array must be serialized in *exactly* this form with no whitespace.\n const serialized = JSON.stringify([\n 0,\n event.pubkey,\n event.created_at,\n event.kind,\n event.tags,\n event.content,\n ]);\n return hexEncode(sha256Bytes(utf8Encode(serialized)));\n}\n\n/**\n * Finalize a device event: derive Nostr key from device_sk, attach pubkey,\n * compute id, sign. Returns a fully-signed NIP-01 event ready to publish.\n */\nexport function finalizeDeviceEvent(params: {\n deviceSk: Uint8Array;\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): NostrEvent {\n const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);\n const base = buildDeviceEvent({\n address: params.address,\n device_id: params.device_id,\n device_pk: params.device_pk,\n bindingStatement: params.bindingStatement,\n bindingSigBase64: params.bindingSigBase64,\n createdAtUnix: params.createdAtUnix,\n });\n const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;\n return signNostrEvent(withPubkey, nostrSk);\n}\n\n// ---------------------------------------------------------------------------\n// Parsing\n// ---------------------------------------------------------------------------\n\nexport interface ParsedDeviceEvent {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n revoked: boolean;\n createdAtUnix: number;\n nostrPubkey: string;\n eventId: string;\n}\n\nexport function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {\n if (event.kind !== DEVICE_KIND) {\n throw new Error('not a device event (kind mismatch)');\n }\n const tag = (name: string): string | null => {\n for (const t of event.tags) {\n if (t[0] === name && t.length > 1) return t[1] ?? null;\n }\n return null;\n };\n const address = tag('addr');\n const device_id = tag('device_id');\n const device_pk = tag('device_pk');\n const binding_sig = tag('binding_sig');\n if (!address || !device_id || !device_pk || !binding_sig) {\n throw new Error('device event missing required tags');\n }\n\n // The BIP-322 signature covers `event.content` — the statement — and nothing\n // else. Tags are an index the relay lets anyone write, so a value read from a\n // tag has been authenticated by nobody.\n //\n // Skipping this check was a total confidentiality break, and it needed no key\n // material: republish a victim's device event with their real content and\n // real binding_sig, change only the `device_pk` TAG to your own X25519 key.\n // verifyBip322(content, sig, addr) passes — every signed byte is genuine —\n // and the sender then seals the content key to the attacker's key, because\n // the recipient record was built from the tag. Proven against the published\n // package before this existed.\n //\n // So the statement is the source of truth and the tags must agree with it.\n const signed = parseBindingStatement(event.content);\n if (!signed) {\n throw new Error('device event content is not a v2 bind or revoke statement');\n }\n if (signed.address !== address) {\n throw new Error(\n `device event addr tag (${address}) does not match the signed statement (${signed.address})`,\n );\n }\n if (signed.device_id !== device_id) {\n throw new Error(\n `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`,\n );\n }\n if (signed.kind === 'bind') {\n if (signed.device_pk !== device_pk) {\n throw new Error(\n `device event device_pk tag does not match the signed statement — ` +\n `tag says ${device_pk}, the signature covers ${signed.device_pk}`,\n );\n }\n } else if (device_pk !== 'revoked') {\n throw new Error(\n `device event carries a revocation statement but its device_pk tag is ${device_pk}, not \"revoked\"`,\n );\n }\n\n return {\n address: signed.address,\n device_id: signed.device_id,\n // Take the key from the SIGNED bytes, never the tag.\n device_pk: signed.kind === 'bind' ? signed.device_pk : 'revoked',\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: signed.kind === 'revoke',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n };\n}\n\n/**\n * Read back the fields the signature actually commits to. Returns null for\n * anything that is not a well-formed v2 bind/revoke statement, which the caller\n * treats as an unusable record.\n */\nfunction parseBindingStatement(\n content: string,\n):\n | { kind: 'bind'; address: string; device_pk: string; device_id: string }\n | { kind: 'revoke'; address: string; device_id: string }\n | null {\n const lines = content.split('\\n');\n const header = lines[0];\n const field = (name: string): string | null => {\n const prefix = `${name}: `;\n for (const l of lines.slice(1)) {\n if (l.startsWith(prefix)) return l.slice(prefix.length);\n }\n return null;\n };\n const address = field('address');\n const device_id = field('device_id');\n if (!address || !device_id) return null;\n\n if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {\n const device_pk = field('device_pk');\n if (!device_pk) return null;\n return { kind: 'bind', address, device_pk, device_id };\n }\n if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {\n return { kind: 'revoke', address, device_id };\n }\n return null;\n}\n"]}
1
+ {"version":3,"sources":["../src/authorize.ts","../src/index.ts"],"names":["generateX25519KeyPair","randomBytesN","hexEncode","hkdfSha256","utf8Encode","schnorr","hexDecode","sha256Bytes"],"mappings":"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","file":"index.js","sourcesContent":["// Which Nostr pubkeys a set of device records authorizes. See SPEC §3.4.\n\nimport type { ParsedDeviceEvent } from './index.js';\n\n/**\n * Select the device records whose `nostrPubkey` may act for their address.\n *\n * `records` MUST already be BIP-322-verified, non-revoked bind records for one\n * address. A v3 record signs its Nostr pubkey, so it is always authorized. A v2\n * record does not, so its pubkey is only the event author's claim: it is\n * authorized only while its `device_id` has no v3 record and appears under\n * exactly one Nostr pubkey. Two pubkeys carrying one v2 device authorize neither.\n */\nexport function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {\n const v3Ids = new Set<string>();\n const v2Pubkeys = new Map<string, Set<string>>();\n for (const r of records) {\n if (r.revoked) continue;\n if (r.bindingVersion === 'v3') {\n v3Ids.add(r.device_id);\n } else {\n const set = v2Pubkeys.get(r.device_id) ?? new Set<string>();\n set.add(r.nostrPubkey);\n v2Pubkeys.set(r.device_id, set);\n }\n }\n return records.filter((r) => {\n if (r.revoked) return false;\n if (r.bindingVersion === 'v3') return true;\n if (v3Ids.has(r.device_id)) return false;\n return v2Pubkeys.get(r.device_id)?.size === 1;\n });\n}\n","// Device key binding and Nostr directory publication.\n// See SPEC.md §3.\n\nimport { schnorr } from '@noble/curves/secp256k1';\nimport {\n generateX25519KeyPair,\n hexDecode,\n hexEncode,\n hkdfSha256,\n randomBytesN,\n sha256Bytes,\n utf8Encode,\n} from '@orangecheck/lock-crypto';\n\nexport const DEVICE_KIND = 30078;\nexport const BINDING_VERSION = 'v2';\n/** Bind statement version that also signs the device's Nostr inbox pubkey. */\nexport const BINDING_VERSION_V3 = 'v3';\n\nexport interface DeviceKeyPair {\n device_id: string;\n device_pk: string; // 32-byte hex\n device_sk: Uint8Array; // 32 bytes\n created_at: string; // iso8601\n}\n\nexport function generateDeviceKey(): DeviceKeyPair {\n const kp = generateX25519KeyPair();\n const deviceIdBytes = randomBytesN(16);\n return {\n device_id: hexEncode(deviceIdBytes),\n device_pk: hexEncode(kp.public),\n device_sk: kp.secret,\n created_at: new Date().toISOString(),\n };\n}\n\nexport function buildBindingStatement(params: {\n address: string;\n device_pk: string;\n device_id: string;\n created_at: string;\n /** The device's Nostr pubkey (deriveNostrKey). Present ⇒ a v3 statement. */\n nostr_pk?: string;\n}): string {\n // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF\n // beyond the last field's. Order is fixed.\n const lines = params.nostr_pk\n ? [\n `oc-lock:device-bind:${BINDING_VERSION_V3}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `nostr_pk: ${params.nostr_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ]\n : [\n `oc-lock:device-bind:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\nexport function buildRevocationStatement(params: {\n address: string;\n device_id: string;\n revoked_at: string;\n}): string {\n const lines = [\n `oc-lock:device-revoke:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_id: ${params.device_id}`,\n `revoked_at: ${params.revoked_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\n// ---------------------------------------------------------------------------\n// Nostr event helpers\n// ---------------------------------------------------------------------------\n\nexport interface NostrEvent {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/**\n * Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.\n * Returns a 32-byte private key (hex).\n */\nexport function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {\n const sk = hkdfSha256(\n deviceSk,\n utf8Encode('oc-lock/v2/nostr-key'),\n utf8Encode('nostr-sk'),\n 32\n );\n const pk = schnorr.getPublicKey(sk);\n return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };\n}\n\n/**\n * Build a kind-30078 addressable event announcing a device binding.\n * The caller must supply the BIP-322 signature produced by the user's Bitcoin\n * wallet (we cannot sign BIP-322 in this package — that requires a wallet).\n */\nexport function buildDeviceEvent(params: {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {\n const tags: string[][] = [\n ['d', `oc-lock:device:${params.address}`],\n ['addr', params.address],\n ['device_id', params.device_id],\n ['device_pk', params.device_pk],\n ['alg', 'x25519'],\n ['binding_sig', params.bindingSigBase64],\n ];\n const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);\n return {\n kind: DEVICE_KIND,\n tags,\n content: params.bindingStatement,\n created_at,\n } as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };\n}\n\n/**\n * Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces\n * the full NostrEvent with id and sig.\n */\nexport function signNostrEvent(\n event: Omit<NostrEvent, 'id' | 'sig'>,\n nostrSkHex: string\n): NostrEvent {\n const id = computeNostrEventId(event);\n const sk = hexDecode(nostrSkHex);\n const sig = schnorr.sign(hexDecode(id), sk);\n return {\n ...event,\n id,\n sig: hexEncode(sig),\n };\n}\n\nexport function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {\n // Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).\n // The array must be serialized in *exactly* this form with no whitespace.\n const serialized = JSON.stringify([\n 0,\n event.pubkey,\n event.created_at,\n event.kind,\n event.tags,\n event.content,\n ]);\n return hexEncode(sha256Bytes(utf8Encode(serialized)));\n}\n\n/**\n * Finalize a device event: derive Nostr key from device_sk, attach pubkey,\n * compute id, sign. Returns a fully-signed NIP-01 event ready to publish.\n */\nexport function finalizeDeviceEvent(params: {\n deviceSk: Uint8Array;\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): NostrEvent {\n const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);\n const base = buildDeviceEvent({\n address: params.address,\n device_id: params.device_id,\n device_pk: params.device_pk,\n bindingStatement: params.bindingStatement,\n bindingSigBase64: params.bindingSigBase64,\n createdAtUnix: params.createdAtUnix,\n });\n const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;\n return signNostrEvent(withPubkey, nostrSk);\n}\n\n// ---------------------------------------------------------------------------\n// Parsing\n// ---------------------------------------------------------------------------\n\nexport interface ParsedDeviceEvent {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n revoked: boolean;\n createdAtUnix: number;\n nostrPubkey: string;\n eventId: string;\n /** 'v3' binds signed the Nostr pubkey; 'v2' binds and revocations did not. */\n bindingVersion: 'v2' | 'v3';\n}\n\nexport function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {\n if (event.kind !== DEVICE_KIND) {\n throw new Error('not a device event (kind mismatch)');\n }\n const tag = (name: string): string | null => {\n for (const t of event.tags) {\n if (t[0] === name && t.length > 1) return t[1] ?? null;\n }\n return null;\n };\n const address = tag('addr');\n const device_id = tag('device_id');\n const device_pk = tag('device_pk');\n const binding_sig = tag('binding_sig');\n if (!address || !device_id || !device_pk || !binding_sig) {\n throw new Error('device event missing required tags');\n }\n\n // The BIP-322 signature covers `event.content` — the statement — and nothing\n // else. Tags are an index the relay lets anyone write, so a value read from a\n // tag has been authenticated by nobody.\n //\n // Skipping this check was a total confidentiality break, and it needed no key\n // material: republish a victim's device event with their real content and\n // real binding_sig, change only the `device_pk` TAG to your own X25519 key.\n // verifyBip322(content, sig, addr) passes — every signed byte is genuine —\n // and the sender then seals the content key to the attacker's key, because\n // the recipient record was built from the tag. Proven against the published\n // package before this existed.\n //\n // So the statement is the source of truth and the tags must agree with it.\n const signed = parseBindingStatement(event.content);\n if (!signed) {\n throw new Error('device event content is not a v2/v3 bind or revoke statement');\n }\n if (signed.address !== address) {\n throw new Error(\n `device event addr tag (${address}) does not match the signed statement (${signed.address})`,\n );\n }\n // SPEC §3.3 / §3.6: the record lives at `oc-lock:device:<addr>` or the\n // per-device `oc-lock:device:<addr>:<device_id>`. Lookups filter on `d`, so a\n // record for one address filed under another's slot must not parse.\n const d = tag('d');\n if (\n d !== `oc-lock:device:${signed.address}` &&\n d !== `oc-lock:device:${signed.address}:${signed.device_id}`\n ) {\n throw new Error(\n `device event d tag (${d}) is not the slot for the signed address (${signed.address})`,\n );\n }\n if (signed.device_id !== device_id) {\n throw new Error(\n `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`,\n );\n }\n if (signed.kind === 'bind') {\n if (signed.device_pk !== device_pk) {\n throw new Error(\n `device event device_pk tag does not match the signed statement — ` +\n `tag says ${device_pk}, the signature covers ${signed.device_pk}`,\n );\n }\n // A v3 statement names the one Nostr key allowed to carry it.\n if (signed.nostr_pk !== null && signed.nostr_pk !== event.pubkey) {\n throw new Error(\n `device event pubkey (${event.pubkey}) does not match the signed nostr_pk (${signed.nostr_pk})`,\n );\n }\n } else if (device_pk !== 'revoked') {\n throw new Error(\n `device event carries a revocation statement but its device_pk tag is ${device_pk}, not \"revoked\"`,\n );\n }\n\n return {\n address: signed.address,\n device_id: signed.device_id,\n // Take the key from the SIGNED bytes, never the tag.\n device_pk: signed.kind === 'bind' ? signed.device_pk : 'revoked',\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: signed.kind === 'revoke',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n bindingVersion: signed.kind === 'bind' && signed.nostr_pk !== null ? 'v3' : 'v2',\n };\n}\n\n/**\n * Read back the fields the signature actually commits to. Returns null for\n * anything that is not a well-formed v2/v3 bind or v2 revoke statement, which the caller\n * treats as an unusable record.\n */\nfunction parseBindingStatement(\n content: string,\n):\n | {\n kind: 'bind';\n address: string;\n device_pk: string;\n device_id: string;\n nostr_pk: string | null;\n }\n | { kind: 'revoke'; address: string; device_id: string }\n | null {\n const lines = content.split('\\n');\n const header = lines[0];\n const field = (name: string): string | null => {\n const prefix = `${name}: `;\n for (const l of lines.slice(1)) {\n if (l.startsWith(prefix)) return l.slice(prefix.length);\n }\n return null;\n };\n const address = field('address');\n const device_id = field('device_id');\n if (!address || !device_id) return null;\n\n if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {\n const device_pk = field('device_pk');\n if (!device_pk) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk: null };\n }\n if (header === `oc-lock:device-bind:${BINDING_VERSION_V3}`) {\n const device_pk = field('device_pk');\n const nostr_pk = field('nostr_pk');\n if (!device_pk || !nostr_pk || !/^[0-9a-f]{64}$/.test(nostr_pk)) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk };\n }\n if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {\n return { kind: 'revoke', address, device_id };\n }\n return null;\n}\n\nexport { authorizedDevices } from './authorize.js';\n"]}
package/dist/index.mjs CHANGED
@@ -1,9 +1,34 @@
1
1
  import { schnorr } from '@noble/curves/secp256k1';
2
2
  import { generateX25519KeyPair, randomBytesN, hexEncode, hkdfSha256, utf8Encode, hexDecode, sha256Bytes } from '@orangecheck/lock-crypto';
3
3
 
4
+ // src/index.ts
5
+
6
+ // src/authorize.ts
7
+ function authorizedDevices(records) {
8
+ const v3Ids = /* @__PURE__ */ new Set();
9
+ const v2Pubkeys = /* @__PURE__ */ new Map();
10
+ for (const r of records) {
11
+ if (r.revoked) continue;
12
+ if (r.bindingVersion === "v3") {
13
+ v3Ids.add(r.device_id);
14
+ } else {
15
+ const set = v2Pubkeys.get(r.device_id) ?? /* @__PURE__ */ new Set();
16
+ set.add(r.nostrPubkey);
17
+ v2Pubkeys.set(r.device_id, set);
18
+ }
19
+ }
20
+ return records.filter((r) => {
21
+ if (r.revoked) return false;
22
+ if (r.bindingVersion === "v3") return true;
23
+ if (v3Ids.has(r.device_id)) return false;
24
+ return v2Pubkeys.get(r.device_id)?.size === 1;
25
+ });
26
+ }
27
+
4
28
  // src/index.ts
5
29
  var DEVICE_KIND = 30078;
6
30
  var BINDING_VERSION = "v2";
31
+ var BINDING_VERSION_V3 = "v3";
7
32
  function generateDeviceKey() {
8
33
  const kp = generateX25519KeyPair();
9
34
  const deviceIdBytes = randomBytesN(16);
@@ -15,7 +40,14 @@ function generateDeviceKey() {
15
40
  };
16
41
  }
17
42
  function buildBindingStatement(params) {
18
- const lines = [
43
+ const lines = params.nostr_pk ? [
44
+ `oc-lock:device-bind:${BINDING_VERSION_V3}`,
45
+ `address: ${params.address}`,
46
+ `device_pk: ${params.device_pk}`,
47
+ `nostr_pk: ${params.nostr_pk}`,
48
+ `device_id: ${params.device_id}`,
49
+ `created_at: ${params.created_at}`
50
+ ] : [
19
51
  `oc-lock:device-bind:${BINDING_VERSION}`,
20
52
  `address: ${params.address}`,
21
53
  `device_pk: ${params.device_pk}`,
@@ -113,13 +145,19 @@ function parseDeviceEvent(event) {
113
145
  }
114
146
  const signed = parseBindingStatement(event.content);
115
147
  if (!signed) {
116
- throw new Error("device event content is not a v2 bind or revoke statement");
148
+ throw new Error("device event content is not a v2/v3 bind or revoke statement");
117
149
  }
118
150
  if (signed.address !== address) {
119
151
  throw new Error(
120
152
  `device event addr tag (${address}) does not match the signed statement (${signed.address})`
121
153
  );
122
154
  }
155
+ const d = tag("d");
156
+ if (d !== `oc-lock:device:${signed.address}` && d !== `oc-lock:device:${signed.address}:${signed.device_id}`) {
157
+ throw new Error(
158
+ `device event d tag (${d}) is not the slot for the signed address (${signed.address})`
159
+ );
160
+ }
123
161
  if (signed.device_id !== device_id) {
124
162
  throw new Error(
125
163
  `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`
@@ -131,6 +169,11 @@ function parseDeviceEvent(event) {
131
169
  `device event device_pk tag does not match the signed statement \u2014 tag says ${device_pk}, the signature covers ${signed.device_pk}`
132
170
  );
133
171
  }
172
+ if (signed.nostr_pk !== null && signed.nostr_pk !== event.pubkey) {
173
+ throw new Error(
174
+ `device event pubkey (${event.pubkey}) does not match the signed nostr_pk (${signed.nostr_pk})`
175
+ );
176
+ }
134
177
  } else if (device_pk !== "revoked") {
135
178
  throw new Error(
136
179
  `device event carries a revocation statement but its device_pk tag is ${device_pk}, not "revoked"`
@@ -146,7 +189,8 @@ function parseDeviceEvent(event) {
146
189
  revoked: signed.kind === "revoke",
147
190
  createdAtUnix: event.created_at,
148
191
  nostrPubkey: event.pubkey,
149
- eventId: event.id
192
+ eventId: event.id,
193
+ bindingVersion: signed.kind === "bind" && signed.nostr_pk !== null ? "v3" : "v2"
150
194
  };
151
195
  }
152
196
  function parseBindingStatement(content) {
@@ -165,7 +209,13 @@ function parseBindingStatement(content) {
165
209
  if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {
166
210
  const device_pk = field("device_pk");
167
211
  if (!device_pk) return null;
168
- return { kind: "bind", address, device_pk, device_id };
212
+ return { kind: "bind", address, device_pk, device_id, nostr_pk: null };
213
+ }
214
+ if (header === `oc-lock:device-bind:${BINDING_VERSION_V3}`) {
215
+ const device_pk = field("device_pk");
216
+ const nostr_pk = field("nostr_pk");
217
+ if (!device_pk || !nostr_pk || !/^[0-9a-f]{64}$/.test(nostr_pk)) return null;
218
+ return { kind: "bind", address, device_pk, device_id, nostr_pk };
169
219
  }
170
220
  if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {
171
221
  return { kind: "revoke", address, device_id };
@@ -173,6 +223,6 @@ function parseBindingStatement(content) {
173
223
  return null;
174
224
  }
175
225
 
176
- export { BINDING_VERSION, DEVICE_KIND, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
226
+ export { BINDING_VERSION, BINDING_VERSION_V3, DEVICE_KIND, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
177
227
  //# sourceMappingURL=index.mjs.map
178
228
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["// Device key binding and Nostr directory publication.\n// See SPEC.md §3.\n\nimport { schnorr } from '@noble/curves/secp256k1';\nimport {\n generateX25519KeyPair,\n hexDecode,\n hexEncode,\n hkdfSha256,\n randomBytesN,\n sha256Bytes,\n utf8Encode,\n} from '@orangecheck/lock-crypto';\n\nexport const DEVICE_KIND = 30078;\nexport const BINDING_VERSION = 'v2';\n\nexport interface DeviceKeyPair {\n device_id: string;\n device_pk: string; // 32-byte hex\n device_sk: Uint8Array; // 32 bytes\n created_at: string; // iso8601\n}\n\nexport function generateDeviceKey(): DeviceKeyPair {\n const kp = generateX25519KeyPair();\n const deviceIdBytes = randomBytesN(16);\n return {\n device_id: hexEncode(deviceIdBytes),\n device_pk: hexEncode(kp.public),\n device_sk: kp.secret,\n created_at: new Date().toISOString(),\n };\n}\n\nexport function buildBindingStatement(params: {\n address: string;\n device_pk: string;\n device_id: string;\n created_at: string;\n}): string {\n // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF\n // beyond the last field's. Order is fixed.\n const lines = [\n `oc-lock:device-bind:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\nexport function buildRevocationStatement(params: {\n address: string;\n device_id: string;\n revoked_at: string;\n}): string {\n const lines = [\n `oc-lock:device-revoke:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_id: ${params.device_id}`,\n `revoked_at: ${params.revoked_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\n// ---------------------------------------------------------------------------\n// Nostr event helpers\n// ---------------------------------------------------------------------------\n\nexport interface NostrEvent {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/**\n * Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.\n * Returns a 32-byte private key (hex).\n */\nexport function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {\n const sk = hkdfSha256(\n deviceSk,\n utf8Encode('oc-lock/v2/nostr-key'),\n utf8Encode('nostr-sk'),\n 32\n );\n const pk = schnorr.getPublicKey(sk);\n return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };\n}\n\n/**\n * Build a kind-30078 addressable event announcing a device binding.\n * The caller must supply the BIP-322 signature produced by the user's Bitcoin\n * wallet (we cannot sign BIP-322 in this package — that requires a wallet).\n */\nexport function buildDeviceEvent(params: {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {\n const tags: string[][] = [\n ['d', `oc-lock:device:${params.address}`],\n ['addr', params.address],\n ['device_id', params.device_id],\n ['device_pk', params.device_pk],\n ['alg', 'x25519'],\n ['binding_sig', params.bindingSigBase64],\n ];\n const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);\n return {\n kind: DEVICE_KIND,\n tags,\n content: params.bindingStatement,\n created_at,\n } as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };\n}\n\n/**\n * Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces\n * the full NostrEvent with id and sig.\n */\nexport function signNostrEvent(\n event: Omit<NostrEvent, 'id' | 'sig'>,\n nostrSkHex: string\n): NostrEvent {\n const id = computeNostrEventId(event);\n const sk = hexDecode(nostrSkHex);\n const sig = schnorr.sign(hexDecode(id), sk);\n return {\n ...event,\n id,\n sig: hexEncode(sig),\n };\n}\n\nexport function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {\n // Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).\n // The array must be serialized in *exactly* this form with no whitespace.\n const serialized = JSON.stringify([\n 0,\n event.pubkey,\n event.created_at,\n event.kind,\n event.tags,\n event.content,\n ]);\n return hexEncode(sha256Bytes(utf8Encode(serialized)));\n}\n\n/**\n * Finalize a device event: derive Nostr key from device_sk, attach pubkey,\n * compute id, sign. Returns a fully-signed NIP-01 event ready to publish.\n */\nexport function finalizeDeviceEvent(params: {\n deviceSk: Uint8Array;\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): NostrEvent {\n const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);\n const base = buildDeviceEvent({\n address: params.address,\n device_id: params.device_id,\n device_pk: params.device_pk,\n bindingStatement: params.bindingStatement,\n bindingSigBase64: params.bindingSigBase64,\n createdAtUnix: params.createdAtUnix,\n });\n const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;\n return signNostrEvent(withPubkey, nostrSk);\n}\n\n// ---------------------------------------------------------------------------\n// Parsing\n// ---------------------------------------------------------------------------\n\nexport interface ParsedDeviceEvent {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n revoked: boolean;\n createdAtUnix: number;\n nostrPubkey: string;\n eventId: string;\n}\n\nexport function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {\n if (event.kind !== DEVICE_KIND) {\n throw new Error('not a device event (kind mismatch)');\n }\n const tag = (name: string): string | null => {\n for (const t of event.tags) {\n if (t[0] === name && t.length > 1) return t[1] ?? null;\n }\n return null;\n };\n const address = tag('addr');\n const device_id = tag('device_id');\n const device_pk = tag('device_pk');\n const binding_sig = tag('binding_sig');\n if (!address || !device_id || !device_pk || !binding_sig) {\n throw new Error('device event missing required tags');\n }\n\n // The BIP-322 signature covers `event.content` — the statement — and nothing\n // else. Tags are an index the relay lets anyone write, so a value read from a\n // tag has been authenticated by nobody.\n //\n // Skipping this check was a total confidentiality break, and it needed no key\n // material: republish a victim's device event with their real content and\n // real binding_sig, change only the `device_pk` TAG to your own X25519 key.\n // verifyBip322(content, sig, addr) passes — every signed byte is genuine —\n // and the sender then seals the content key to the attacker's key, because\n // the recipient record was built from the tag. Proven against the published\n // package before this existed.\n //\n // So the statement is the source of truth and the tags must agree with it.\n const signed = parseBindingStatement(event.content);\n if (!signed) {\n throw new Error('device event content is not a v2 bind or revoke statement');\n }\n if (signed.address !== address) {\n throw new Error(\n `device event addr tag (${address}) does not match the signed statement (${signed.address})`,\n );\n }\n if (signed.device_id !== device_id) {\n throw new Error(\n `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`,\n );\n }\n if (signed.kind === 'bind') {\n if (signed.device_pk !== device_pk) {\n throw new Error(\n `device event device_pk tag does not match the signed statement — ` +\n `tag says ${device_pk}, the signature covers ${signed.device_pk}`,\n );\n }\n } else if (device_pk !== 'revoked') {\n throw new Error(\n `device event carries a revocation statement but its device_pk tag is ${device_pk}, not \"revoked\"`,\n );\n }\n\n return {\n address: signed.address,\n device_id: signed.device_id,\n // Take the key from the SIGNED bytes, never the tag.\n device_pk: signed.kind === 'bind' ? signed.device_pk : 'revoked',\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: signed.kind === 'revoke',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n };\n}\n\n/**\n * Read back the fields the signature actually commits to. Returns null for\n * anything that is not a well-formed v2 bind/revoke statement, which the caller\n * treats as an unusable record.\n */\nfunction parseBindingStatement(\n content: string,\n):\n | { kind: 'bind'; address: string; device_pk: string; device_id: string }\n | { kind: 'revoke'; address: string; device_id: string }\n | null {\n const lines = content.split('\\n');\n const header = lines[0];\n const field = (name: string): string | null => {\n const prefix = `${name}: `;\n for (const l of lines.slice(1)) {\n if (l.startsWith(prefix)) return l.slice(prefix.length);\n }\n return null;\n };\n const address = field('address');\n const device_id = field('device_id');\n if (!address || !device_id) return null;\n\n if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {\n const device_pk = field('device_pk');\n if (!device_pk) return null;\n return { kind: 'bind', address, device_pk, device_id };\n }\n if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {\n return { kind: 'revoke', address, device_id };\n }\n return null;\n}\n"]}
1
+ {"version":3,"sources":["../src/authorize.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["// Which Nostr pubkeys a set of device records authorizes. See SPEC §3.4.\n\nimport type { ParsedDeviceEvent } from './index.js';\n\n/**\n * Select the device records whose `nostrPubkey` may act for their address.\n *\n * `records` MUST already be BIP-322-verified, non-revoked bind records for one\n * address. A v3 record signs its Nostr pubkey, so it is always authorized. A v2\n * record does not, so its pubkey is only the event author's claim: it is\n * authorized only while its `device_id` has no v3 record and appears under\n * exactly one Nostr pubkey. Two pubkeys carrying one v2 device authorize neither.\n */\nexport function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {\n const v3Ids = new Set<string>();\n const v2Pubkeys = new Map<string, Set<string>>();\n for (const r of records) {\n if (r.revoked) continue;\n if (r.bindingVersion === 'v3') {\n v3Ids.add(r.device_id);\n } else {\n const set = v2Pubkeys.get(r.device_id) ?? new Set<string>();\n set.add(r.nostrPubkey);\n v2Pubkeys.set(r.device_id, set);\n }\n }\n return records.filter((r) => {\n if (r.revoked) return false;\n if (r.bindingVersion === 'v3') return true;\n if (v3Ids.has(r.device_id)) return false;\n return v2Pubkeys.get(r.device_id)?.size === 1;\n });\n}\n","// Device key binding and Nostr directory publication.\n// See SPEC.md §3.\n\nimport { schnorr } from '@noble/curves/secp256k1';\nimport {\n generateX25519KeyPair,\n hexDecode,\n hexEncode,\n hkdfSha256,\n randomBytesN,\n sha256Bytes,\n utf8Encode,\n} from '@orangecheck/lock-crypto';\n\nexport const DEVICE_KIND = 30078;\nexport const BINDING_VERSION = 'v2';\n/** Bind statement version that also signs the device's Nostr inbox pubkey. */\nexport const BINDING_VERSION_V3 = 'v3';\n\nexport interface DeviceKeyPair {\n device_id: string;\n device_pk: string; // 32-byte hex\n device_sk: Uint8Array; // 32 bytes\n created_at: string; // iso8601\n}\n\nexport function generateDeviceKey(): DeviceKeyPair {\n const kp = generateX25519KeyPair();\n const deviceIdBytes = randomBytesN(16);\n return {\n device_id: hexEncode(deviceIdBytes),\n device_pk: hexEncode(kp.public),\n device_sk: kp.secret,\n created_at: new Date().toISOString(),\n };\n}\n\nexport function buildBindingStatement(params: {\n address: string;\n device_pk: string;\n device_id: string;\n created_at: string;\n /** The device's Nostr pubkey (deriveNostrKey). Present ⇒ a v3 statement. */\n nostr_pk?: string;\n}): string {\n // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF\n // beyond the last field's. Order is fixed.\n const lines = params.nostr_pk\n ? [\n `oc-lock:device-bind:${BINDING_VERSION_V3}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `nostr_pk: ${params.nostr_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ]\n : [\n `oc-lock:device-bind:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\nexport function buildRevocationStatement(params: {\n address: string;\n device_id: string;\n revoked_at: string;\n}): string {\n const lines = [\n `oc-lock:device-revoke:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_id: ${params.device_id}`,\n `revoked_at: ${params.revoked_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\n// ---------------------------------------------------------------------------\n// Nostr event helpers\n// ---------------------------------------------------------------------------\n\nexport interface NostrEvent {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/**\n * Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.\n * Returns a 32-byte private key (hex).\n */\nexport function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {\n const sk = hkdfSha256(\n deviceSk,\n utf8Encode('oc-lock/v2/nostr-key'),\n utf8Encode('nostr-sk'),\n 32\n );\n const pk = schnorr.getPublicKey(sk);\n return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };\n}\n\n/**\n * Build a kind-30078 addressable event announcing a device binding.\n * The caller must supply the BIP-322 signature produced by the user's Bitcoin\n * wallet (we cannot sign BIP-322 in this package — that requires a wallet).\n */\nexport function buildDeviceEvent(params: {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {\n const tags: string[][] = [\n ['d', `oc-lock:device:${params.address}`],\n ['addr', params.address],\n ['device_id', params.device_id],\n ['device_pk', params.device_pk],\n ['alg', 'x25519'],\n ['binding_sig', params.bindingSigBase64],\n ];\n const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);\n return {\n kind: DEVICE_KIND,\n tags,\n content: params.bindingStatement,\n created_at,\n } as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };\n}\n\n/**\n * Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces\n * the full NostrEvent with id and sig.\n */\nexport function signNostrEvent(\n event: Omit<NostrEvent, 'id' | 'sig'>,\n nostrSkHex: string\n): NostrEvent {\n const id = computeNostrEventId(event);\n const sk = hexDecode(nostrSkHex);\n const sig = schnorr.sign(hexDecode(id), sk);\n return {\n ...event,\n id,\n sig: hexEncode(sig),\n };\n}\n\nexport function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {\n // Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).\n // The array must be serialized in *exactly* this form with no whitespace.\n const serialized = JSON.stringify([\n 0,\n event.pubkey,\n event.created_at,\n event.kind,\n event.tags,\n event.content,\n ]);\n return hexEncode(sha256Bytes(utf8Encode(serialized)));\n}\n\n/**\n * Finalize a device event: derive Nostr key from device_sk, attach pubkey,\n * compute id, sign. Returns a fully-signed NIP-01 event ready to publish.\n */\nexport function finalizeDeviceEvent(params: {\n deviceSk: Uint8Array;\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): NostrEvent {\n const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);\n const base = buildDeviceEvent({\n address: params.address,\n device_id: params.device_id,\n device_pk: params.device_pk,\n bindingStatement: params.bindingStatement,\n bindingSigBase64: params.bindingSigBase64,\n createdAtUnix: params.createdAtUnix,\n });\n const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;\n return signNostrEvent(withPubkey, nostrSk);\n}\n\n// ---------------------------------------------------------------------------\n// Parsing\n// ---------------------------------------------------------------------------\n\nexport interface ParsedDeviceEvent {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n revoked: boolean;\n createdAtUnix: number;\n nostrPubkey: string;\n eventId: string;\n /** 'v3' binds signed the Nostr pubkey; 'v2' binds and revocations did not. */\n bindingVersion: 'v2' | 'v3';\n}\n\nexport function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {\n if (event.kind !== DEVICE_KIND) {\n throw new Error('not a device event (kind mismatch)');\n }\n const tag = (name: string): string | null => {\n for (const t of event.tags) {\n if (t[0] === name && t.length > 1) return t[1] ?? null;\n }\n return null;\n };\n const address = tag('addr');\n const device_id = tag('device_id');\n const device_pk = tag('device_pk');\n const binding_sig = tag('binding_sig');\n if (!address || !device_id || !device_pk || !binding_sig) {\n throw new Error('device event missing required tags');\n }\n\n // The BIP-322 signature covers `event.content` — the statement — and nothing\n // else. Tags are an index the relay lets anyone write, so a value read from a\n // tag has been authenticated by nobody.\n //\n // Skipping this check was a total confidentiality break, and it needed no key\n // material: republish a victim's device event with their real content and\n // real binding_sig, change only the `device_pk` TAG to your own X25519 key.\n // verifyBip322(content, sig, addr) passes — every signed byte is genuine —\n // and the sender then seals the content key to the attacker's key, because\n // the recipient record was built from the tag. Proven against the published\n // package before this existed.\n //\n // So the statement is the source of truth and the tags must agree with it.\n const signed = parseBindingStatement(event.content);\n if (!signed) {\n throw new Error('device event content is not a v2/v3 bind or revoke statement');\n }\n if (signed.address !== address) {\n throw new Error(\n `device event addr tag (${address}) does not match the signed statement (${signed.address})`,\n );\n }\n // SPEC §3.3 / §3.6: the record lives at `oc-lock:device:<addr>` or the\n // per-device `oc-lock:device:<addr>:<device_id>`. Lookups filter on `d`, so a\n // record for one address filed under another's slot must not parse.\n const d = tag('d');\n if (\n d !== `oc-lock:device:${signed.address}` &&\n d !== `oc-lock:device:${signed.address}:${signed.device_id}`\n ) {\n throw new Error(\n `device event d tag (${d}) is not the slot for the signed address (${signed.address})`,\n );\n }\n if (signed.device_id !== device_id) {\n throw new Error(\n `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`,\n );\n }\n if (signed.kind === 'bind') {\n if (signed.device_pk !== device_pk) {\n throw new Error(\n `device event device_pk tag does not match the signed statement — ` +\n `tag says ${device_pk}, the signature covers ${signed.device_pk}`,\n );\n }\n // A v3 statement names the one Nostr key allowed to carry it.\n if (signed.nostr_pk !== null && signed.nostr_pk !== event.pubkey) {\n throw new Error(\n `device event pubkey (${event.pubkey}) does not match the signed nostr_pk (${signed.nostr_pk})`,\n );\n }\n } else if (device_pk !== 'revoked') {\n throw new Error(\n `device event carries a revocation statement but its device_pk tag is ${device_pk}, not \"revoked\"`,\n );\n }\n\n return {\n address: signed.address,\n device_id: signed.device_id,\n // Take the key from the SIGNED bytes, never the tag.\n device_pk: signed.kind === 'bind' ? signed.device_pk : 'revoked',\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: signed.kind === 'revoke',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n bindingVersion: signed.kind === 'bind' && signed.nostr_pk !== null ? 'v3' : 'v2',\n };\n}\n\n/**\n * Read back the fields the signature actually commits to. Returns null for\n * anything that is not a well-formed v2/v3 bind or v2 revoke statement, which the caller\n * treats as an unusable record.\n */\nfunction parseBindingStatement(\n content: string,\n):\n | {\n kind: 'bind';\n address: string;\n device_pk: string;\n device_id: string;\n nostr_pk: string | null;\n }\n | { kind: 'revoke'; address: string; device_id: string }\n | null {\n const lines = content.split('\\n');\n const header = lines[0];\n const field = (name: string): string | null => {\n const prefix = `${name}: `;\n for (const l of lines.slice(1)) {\n if (l.startsWith(prefix)) return l.slice(prefix.length);\n }\n return null;\n };\n const address = field('address');\n const device_id = field('device_id');\n if (!address || !device_id) return null;\n\n if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {\n const device_pk = field('device_pk');\n if (!device_pk) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk: null };\n }\n if (header === `oc-lock:device-bind:${BINDING_VERSION_V3}`) {\n const device_pk = field('device_pk');\n const nostr_pk = field('nostr_pk');\n if (!device_pk || !nostr_pk || !/^[0-9a-f]{64}$/.test(nostr_pk)) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk };\n }\n if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {\n return { kind: 'revoke', address, device_id };\n }\n return null;\n}\n\nexport { authorizedDevices } from './authorize.js';\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangecheck/lock-device",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Device-key binding and Nostr kind-30078 directory publication for OC Lock.",
5
5
  "keywords": [
6
6
  "bitcoin",
@@ -0,0 +1,104 @@
1
+ // A v3 bind statement signs the device's Nostr pubkey; a v2 statement does not,
2
+ // so a v2 record's event pubkey is only its author's claim.
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { authorizedDevices, buildBindingStatement, parseDeviceEvent } from './index.js';
6
+
7
+ const ADDR = 'bc1qowner00000000000000000000000000000000';
8
+ const DEVICE_PK = 'aa'.repeat(32);
9
+ const DEVICE_ID = 'owner-laptop';
10
+ const OWNER_NOSTR = '1'.repeat(64);
11
+ const OTHER_NOSTR = '2'.repeat(64);
12
+
13
+ function statement(nostr_pk?: string, device_id = DEVICE_ID) {
14
+ return buildBindingStatement({
15
+ address: ADDR,
16
+ device_pk: DEVICE_PK,
17
+ device_id,
18
+ created_at: '2026-09-01T00:00:00Z',
19
+ ...(nostr_pk ? { nostr_pk } : {}),
20
+ });
21
+ }
22
+
23
+ function event(pubkey: string, content: string, device_id = DEVICE_ID) {
24
+ return {
25
+ id: 'f'.repeat(64),
26
+ pubkey,
27
+ kind: 30078,
28
+ created_at: 1_780_000_000,
29
+ content,
30
+ tags: [
31
+ ['d', `oc-lock:device:${ADDR}`],
32
+ ['addr', ADDR],
33
+ ['device_id', device_id],
34
+ ['device_pk', DEVICE_PK],
35
+ ['binding_sig', 'SIG'],
36
+ ],
37
+ sig: 'd'.repeat(128),
38
+ };
39
+ }
40
+
41
+ describe('v3 bind statement', () => {
42
+ it('is canonical and carries nostr_pk after device_pk', () => {
43
+ expect(statement(OWNER_NOSTR)).toBe(
44
+ 'oc-lock:device-bind:v3\n' +
45
+ `address: ${ADDR}\n` +
46
+ `device_pk: ${DEVICE_PK}\n` +
47
+ `nostr_pk: ${OWNER_NOSTR}\n` +
48
+ `device_id: ${DEVICE_ID}\n` +
49
+ 'created_at: 2026-09-01T00:00:00Z\n'
50
+ );
51
+ });
52
+
53
+ it('parses when the event pubkey is the signed nostr_pk', () => {
54
+ const p = parseDeviceEvent(event(OWNER_NOSTR, statement(OWNER_NOSTR)));
55
+ expect(p.bindingVersion).toBe('v3');
56
+ expect(p.nostrPubkey).toBe(OWNER_NOSTR);
57
+ });
58
+
59
+ it('rejects the same statement carried by a different pubkey', () => {
60
+ expect(() => parseDeviceEvent(event(OTHER_NOSTR, statement(OWNER_NOSTR)))).toThrow(
61
+ /signed nostr_pk/
62
+ );
63
+ });
64
+
65
+ it('rejects a v3 statement without a well-formed nostr_pk', () => {
66
+ const bad = statement(OWNER_NOSTR).replace(`nostr_pk: ${OWNER_NOSTR}`, 'nostr_pk: xyz');
67
+ expect(() => parseDeviceEvent(event(OWNER_NOSTR, bad))).toThrow();
68
+ });
69
+
70
+ it('a v2 statement parses as v2', () => {
71
+ expect(parseDeviceEvent(event(OWNER_NOSTR, statement())).bindingVersion).toBe('v2');
72
+ });
73
+ });
74
+
75
+ describe('authorizedDevices', () => {
76
+ const v2Owner = parseDeviceEvent(event(OWNER_NOSTR, statement()));
77
+ const v2Other = parseDeviceEvent(event(OTHER_NOSTR, statement()));
78
+ const v3Owner = parseDeviceEvent(event(OWNER_NOSTR, statement(OWNER_NOSTR)));
79
+
80
+ it('authorizes a v2 device seen under exactly one pubkey', () => {
81
+ expect(authorizedDevices([v2Owner]).map((d) => d.nostrPubkey)).toEqual([OWNER_NOSTR]);
82
+ });
83
+
84
+ it('authorizes neither pubkey when one v2 device appears under two', () => {
85
+ expect(authorizedDevices([v2Owner, v2Other])).toEqual([]);
86
+ });
87
+
88
+ it('a v3 record for the device supersedes every v2 copy of it', () => {
89
+ expect(authorizedDevices([v2Other, v3Owner, v2Owner]).map((d) => d.nostrPubkey)).toEqual(
90
+ [OWNER_NOSTR]
91
+ );
92
+ expect(authorizedDevices([v2Other, v3Owner])).toEqual([v3Owner]);
93
+ });
94
+
95
+ it('scopes the v2 rule per device_id', () => {
96
+ const second = parseDeviceEvent(
97
+ event(OTHER_NOSTR, statement(undefined, 'owner-phone'), 'owner-phone')
98
+ );
99
+ expect(authorizedDevices([v2Owner, second]).map((d) => d.device_id)).toEqual([
100
+ DEVICE_ID,
101
+ 'owner-phone',
102
+ ]);
103
+ });
104
+ });
@@ -0,0 +1,33 @@
1
+ // Which Nostr pubkeys a set of device records authorizes. See SPEC §3.4.
2
+
3
+ import type { ParsedDeviceEvent } from './index.js';
4
+
5
+ /**
6
+ * Select the device records whose `nostrPubkey` may act for their address.
7
+ *
8
+ * `records` MUST already be BIP-322-verified, non-revoked bind records for one
9
+ * address. A v3 record signs its Nostr pubkey, so it is always authorized. A v2
10
+ * record does not, so its pubkey is only the event author's claim: it is
11
+ * authorized only while its `device_id` has no v3 record and appears under
12
+ * exactly one Nostr pubkey. Two pubkeys carrying one v2 device authorize neither.
13
+ */
14
+ export function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {
15
+ const v3Ids = new Set<string>();
16
+ const v2Pubkeys = new Map<string, Set<string>>();
17
+ for (const r of records) {
18
+ if (r.revoked) continue;
19
+ if (r.bindingVersion === 'v3') {
20
+ v3Ids.add(r.device_id);
21
+ } else {
22
+ const set = v2Pubkeys.get(r.device_id) ?? new Set<string>();
23
+ set.add(r.nostrPubkey);
24
+ v2Pubkeys.set(r.device_id, set);
25
+ }
26
+ }
27
+ return records.filter((r) => {
28
+ if (r.revoked) return false;
29
+ if (r.bindingVersion === 'v3') return true;
30
+ if (v3Ids.has(r.device_id)) return false;
31
+ return v2Pubkeys.get(r.device_id)?.size === 1;
32
+ });
33
+ }
package/src/index.ts CHANGED
@@ -14,6 +14,8 @@ import {
14
14
 
15
15
  export const DEVICE_KIND = 30078;
16
16
  export const BINDING_VERSION = 'v2';
17
+ /** Bind statement version that also signs the device's Nostr inbox pubkey. */
18
+ export const BINDING_VERSION_V3 = 'v3';
17
19
 
18
20
  export interface DeviceKeyPair {
19
21
  device_id: string;
@@ -38,16 +40,27 @@ export function buildBindingStatement(params: {
38
40
  device_pk: string;
39
41
  device_id: string;
40
42
  created_at: string;
43
+ /** The device's Nostr pubkey (deriveNostrKey). Present ⇒ a v3 statement. */
44
+ nostr_pk?: string;
41
45
  }): string {
42
46
  // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF
43
47
  // beyond the last field's. Order is fixed.
44
- const lines = [
45
- `oc-lock:device-bind:${BINDING_VERSION}`,
46
- `address: ${params.address}`,
47
- `device_pk: ${params.device_pk}`,
48
- `device_id: ${params.device_id}`,
49
- `created_at: ${params.created_at}`,
50
- ];
48
+ const lines = params.nostr_pk
49
+ ? [
50
+ `oc-lock:device-bind:${BINDING_VERSION_V3}`,
51
+ `address: ${params.address}`,
52
+ `device_pk: ${params.device_pk}`,
53
+ `nostr_pk: ${params.nostr_pk}`,
54
+ `device_id: ${params.device_id}`,
55
+ `created_at: ${params.created_at}`,
56
+ ]
57
+ : [
58
+ `oc-lock:device-bind:${BINDING_VERSION}`,
59
+ `address: ${params.address}`,
60
+ `device_pk: ${params.device_pk}`,
61
+ `device_id: ${params.device_id}`,
62
+ `created_at: ${params.created_at}`,
63
+ ];
51
64
  return lines.join('\n') + '\n';
52
65
  }
53
66
 
@@ -196,6 +209,8 @@ export interface ParsedDeviceEvent {
196
209
  createdAtUnix: number;
197
210
  nostrPubkey: string;
198
211
  eventId: string;
212
+ /** 'v3' binds signed the Nostr pubkey; 'v2' binds and revocations did not. */
213
+ bindingVersion: 'v2' | 'v3';
199
214
  }
200
215
 
201
216
  export function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {
@@ -231,13 +246,25 @@ export function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {
231
246
  // So the statement is the source of truth and the tags must agree with it.
232
247
  const signed = parseBindingStatement(event.content);
233
248
  if (!signed) {
234
- throw new Error('device event content is not a v2 bind or revoke statement');
249
+ throw new Error('device event content is not a v2/v3 bind or revoke statement');
235
250
  }
236
251
  if (signed.address !== address) {
237
252
  throw new Error(
238
253
  `device event addr tag (${address}) does not match the signed statement (${signed.address})`,
239
254
  );
240
255
  }
256
+ // SPEC §3.3 / §3.6: the record lives at `oc-lock:device:<addr>` or the
257
+ // per-device `oc-lock:device:<addr>:<device_id>`. Lookups filter on `d`, so a
258
+ // record for one address filed under another's slot must not parse.
259
+ const d = tag('d');
260
+ if (
261
+ d !== `oc-lock:device:${signed.address}` &&
262
+ d !== `oc-lock:device:${signed.address}:${signed.device_id}`
263
+ ) {
264
+ throw new Error(
265
+ `device event d tag (${d}) is not the slot for the signed address (${signed.address})`,
266
+ );
267
+ }
241
268
  if (signed.device_id !== device_id) {
242
269
  throw new Error(
243
270
  `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`,
@@ -250,6 +277,12 @@ export function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {
250
277
  `tag says ${device_pk}, the signature covers ${signed.device_pk}`,
251
278
  );
252
279
  }
280
+ // A v3 statement names the one Nostr key allowed to carry it.
281
+ if (signed.nostr_pk !== null && signed.nostr_pk !== event.pubkey) {
282
+ throw new Error(
283
+ `device event pubkey (${event.pubkey}) does not match the signed nostr_pk (${signed.nostr_pk})`,
284
+ );
285
+ }
253
286
  } else if (device_pk !== 'revoked') {
254
287
  throw new Error(
255
288
  `device event carries a revocation statement but its device_pk tag is ${device_pk}, not "revoked"`,
@@ -267,18 +300,25 @@ export function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {
267
300
  createdAtUnix: event.created_at,
268
301
  nostrPubkey: event.pubkey,
269
302
  eventId: event.id,
303
+ bindingVersion: signed.kind === 'bind' && signed.nostr_pk !== null ? 'v3' : 'v2',
270
304
  };
271
305
  }
272
306
 
273
307
  /**
274
308
  * Read back the fields the signature actually commits to. Returns null for
275
- * anything that is not a well-formed v2 bind/revoke statement, which the caller
309
+ * anything that is not a well-formed v2/v3 bind or v2 revoke statement, which the caller
276
310
  * treats as an unusable record.
277
311
  */
278
312
  function parseBindingStatement(
279
313
  content: string,
280
314
  ):
281
- | { kind: 'bind'; address: string; device_pk: string; device_id: string }
315
+ | {
316
+ kind: 'bind';
317
+ address: string;
318
+ device_pk: string;
319
+ device_id: string;
320
+ nostr_pk: string | null;
321
+ }
282
322
  | { kind: 'revoke'; address: string; device_id: string }
283
323
  | null {
284
324
  const lines = content.split('\n');
@@ -297,10 +337,18 @@ function parseBindingStatement(
297
337
  if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {
298
338
  const device_pk = field('device_pk');
299
339
  if (!device_pk) return null;
300
- return { kind: 'bind', address, device_pk, device_id };
340
+ return { kind: 'bind', address, device_pk, device_id, nostr_pk: null };
341
+ }
342
+ if (header === `oc-lock:device-bind:${BINDING_VERSION_V3}`) {
343
+ const device_pk = field('device_pk');
344
+ const nostr_pk = field('nostr_pk');
345
+ if (!device_pk || !nostr_pk || !/^[0-9a-f]{64}$/.test(nostr_pk)) return null;
346
+ return { kind: 'bind', address, device_pk, device_id, nostr_pk };
301
347
  }
302
348
  if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {
303
349
  return { kind: 'revoke', address, device_id };
304
350
  }
305
351
  return null;
306
352
  }
353
+
354
+ export { authorizedDevices } from './authorize.js';
@@ -65,6 +65,23 @@ describe('parseDeviceEvent trusts the signed statement, not the tags', () => {
65
65
  expect(() => parseDeviceEvent(event(tags))).toThrow(/addr tag .* does not match/i);
66
66
  });
67
67
 
68
+ it('REJECTS a record filed under another address\'s d tag', () => {
69
+ const tags = honestTags.map((t) => (t[0] === 'd' ? ['d', 'oc-lock:device:bc1qother'] : t));
70
+ expect(() => parseDeviceEvent(event(tags))).toThrow(/d tag .* is not the slot/i);
71
+ });
72
+
73
+ it('REJECTS a record with no d tag', () => {
74
+ const tags = honestTags.filter((t) => t[0] !== 'd');
75
+ expect(() => parseDeviceEvent(event(tags))).toThrow(/d tag .* is not the slot/i);
76
+ });
77
+
78
+ it('accepts the per-device d tag form', () => {
79
+ const tags = honestTags.map((t) =>
80
+ t[0] === 'd' ? ['d', `oc-lock:device:${VICTIM}:${DEVICE}`] : t,
81
+ );
82
+ expect(parseDeviceEvent(event(tags)).address).toBe(VICTIM);
83
+ });
84
+
68
85
  it('REJECTS a substituted device_id tag', () => {
69
86
  const tags = honestTags.map((t) =>
70
87
  t[0] === 'device_id' ? ['device_id', 'other-device'] : t,
@@ -73,7 +90,7 @@ describe('parseDeviceEvent trusts the signed statement, not the tags', () => {
73
90
  });
74
91
 
75
92
  it('REJECTS content that is not a v2 statement at all', () => {
76
- expect(() => parseDeviceEvent(event(honestTags, 'hello'))).toThrow(/not a v2 bind or revoke/i);
93
+ expect(() => parseDeviceEvent(event(honestTags, 'hello'))).toThrow(/not a v2\/v3 bind or revoke/i);
77
94
  });
78
95
 
79
96
  it('accepts a genuine revocation and reports it revoked', () => {