@orangecheck/lock-device 0.1.0 → 0.2.1

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
@@ -1,5 +1,9 @@
1
1
  # @orangecheck/lock-device
2
2
 
3
+ > **Full reference:** [docs.ochk.io/sdk/lock-device](https://docs.ochk.io/sdk/lock-device) — auto-generated from the TypeScript source on every release.
4
+ > Hand-written prose below is the high-level overview; the docs site is the source of truth for every export, type, and signature.
5
+
6
+
3
7
  Device-key binding and Nostr kind-30078 directory publication helpers for [OC Lock](https://github.com/orangecheck/oc-lock-protocol).
4
8
 
5
9
  ## Install
package/dist/index.js CHANGED
@@ -113,18 +113,67 @@ function parseDeviceEvent(event) {
113
113
  if (!address || !device_id || !device_pk || !binding_sig) {
114
114
  throw new Error("device event missing required tags");
115
115
  }
116
+ const signed = parseBindingStatement(event.content);
117
+ if (!signed) {
118
+ throw new Error("device event content is not a v2 bind or revoke statement");
119
+ }
120
+ if (signed.address !== address) {
121
+ throw new Error(
122
+ `device event addr tag (${address}) does not match the signed statement (${signed.address})`
123
+ );
124
+ }
125
+ if (signed.device_id !== device_id) {
126
+ throw new Error(
127
+ `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`
128
+ );
129
+ }
130
+ if (signed.kind === "bind") {
131
+ if (signed.device_pk !== device_pk) {
132
+ throw new Error(
133
+ `device event device_pk tag does not match the signed statement \u2014 tag says ${device_pk}, the signature covers ${signed.device_pk}`
134
+ );
135
+ }
136
+ } else if (device_pk !== "revoked") {
137
+ throw new Error(
138
+ `device event carries a revocation statement but its device_pk tag is ${device_pk}, not "revoked"`
139
+ );
140
+ }
116
141
  return {
117
- address,
118
- device_id,
119
- device_pk,
142
+ address: signed.address,
143
+ device_id: signed.device_id,
144
+ // Take the key from the SIGNED bytes, never the tag.
145
+ device_pk: signed.kind === "bind" ? signed.device_pk : "revoked",
120
146
  bindingStatement: event.content,
121
147
  bindingSigBase64: binding_sig,
122
- revoked: device_pk === "revoked",
148
+ revoked: signed.kind === "revoke",
123
149
  createdAtUnix: event.created_at,
124
150
  nostrPubkey: event.pubkey,
125
151
  eventId: event.id
126
152
  };
127
153
  }
154
+ function parseBindingStatement(content) {
155
+ const lines = content.split("\n");
156
+ const header = lines[0];
157
+ const field = (name) => {
158
+ const prefix = `${name}: `;
159
+ for (const l of lines.slice(1)) {
160
+ if (l.startsWith(prefix)) return l.slice(prefix.length);
161
+ }
162
+ return null;
163
+ };
164
+ const address = field("address");
165
+ const device_id = field("device_id");
166
+ if (!address || !device_id) return null;
167
+ if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {
168
+ const device_pk = field("device_pk");
169
+ if (!device_pk) return null;
170
+ return { kind: "bind", address, device_pk, device_id };
171
+ }
172
+ if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {
173
+ return { kind: "revoke", address, device_id };
174
+ }
175
+ return null;
176
+ }
128
177
 
129
178
  exports.BINDING_VERSION = BINDING_VERSION;
130
179
  exports.DEVICE_KIND = DEVICE_KIND;
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 return {\n address,\n device_id,\n device_pk,\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: device_pk === 'revoked',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n };\n}\n"]}
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"]}
package/dist/index.mjs CHANGED
@@ -111,18 +111,67 @@ function parseDeviceEvent(event) {
111
111
  if (!address || !device_id || !device_pk || !binding_sig) {
112
112
  throw new Error("device event missing required tags");
113
113
  }
114
+ const signed = parseBindingStatement(event.content);
115
+ if (!signed) {
116
+ throw new Error("device event content is not a v2 bind or revoke statement");
117
+ }
118
+ if (signed.address !== address) {
119
+ throw new Error(
120
+ `device event addr tag (${address}) does not match the signed statement (${signed.address})`
121
+ );
122
+ }
123
+ if (signed.device_id !== device_id) {
124
+ throw new Error(
125
+ `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`
126
+ );
127
+ }
128
+ if (signed.kind === "bind") {
129
+ if (signed.device_pk !== device_pk) {
130
+ throw new Error(
131
+ `device event device_pk tag does not match the signed statement \u2014 tag says ${device_pk}, the signature covers ${signed.device_pk}`
132
+ );
133
+ }
134
+ } else if (device_pk !== "revoked") {
135
+ throw new Error(
136
+ `device event carries a revocation statement but its device_pk tag is ${device_pk}, not "revoked"`
137
+ );
138
+ }
114
139
  return {
115
- address,
116
- device_id,
117
- device_pk,
140
+ address: signed.address,
141
+ device_id: signed.device_id,
142
+ // Take the key from the SIGNED bytes, never the tag.
143
+ device_pk: signed.kind === "bind" ? signed.device_pk : "revoked",
118
144
  bindingStatement: event.content,
119
145
  bindingSigBase64: binding_sig,
120
- revoked: device_pk === "revoked",
146
+ revoked: signed.kind === "revoke",
121
147
  createdAtUnix: event.created_at,
122
148
  nostrPubkey: event.pubkey,
123
149
  eventId: event.id
124
150
  };
125
151
  }
152
+ function parseBindingStatement(content) {
153
+ const lines = content.split("\n");
154
+ const header = lines[0];
155
+ const field = (name) => {
156
+ const prefix = `${name}: `;
157
+ for (const l of lines.slice(1)) {
158
+ if (l.startsWith(prefix)) return l.slice(prefix.length);
159
+ }
160
+ return null;
161
+ };
162
+ const address = field("address");
163
+ const device_id = field("device_id");
164
+ if (!address || !device_id) return null;
165
+ if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {
166
+ const device_pk = field("device_pk");
167
+ if (!device_pk) return null;
168
+ return { kind: "bind", address, device_pk, device_id };
169
+ }
170
+ if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {
171
+ return { kind: "revoke", address, device_id };
172
+ }
173
+ return null;
174
+ }
126
175
 
127
176
  export { BINDING_VERSION, DEVICE_KIND, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
128
177
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;;AAcO,IAAM,WAAA,GAAc;AACpB,IAAM,eAAA,GAAkB;AASxB,SAAS,iBAAA,GAAmC;AAC/C,EAAA,MAAM,KAAK,qBAAA,EAAsB;AACjC,EAAA,MAAM,aAAA,GAAgB,aAAa,EAAE,CAAA;AACrC,EAAA,OAAO;AAAA,IACH,SAAA,EAAW,UAAU,aAAa,CAAA;AAAA,IAClC,SAAA,EAAW,SAAA,CAAU,EAAA,CAAG,MAAM,CAAA;AAAA,IAC9B,WAAW,EAAA,CAAG,MAAA;AAAA,IACd,UAAA,EAAA,iBAAY,IAAI,IAAA,EAAK,EAAE,WAAA;AAAY,GACvC;AACJ;AAEO,SAAS,sBAAsB,MAAA,EAK3B;AAGP,EAAA,MAAM,KAAA,GAAQ;AAAA,IACV,uBAAuB,eAAe,CAAA,CAAA;AAAA,IACtC,CAAA,SAAA,EAAY,OAAO,OAAO,CAAA,CAAA;AAAA,IAC1B,CAAA,WAAA,EAAc,OAAO,SAAS,CAAA,CAAA;AAAA,IAC9B,CAAA,WAAA,EAAc,OAAO,SAAS,CAAA,CAAA;AAAA,IAC9B,CAAA,YAAA,EAAe,OAAO,UAAU,CAAA;AAAA,GACpC;AACA,EAAA,OAAO,KAAA,CAAM,IAAA,CAAK,IAAI,CAAA,GAAI,IAAA;AAC9B;AAEO,SAAS,yBAAyB,MAAA,EAI9B;AACP,EAAA,MAAM,KAAA,GAAQ;AAAA,IACV,yBAAyB,eAAe,CAAA,CAAA;AAAA,IACxC,CAAA,SAAA,EAAY,OAAO,OAAO,CAAA,CAAA;AAAA,IAC1B,CAAA,WAAA,EAAc,OAAO,SAAS,CAAA,CAAA;AAAA,IAC9B,CAAA,YAAA,EAAe,OAAO,UAAU,CAAA;AAAA,GACpC;AACA,EAAA,OAAO,KAAA,CAAM,IAAA,CAAK,IAAI,CAAA,GAAI,IAAA;AAC9B;AAoBO,SAAS,eAAe,QAAA,EAA4D;AACvF,EAAA,MAAM,EAAA,GAAK,UAAA;AAAA,IACP,QAAA;AAAA,IACA,WAAW,sBAAsB,CAAA;AAAA,IACjC,WAAW,UAAU,CAAA;AAAA,IACrB;AAAA,GACJ;AACA,EAAA,MAAM,EAAA,GAAK,OAAA,CAAQ,YAAA,CAAa,EAAE,CAAA;AAClC,EAAA,OAAO,EAAE,SAAS,SAAA,CAAU,EAAE,GAAG,OAAA,EAAS,SAAA,CAAU,EAAE,CAAA,EAAE;AAC5D;AAOO,SAAS,iBAAiB,MAAA,EAOsC;AACnE,EAAA,MAAM,IAAA,GAAmB;AAAA,IACrB,CAAC,GAAA,EAAK,CAAA,eAAA,EAAkB,MAAA,CAAO,OAAO,CAAA,CAAE,CAAA;AAAA,IACxC,CAAC,MAAA,EAAQ,MAAA,CAAO,OAAO,CAAA;AAAA,IACvB,CAAC,WAAA,EAAa,MAAA,CAAO,SAAS,CAAA;AAAA,IAC9B,CAAC,WAAA,EAAa,MAAA,CAAO,SAAS,CAAA;AAAA,IAC9B,CAAC,OAAO,QAAQ,CAAA;AAAA,IAChB,CAAC,aAAA,EAAe,MAAA,CAAO,gBAAgB;AAAA,GAC3C;AACA,EAAA,MAAM,UAAA,GAAa,OAAO,aAAA,IAAiB,IAAA,CAAK,MAAM,IAAA,CAAK,GAAA,KAAQ,GAAI,CAAA;AACvE,EAAA,OAAO;AAAA,IACH,IAAA,EAAM,WAAA;AAAA,IACN,IAAA;AAAA,IACA,SAAS,MAAA,CAAO,gBAAA;AAAA,IAChB;AAAA,GACJ;AACJ;AAMO,SAAS,cAAA,CACZ,OACA,UAAA,EACU;AACV,EAAA,MAAM,EAAA,GAAK,oBAAoB,KAAK,CAAA;AACpC,EAAA,MAAM,EAAA,GAAK,UAAU,UAAU,CAAA;AAC/B,EAAA,MAAM,MAAM,OAAA,CAAQ,IAAA,CAAK,SAAA,CAAU,EAAE,GAAG,EAAE,CAAA;AAC1C,EAAA,OAAO;AAAA,IACH,GAAG,KAAA;AAAA,IACH,EAAA;AAAA,IACA,GAAA,EAAK,UAAU,GAAG;AAAA,GACtB;AACJ;AAEO,SAAS,oBAAoB,KAAA,EAA+C;AAG/E,EAAA,MAAM,UAAA,GAAa,KAAK,SAAA,CAAU;AAAA,IAC9B,CAAA;AAAA,IACA,KAAA,CAAM,MAAA;AAAA,IACN,KAAA,CAAM,UAAA;AAAA,IACN,KAAA,CAAM,IAAA;AAAA,IACN,KAAA,CAAM,IAAA;AAAA,IACN,KAAA,CAAM;AAAA,GACT,CAAA;AACD,EAAA,OAAO,SAAA,CAAU,WAAA,CAAY,UAAA,CAAW,UAAU,CAAC,CAAC,CAAA;AACxD;AAMO,SAAS,oBAAoB,MAAA,EAQrB;AACX,EAAA,MAAM,EAAE,OAAA,EAAS,OAAA,EAAQ,GAAI,cAAA,CAAe,OAAO,QAAQ,CAAA;AAC3D,EAAA,MAAM,OAAO,gBAAA,CAAiB;AAAA,IAC1B,SAAS,MAAA,CAAO,OAAA;AAAA,IAChB,WAAW,MAAA,CAAO,SAAA;AAAA,IAClB,WAAW,MAAA,CAAO,SAAA;AAAA,IAClB,kBAAkB,MAAA,CAAO,gBAAA;AAAA,IACzB,kBAAkB,MAAA,CAAO,gBAAA;AAAA,IACzB,eAAe,MAAA,CAAO;AAAA,GACzB,CAAA;AACD,EAAA,MAAM,UAAA,GAAa,EAAE,GAAG,IAAA,EAAM,QAAQ,OAAA,EAAQ;AAC9C,EAAA,OAAO,cAAA,CAAe,YAAY,OAAO,CAAA;AAC7C;AAkBO,SAAS,iBAAiB,KAAA,EAAsC;AACnE,EAAA,IAAI,KAAA,CAAM,SAAS,WAAA,EAAa;AAC5B,IAAA,MAAM,IAAI,MAAM,oCAAoC,CAAA;AAAA,EACxD;AACA,EAAA,MAAM,GAAA,GAAM,CAAC,IAAA,KAAgC;AACzC,IAAA,KAAA,MAAW,CAAA,IAAK,MAAM,IAAA,EAAM;AACxB,MAAA,IAAI,CAAA,CAAE,CAAC,CAAA,KAAM,IAAA,IAAQ,CAAA,CAAE,SAAS,CAAA,EAAG,OAAO,CAAA,CAAE,CAAC,CAAA,IAAK,IAAA;AAAA,IACtD;AACA,IAAA,OAAO,IAAA;AAAA,EACX,CAAA;AACA,EAAA,MAAM,OAAA,GAAU,IAAI,MAAM,CAAA;AAC1B,EAAA,MAAM,SAAA,GAAY,IAAI,WAAW,CAAA;AACjC,EAAA,MAAM,SAAA,GAAY,IAAI,WAAW,CAAA;AACjC,EAAA,MAAM,WAAA,GAAc,IAAI,aAAa,CAAA;AACrC,EAAA,IAAI,CAAC,OAAA,IAAW,CAAC,aAAa,CAAC,SAAA,IAAa,CAAC,WAAA,EAAa;AACtD,IAAA,MAAM,IAAI,MAAM,oCAAoC,CAAA;AAAA,EACxD;AACA,EAAA,OAAO;AAAA,IACH,OAAA;AAAA,IACA,SAAA;AAAA,IACA,SAAA;AAAA,IACA,kBAAkB,KAAA,CAAM,OAAA;AAAA,IACxB,gBAAA,EAAkB,WAAA;AAAA,IAClB,SAAS,SAAA,KAAc,SAAA;AAAA,IACvB,eAAe,KAAA,CAAM,UAAA;AAAA,IACrB,aAAa,KAAA,CAAM,MAAA;AAAA,IACnB,SAAS,KAAA,CAAM;AAAA,GACnB;AACJ","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 return {\n address,\n device_id,\n device_pk,\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: device_pk === 'revoked',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n };\n}\n"]}
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"]}
package/package.json CHANGED
@@ -1,59 +1,62 @@
1
1
  {
2
- "name": "@orangecheck/lock-device",
3
- "version": "0.1.0",
4
- "description": "Device-key binding and Nostr kind-30078 directory publication for OC Lock.",
5
- "keywords": [
6
- "bitcoin",
7
- "bip322",
8
- "nostr",
9
- "encryption",
10
- "oc-lock",
11
- "orangecheck"
12
- ],
13
- "author": "OrangeCheck",
14
- "license": "MIT",
15
- "repository": {
16
- "type": "git",
17
- "url": "https://github.com/orangecheck/oc-packages.git",
18
- "directory": "lock-device"
19
- },
20
- "homepage": "https://github.com/orangecheck/oc-lock-protocol",
21
- "bugs": {
22
- "url": "https://github.com/orangecheck/oc-lock-protocol/issues"
23
- },
24
- "main": "./dist/index.js",
25
- "module": "./dist/index.mjs",
26
- "types": "./dist/index.d.ts",
27
- "exports": {
28
- ".": {
29
- "types": "./dist/index.d.ts",
30
- "import": "./dist/index.mjs",
31
- "require": "./dist/index.js"
32
- }
33
- },
34
- "files": [
35
- "dist",
36
- "src",
37
- "README.md",
38
- "LICENSE"
39
- ],
40
- "scripts": {
41
- "build": "tsup",
42
- "dev": "tsup --watch",
43
- "type-check": "tsc --noEmit",
44
- "test": "vitest run",
45
- "test:watch": "vitest",
46
- "clean": "rm -rf dist",
47
- "prepublishOnly": "npm run clean && npm run build"
48
- },
49
- "dependencies": {
50
- "@noble/curves": "^1.6.0",
51
- "@orangecheck/lock-crypto": "^0.1.0"
52
- },
53
- "devDependencies": {
54
- "@types/node": "^22.10.2",
55
- "tsup": "^8.3.5",
56
- "typescript": "^5.7.2",
57
- "vitest": "^3.2.4"
2
+ "name": "@orangecheck/lock-device",
3
+ "version": "0.2.1",
4
+ "description": "Device-key binding and Nostr kind-30078 directory publication for OC Lock.",
5
+ "keywords": [
6
+ "bitcoin",
7
+ "bip322",
8
+ "nostr",
9
+ "encryption",
10
+ "oc-lock",
11
+ "orangecheck"
12
+ ],
13
+ "author": "OrangeCheck",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/orangecheck/oc-packages.git",
18
+ "directory": "lock-device"
19
+ },
20
+ "homepage": "https://github.com/orangecheck/oc-lock-protocol",
21
+ "bugs": {
22
+ "url": "https://github.com/orangecheck/oc-lock-protocol/issues"
23
+ },
24
+ "main": "./dist/index.js",
25
+ "module": "./dist/index.mjs",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.mjs",
31
+ "require": "./dist/index.js"
58
32
  }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsup",
42
+ "dev": "tsup --watch",
43
+ "type-check": "tsc --noEmit",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "clean": "rm -rf dist",
47
+ "prepublishOnly": "npm run clean && npm run build"
48
+ },
49
+ "dependencies": {
50
+ "@noble/curves": "^1.6.0",
51
+ "@orangecheck/lock-crypto": "^0.1.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^22.10.2",
55
+ "tsup": "^8.3.5",
56
+ "typescript": "^5.7.2",
57
+ "vitest": "^4.1.11"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
59
62
  }
