@orangecheck/lock-device 0.1.0 → 0.2.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 +4 -0
- package/dist/index.js +53 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +53 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +59 -56
- package/src/index.ts +81 -4
- package/src/tag-substitution.test.ts +101 -0
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
package/dist/index.mjs.map
CHANGED
|
@@ -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":";;;;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;AAeA,EAAA,MAAM,MAAA,GAAS,qBAAA,CAAsB,KAAA,CAAM,OAAO,CAAA;AAClD,EAAA,IAAI,CAAC,MAAA,EAAQ;AACT,IAAA,MAAM,IAAI,MAAM,2DAA2D,CAAA;AAAA,EAC/E;AACA,EAAA,IAAI,MAAA,CAAO,YAAY,OAAA,EAAS;AAC5B,IAAA,MAAM,IAAI,KAAA;AAAA,MACN,CAAA,uBAAA,EAA0B,OAAO,CAAA,uCAAA,EAA0C,MAAA,CAAO,OAAO,CAAA,CAAA;AAAA,KAC7F;AAAA,EACJ;AACA,EAAA,IAAI,MAAA,CAAO,cAAc,SAAA,EAAW;AAChC,IAAA,MAAM,IAAI,KAAA;AAAA,MACN,CAAA,4BAAA,EAA+B,SAAS,CAAA,uCAAA,EAA0C,MAAA,CAAO,SAAS,CAAA,CAAA;AAAA,KACtG;AAAA,EACJ;AACA,EAAA,IAAI,MAAA,CAAO,SAAS,MAAA,EAAQ;AACxB,IAAA,IAAI,MAAA,CAAO,cAAc,SAAA,EAAW;AAChC,MAAA,MAAM,IAAI,KAAA;AAAA,QACN,CAAA,+EAAA,EACgB,SAAS,CAAA,uBAAA,EAA0B,MAAA,CAAO,SAAS,CAAA;AAAA,OACvE;AAAA,IACJ;AAAA,EACJ,CAAA,MAAA,IAAW,cAAc,SAAA,EAAW;AAChC,IAAA,MAAM,IAAI,KAAA;AAAA,MACN,wEAAwE,SAAS,CAAA,eAAA;AAAA,KACrF;AAAA,EACJ;AAEA,EAAA,OAAO;AAAA,IACH,SAAS,MAAA,CAAO,OAAA;AAAA,IAChB,WAAW,MAAA,CAAO,SAAA;AAAA;AAAA,IAElB,SAAA,EAAW,MAAA,CAAO,IAAA,KAAS,MAAA,GAAS,OAAO,SAAA,GAAY,SAAA;AAAA,IACvD,kBAAkB,KAAA,CAAM,OAAA;AAAA,IACxB,gBAAA,EAAkB,WAAA;AAAA,IAClB,OAAA,EAAS,OAAO,IAAA,KAAS,QAAA;AAAA,IACzB,eAAe,KAAA,CAAM,UAAA;AAAA,IACrB,aAAa,KAAA,CAAM,MAAA;AAAA,IACnB,SAAS,KAAA,CAAM;AAAA,GACnB;AACJ;AAOA,SAAS,sBACL,OAAA,EAIO;AACP,EAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,KAAA,CAAM,IAAI,CAAA;AAChC,EAAA,MAAM,MAAA,GAAS,MAAM,CAAC,CAAA;AACtB,EAAA,MAAM,KAAA,GAAQ,CAAC,IAAA,KAAgC;AAC3C,IAAA,MAAM,MAAA,GAAS,GAAG,IAAI,CAAA,EAAA,CAAA;AACtB,IAAA,KAAA,MAAW,CAAA,IAAK,KAAA,CAAM,KAAA,CAAM,CAAC,CAAA,EAAG;AAC5B,MAAA,IAAI,CAAA,CAAE,WAAW,MAAM,CAAA,SAAU,CAAA,CAAE,KAAA,CAAM,OAAO,MAAM,CAAA;AAAA,IAC1D;AACA,IAAA,OAAO,IAAA;AAAA,EACX,CAAA;AACA,EAAA,MAAM,OAAA,GAAU,MAAM,SAAS,CAAA;AAC/B,EAAA,MAAM,SAAA,GAAY,MAAM,WAAW,CAAA;AACnC,EAAA,IAAI,CAAC,OAAA,IAAW,CAAC,SAAA,EAAW,OAAO,IAAA;AAEnC,EAAA,IAAI,MAAA,KAAW,CAAA,oBAAA,EAAuB,eAAe,CAAA,CAAA,EAAI;AACrD,IAAA,MAAM,SAAA,GAAY,MAAM,WAAW,CAAA;AACnC,IAAA,IAAI,CAAC,WAAW,OAAO,IAAA;AACvB,IAAA,OAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,OAAA,EAAS,WAAW,SAAA,EAAU;AAAA,EACzD;AACA,EAAA,IAAI,MAAA,KAAW,CAAA,sBAAA,EAAyB,eAAe,CAAA,CAAA,EAAI;AACvD,IAAA,OAAO,EAAE,IAAA,EAAM,QAAA,EAAU,OAAA,EAAS,SAAA,EAAU;AAAA,EAChD;AACA,EAAA,OAAO,IAAA;AACX","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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.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"
|
|
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": "file:../lock-crypto"
|
|
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
|
-
|
|
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:
|
|
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
|
+
});
|