package/src/index.ts CHANGED
@@ -215,15 +215,92 @@ export function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {
215
215
  if (!address || !device_id || !device_pk || !binding_sig) {
216
216
  throw new Error('device event missing required tags');
217
217
  }
218
+
219
+ // The BIP-322 signature covers `event.content` — the statement — and nothing
220
+ // else. Tags are an index the relay lets anyone write, so a value read from a
221
+ // tag has been authenticated by nobody.
222
+ //
223
+ // Skipping this check was a total confidentiality break, and it needed no key
224
+ // material: republish a victim's device event with their real content and
225
+ // real binding_sig, change only the `device_pk` TAG to your own X25519 key.
226
+ // verifyBip322(content, sig, addr) passes — every signed byte is genuine —
227
+ // and the sender then seals the content key to the attacker's key, because
228
+ // the recipient record was built from the tag. Proven against the published
229
+ // package before this existed.
230
+ //
231
+ // So the statement is the source of truth and the tags must agree with it.
232
+ const signed = parseBindingStatement(event.content);
233
+ if (!signed) {
234
+ throw new Error('device event content is not a v2 bind or revoke statement');
235
+ }
236
+ if (signed.address !== address) {
237
+ throw new Error(
238
+ `device event addr tag (${address}) does not match the signed statement (${signed.address})`,
239
+ );
240
+ }
241
+ if (signed.device_id !== device_id) {
242
+ throw new Error(
243
+ `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`,
244
+ );
245
+ }
246
+ if (signed.kind === 'bind') {
247
+ if (signed.device_pk !== device_pk) {
248
+ throw new Error(
249
+ `device event device_pk tag does not match the signed statement — ` +
250
+ `tag says ${device_pk}, the signature covers ${signed.device_pk}`,
251
+ );
252
+ }
253
+ } else if (device_pk !== 'revoked') {
254
+ throw new Error(
255
+ `device event carries a revocation statement but its device_pk tag is ${device_pk}, not "revoked"`,
256
+ );
257
+ }
258
+
218
259
  return {
219
- address,
220
- device_id,
221
- device_pk,
260
+ address: signed.address,
261
+ device_id: signed.device_id,
262
+ // Take the key from the SIGNED bytes, never the tag.
263
+ device_pk: signed.kind === 'bind' ? signed.device_pk : 'revoked',
222
264
  bindingStatement: event.content,
223
265
  bindingSigBase64: binding_sig,
224
- revoked: device_pk === 'revoked',
266
+ revoked: signed.kind === 'revoke',
225
267
  createdAtUnix: event.created_at,
226
268
  nostrPubkey: event.pubkey,
227
269
  eventId: event.id,
228
270
  };
229
271
  }
272
+
273
+ /**
274
+ * 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
276
+ * treats as an unusable record.
277
+ */
278
+ function parseBindingStatement(
279
+ content: string,
280
+ ):
281
+ | { kind: 'bind'; address: string; device_pk: string; device_id: string }
282
+ | { kind: 'revoke'; address: string; device_id: string }
283
+ | null {
284
+ const lines = content.split('\n');
285
+ const header = lines[0];
286
+ const field = (name: string): string | null => {
287
+ const prefix = `${name}: `;
288
+ for (const l of lines.slice(1)) {
289
+ if (l.startsWith(prefix)) return l.slice(prefix.length);
290
+ }
291
+ return null;
292
+ };
293
+ const address = field('address');
294
+ const device_id = field('device_id');
295
+ if (!address || !device_id) return null;
296
+
297
+ if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {
298
+ const device_pk = field('device_pk');
299
+ if (!device_pk) return null;
300
+ return { kind: 'bind', address, device_pk, device_id };
301
+ }
302
+ if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {
303
+ return { kind: 'revoke', address, device_id };
304
+ }
305
+ return null;
306
+ }
@@ -0,0 +1,101 @@
1
+ // The BIP-322 signature covers event.content and nothing else. Tags are an
2
+ // index a relay lets anyone write, so a value read from a tag is authenticated
3
+ // by nobody.
4
+ //
5
+ // Before parseDeviceEvent cross-checked them, this was a total confidentiality
6
+ // break that needed no key material: republish a victim's device event with
7
+ // their REAL content and REAL binding_sig, change only the device_pk tag.
8
+ // verifyBip322(content, sig, addr) passes — every signed byte is genuine — and
9
+ // the sender seals the content key to the attacker, because the recipient
10
+ // record was built from the tag.
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { buildBindingStatement, buildRevocationStatement, parseDeviceEvent } from './index.js';
14
+
15
+ const VICTIM = 'bc1qvictim0000000000000000000000000000000';
16
+ const VICTIM_PK = 'aa'.repeat(32);
17
+ const ATTACKER_PK = 'bb'.repeat(32);
18
+ const DEVICE = 'victim-laptop';
19
+
20
+ const statement = buildBindingStatement({
21
+ address: VICTIM,
22
+ device_pk: VICTIM_PK,
23
+ device_id: DEVICE,
24
+ created_at: '2026-09-01T00:00:00Z',
25
+ });
26
+
27
+ function event(tags: string[][], content = statement) {
28
+ return {
29
+ id: 'f'.repeat(64),
30
+ pubkey: 'c'.repeat(64),
31
+ kind: 30078,
32
+ created_at: 1_780_000_000,
33
+ content,
34
+ tags,
35
+ sig: 'd'.repeat(128),
36
+ };
37
+ }
38
+
39
+ const honestTags = [
40
+ ['d', `oc-lock:device:${VICTIM}`],
41
+ ['addr', VICTIM],
42
+ ['device_id', DEVICE],
43
+ ['device_pk', VICTIM_PK],
44
+ ['binding_sig', 'VICTIMS_REAL_SIGNATURE'],
45
+ ];
46
+
47
+ describe('parseDeviceEvent trusts the signed statement, not the tags', () => {
48
+ it('accepts an honest record and returns the signed key', () => {
49
+ const p = parseDeviceEvent(event(honestTags));
50
+ expect(p.device_pk).toBe(VICTIM_PK);
51
+ expect(p.address).toBe(VICTIM);
52
+ expect(p.revoked).toBe(false);
53
+ });
54
+
55
+ // The attack: one tag changed, every signed byte genuine.
56
+ it('REJECTS a substituted device_pk tag', () => {
57
+ const tags = honestTags.map((t) =>
58
+ t[0] === 'device_pk' ? ['device_pk', ATTACKER_PK] : t,
59
+ );
60
+ expect(() => parseDeviceEvent(event(tags))).toThrow(/device_pk tag does not match/i);
61
+ });
62
+
63
+ it('REJECTS a substituted addr tag', () => {
64
+ const tags = honestTags.map((t) => (t[0] === 'addr' ? ['addr', 'bc1qattacker'] : t));
65
+ expect(() => parseDeviceEvent(event(tags))).toThrow(/addr tag .* does not match/i);
66
+ });
67
+
68
+ it('REJECTS a substituted device_id tag', () => {
69
+ const tags = honestTags.map((t) =>
70
+ t[0] === 'device_id' ? ['device_id', 'other-device'] : t,
71
+ );
72
+ expect(() => parseDeviceEvent(event(tags))).toThrow(/device_id tag .* does not match/i);
73
+ });
74
+
75
+ 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);
77
+ });
78
+
79
+ it('accepts a genuine revocation and reports it revoked', () => {
80
+ const revoke = buildRevocationStatement({
81
+ address: VICTIM,
82
+ device_id: DEVICE,
83
+ revoked_at: '2026-09-02T00:00:00Z',
84
+ });
85
+ const tags = honestTags.map((t) => (t[0] === 'device_pk' ? ['device_pk', 'revoked'] : t));
86
+ const p = parseDeviceEvent(event(tags, revoke));
87
+ expect(p.revoked).toBe(true);
88
+ expect(p.device_pk).toBe('revoked');
89
+ });
90
+
91
+ // A revocation statement smuggled in under a live-looking key tag would
92
+ // otherwise read as an active device.
93
+ it('REJECTS a revocation statement whose device_pk tag claims a live key', () => {
94
+ const revoke = buildRevocationStatement({
95
+ address: VICTIM,
96
+ device_id: DEVICE,
97
+ revoked_at: '2026-09-02T00:00:00Z',
98
+ });
99
+ expect(() => parseDeviceEvent(event(honestTags, revoke))).toThrow(/not "revoked"/i);
100
+ });
101
+ });