@orangecheck/lock-device 0.3.0 → 0.4.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 +1 -1
- package/dist/index.d.mts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +26 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/live.test.ts +115 -0
- package/src/live.ts +41 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @orangecheck/lock-device
|
|
2
2
|
|
|
3
|
-
> **Full reference:** [
|
|
3
|
+
> **Full reference:** [ochk.io/docs/sdk/lock-device](https://ochk.io/docs/sdk/lock-device) — auto-generated from the TypeScript source on every release.
|
|
4
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
5
|
|
|
6
6
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
declare function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[];
|
|
2
2
|
|
|
3
|
+
declare function signedTime(statement: string, field: 'created_at' | 'revoked_at'): number | null;
|
|
4
|
+
declare function liveDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[];
|
|
5
|
+
|
|
3
6
|
declare const DEVICE_KIND = 30078;
|
|
4
7
|
declare const BINDING_VERSION = "v2";
|
|
5
8
|
declare const BINDING_VERSION_V3 = "v3";
|
|
@@ -70,4 +73,4 @@ interface ParsedDeviceEvent {
|
|
|
70
73
|
}
|
|
71
74
|
declare function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent;
|
|
72
75
|
|
|
73
|
-
export { BINDING_VERSION, BINDING_VERSION_V3, DEVICE_KIND, type DeviceKeyPair, type NostrEvent, type ParsedDeviceEvent, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
|
|
76
|
+
export { BINDING_VERSION, BINDING_VERSION_V3, DEVICE_KIND, type DeviceKeyPair, type NostrEvent, type ParsedDeviceEvent, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, liveDevices, parseDeviceEvent, signNostrEvent, signedTime };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
declare function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[];
|
|
2
2
|
|
|
3
|
+
declare function signedTime(statement: string, field: 'created_at' | 'revoked_at'): number | null;
|
|
4
|
+
declare function liveDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[];
|
|
5
|
+
|
|
3
6
|
declare const DEVICE_KIND = 30078;
|
|
4
7
|
declare const BINDING_VERSION = "v2";
|
|
5
8
|
declare const BINDING_VERSION_V3 = "v3";
|
|
@@ -70,4 +73,4 @@ interface ParsedDeviceEvent {
|
|
|
70
73
|
}
|
|
71
74
|
declare function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent;
|
|
72
75
|
|
|
73
|
-
export { BINDING_VERSION, BINDING_VERSION_V3, DEVICE_KIND, type DeviceKeyPair, type NostrEvent, type ParsedDeviceEvent, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
|
|
76
|
+
export { BINDING_VERSION, BINDING_VERSION_V3, DEVICE_KIND, type DeviceKeyPair, type NostrEvent, type ParsedDeviceEvent, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, liveDevices, parseDeviceEvent, signNostrEvent, signedTime };
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,31 @@ function authorizedDevices(records) {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// src/live.ts
|
|
31
|
+
function signedTime(statement, field) {
|
|
32
|
+
const line = statement.split("\n").find((l) => l.startsWith(`${field}: `));
|
|
33
|
+
if (!line) return null;
|
|
34
|
+
const t = Date.parse(line.slice(field.length + 2));
|
|
35
|
+
return Number.isFinite(t) ? t : null;
|
|
36
|
+
}
|
|
37
|
+
function liveDevices(records) {
|
|
38
|
+
const key = (r) => `${r.address}
|
|
39
|
+
${r.device_id}`;
|
|
40
|
+
const revokedAt = /* @__PURE__ */ new Map();
|
|
41
|
+
for (const r of records) {
|
|
42
|
+
if (!r.revoked) continue;
|
|
43
|
+
const at = signedTime(r.bindingStatement, "revoked_at");
|
|
44
|
+
if (at !== null) revokedAt.set(key(r), Math.max(revokedAt.get(key(r)) ?? at, at));
|
|
45
|
+
}
|
|
46
|
+
return records.filter((r) => {
|
|
47
|
+
if (r.revoked) return false;
|
|
48
|
+
const cut = revokedAt.get(key(r));
|
|
49
|
+
if (cut === void 0) return true;
|
|
50
|
+
const bound = signedTime(r.bindingStatement, "created_at");
|
|
51
|
+
return bound !== null && bound > cut;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
// src/index.ts
|
|
31
56
|
var DEVICE_KIND = 30078;
|
|
32
57
|
var BINDING_VERSION = "v2";
|
|
@@ -236,7 +261,9 @@ exports.computeNostrEventId = computeNostrEventId;
|
|
|
236
261
|
exports.deriveNostrKey = deriveNostrKey;
|
|
237
262
|
exports.finalizeDeviceEvent = finalizeDeviceEvent;
|
|
238
263
|
exports.generateDeviceKey = generateDeviceKey;
|
|
264
|
+
exports.liveDevices = liveDevices;
|
|
239
265
|
exports.parseDeviceEvent = parseDeviceEvent;
|
|
240
266
|
exports.signNostrEvent = signNostrEvent;
|
|
267
|
+
exports.signedTime = signedTime;
|
|
241
268
|
//# sourceMappingURL=index.js.map
|
|
242
269
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/authorize.ts","../src/index.ts"],"names":["generateX25519KeyPair","randomBytesN","hexEncode","hkdfSha256","utf8Encode","schnorr","hexDecode","sha256Bytes"],"mappings":"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","file":"index.js","sourcesContent":["// Which Nostr pubkeys a set of device records authorizes. See SPEC §3.4.\n\nimport type { ParsedDeviceEvent } from './index.js';\n\n/**\n * Select the device records whose `nostrPubkey` may act for their address.\n *\n * `records` MUST already be BIP-322-verified, non-revoked bind records for one\n * address. A v3 record signs its Nostr pubkey, so it is always authorized. A v2\n * record does not, so its pubkey is only the event author's claim: it is\n * authorized only while its `device_id` has no v3 record and appears under\n * exactly one Nostr pubkey. Two pubkeys carrying one v2 device authorize neither.\n */\nexport function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {\n const v3Ids = new Set<string>();\n const v2Pubkeys = new Map<string, Set<string>>();\n for (const r of records) {\n if (r.revoked) continue;\n if (r.bindingVersion === 'v3') {\n v3Ids.add(r.device_id);\n } else {\n const set = v2Pubkeys.get(r.device_id) ?? new Set<string>();\n set.add(r.nostrPubkey);\n v2Pubkeys.set(r.device_id, set);\n }\n }\n return records.filter((r) => {\n if (r.revoked) return false;\n if (r.bindingVersion === 'v3') return true;\n if (v3Ids.has(r.device_id)) return false;\n return v2Pubkeys.get(r.device_id)?.size === 1;\n });\n}\n","// Device key binding and Nostr directory publication.\n// See SPEC.md §3.\n\nimport { schnorr } from '@noble/curves/secp256k1';\nimport {\n generateX25519KeyPair,\n hexDecode,\n hexEncode,\n hkdfSha256,\n randomBytesN,\n sha256Bytes,\n utf8Encode,\n} from '@orangecheck/lock-crypto';\n\nexport const DEVICE_KIND = 30078;\nexport const BINDING_VERSION = 'v2';\n/** Bind statement version that also signs the device's Nostr inbox pubkey. */\nexport const BINDING_VERSION_V3 = 'v3';\n\nexport interface DeviceKeyPair {\n device_id: string;\n device_pk: string; // 32-byte hex\n device_sk: Uint8Array; // 32 bytes\n created_at: string; // iso8601\n}\n\nexport function generateDeviceKey(): DeviceKeyPair {\n const kp = generateX25519KeyPair();\n const deviceIdBytes = randomBytesN(16);\n return {\n device_id: hexEncode(deviceIdBytes),\n device_pk: hexEncode(kp.public),\n device_sk: kp.secret,\n created_at: new Date().toISOString(),\n };\n}\n\nexport function buildBindingStatement(params: {\n address: string;\n device_pk: string;\n device_id: string;\n created_at: string;\n /** The device's Nostr pubkey (deriveNostrKey). Present ⇒ a v3 statement. */\n nostr_pk?: string;\n}): string {\n // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF\n // beyond the last field's. Order is fixed.\n const lines = params.nostr_pk\n ? [\n `oc-lock:device-bind:${BINDING_VERSION_V3}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `nostr_pk: ${params.nostr_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ]\n : [\n `oc-lock:device-bind:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\nexport function buildRevocationStatement(params: {\n address: string;\n device_id: string;\n revoked_at: string;\n}): string {\n const lines = [\n `oc-lock:device-revoke:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_id: ${params.device_id}`,\n `revoked_at: ${params.revoked_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\n// ---------------------------------------------------------------------------\n// Nostr event helpers\n// ---------------------------------------------------------------------------\n\nexport interface NostrEvent {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/**\n * Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.\n * Returns a 32-byte private key (hex).\n */\nexport function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {\n const sk = hkdfSha256(\n deviceSk,\n utf8Encode('oc-lock/v2/nostr-key'),\n utf8Encode('nostr-sk'),\n 32\n );\n const pk = schnorr.getPublicKey(sk);\n return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };\n}\n\n/**\n * Build a kind-30078 addressable event announcing a device binding.\n * The caller must supply the BIP-322 signature produced by the user's Bitcoin\n * wallet (we cannot sign BIP-322 in this package — that requires a wallet).\n */\nexport function buildDeviceEvent(params: {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {\n const tags: string[][] = [\n ['d', `oc-lock:device:${params.address}`],\n ['addr', params.address],\n ['device_id', params.device_id],\n ['device_pk', params.device_pk],\n ['alg', 'x25519'],\n ['binding_sig', params.bindingSigBase64],\n ];\n const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);\n return {\n kind: DEVICE_KIND,\n tags,\n content: params.bindingStatement,\n created_at,\n } as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };\n}\n\n/**\n * Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces\n * the full NostrEvent with id and sig.\n */\nexport function signNostrEvent(\n event: Omit<NostrEvent, 'id' | 'sig'>,\n nostrSkHex: string\n): NostrEvent {\n const id = computeNostrEventId(event);\n const sk = hexDecode(nostrSkHex);\n const sig = schnorr.sign(hexDecode(id), sk);\n return {\n ...event,\n id,\n sig: hexEncode(sig),\n };\n}\n\nexport function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {\n // Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).\n // The array must be serialized in *exactly* this form with no whitespace.\n const serialized = JSON.stringify([\n 0,\n event.pubkey,\n event.created_at,\n event.kind,\n event.tags,\n event.content,\n ]);\n return hexEncode(sha256Bytes(utf8Encode(serialized)));\n}\n\n/**\n * Finalize a device event: derive Nostr key from device_sk, attach pubkey,\n * compute id, sign. Returns a fully-signed NIP-01 event ready to publish.\n */\nexport function finalizeDeviceEvent(params: {\n deviceSk: Uint8Array;\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): NostrEvent {\n const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);\n const base = buildDeviceEvent({\n address: params.address,\n device_id: params.device_id,\n device_pk: params.device_pk,\n bindingStatement: params.bindingStatement,\n bindingSigBase64: params.bindingSigBase64,\n createdAtUnix: params.createdAtUnix,\n });\n const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;\n return signNostrEvent(withPubkey, nostrSk);\n}\n\n// ---------------------------------------------------------------------------\n// Parsing\n// ---------------------------------------------------------------------------\n\nexport interface ParsedDeviceEvent {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n revoked: boolean;\n createdAtUnix: number;\n nostrPubkey: string;\n eventId: string;\n /** 'v3' binds signed the Nostr pubkey; 'v2' binds and revocations did not. */\n bindingVersion: 'v2' | 'v3';\n}\n\nexport function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {\n if (event.kind !== DEVICE_KIND) {\n throw new Error('not a device event (kind mismatch)');\n }\n const tag = (name: string): string | null => {\n for (const t of event.tags) {\n if (t[0] === name && t.length > 1) return t[1] ?? null;\n }\n return null;\n };\n const address = tag('addr');\n const device_id = tag('device_id');\n const device_pk = tag('device_pk');\n const binding_sig = tag('binding_sig');\n if (!address || !device_id || !device_pk || !binding_sig) {\n throw new Error('device event missing required tags');\n }\n\n // The BIP-322 signature covers `event.content` — the statement — and nothing\n // else. Tags are an index the relay lets anyone write, so a value read from a\n // tag has been authenticated by nobody.\n //\n // Skipping this check was a total confidentiality break, and it needed no key\n // material: republish a victim's device event with their real content and\n // real binding_sig, change only the `device_pk` TAG to your own X25519 key.\n // verifyBip322(content, sig, addr) passes — every signed byte is genuine —\n // and the sender then seals the content key to the attacker's key, because\n // the recipient record was built from the tag. Proven against the published\n // package before this existed.\n //\n // So the statement is the source of truth and the tags must agree with it.\n const signed = parseBindingStatement(event.content);\n if (!signed) {\n throw new Error('device event content is not a v2/v3 bind or revoke statement');\n }\n if (signed.address !== address) {\n throw new Error(\n `device event addr tag (${address}) does not match the signed statement (${signed.address})`,\n );\n }\n // SPEC §3.3 / §3.6: the record lives at `oc-lock:device:<addr>` or the\n // per-device `oc-lock:device:<addr>:<device_id>`. Lookups filter on `d`, so a\n // record for one address filed under another's slot must not parse.\n const d = tag('d');\n if (\n d !== `oc-lock:device:${signed.address}` &&\n d !== `oc-lock:device:${signed.address}:${signed.device_id}`\n ) {\n throw new Error(\n `device event d tag (${d}) is not the slot for the signed address (${signed.address})`,\n );\n }\n if (signed.device_id !== device_id) {\n throw new Error(\n `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`,\n );\n }\n if (signed.kind === 'bind') {\n if (signed.device_pk !== device_pk) {\n throw new Error(\n `device event device_pk tag does not match the signed statement — ` +\n `tag says ${device_pk}, the signature covers ${signed.device_pk}`,\n );\n }\n // A v3 statement names the one Nostr key allowed to carry it.\n if (signed.nostr_pk !== null && signed.nostr_pk !== event.pubkey) {\n throw new Error(\n `device event pubkey (${event.pubkey}) does not match the signed nostr_pk (${signed.nostr_pk})`,\n );\n }\n } else if (device_pk !== 'revoked') {\n throw new Error(\n `device event carries a revocation statement but its device_pk tag is ${device_pk}, not \"revoked\"`,\n );\n }\n\n return {\n address: signed.address,\n device_id: signed.device_id,\n // Take the key from the SIGNED bytes, never the tag.\n device_pk: signed.kind === 'bind' ? signed.device_pk : 'revoked',\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: signed.kind === 'revoke',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n bindingVersion: signed.kind === 'bind' && signed.nostr_pk !== null ? 'v3' : 'v2',\n };\n}\n\n/**\n * Read back the fields the signature actually commits to. Returns null for\n * anything that is not a well-formed v2/v3 bind or v2 revoke statement, which the caller\n * treats as an unusable record.\n */\nfunction parseBindingStatement(\n content: string,\n):\n | {\n kind: 'bind';\n address: string;\n device_pk: string;\n device_id: string;\n nostr_pk: string | null;\n }\n | { kind: 'revoke'; address: string; device_id: string }\n | null {\n const lines = content.split('\\n');\n const header = lines[0];\n const field = (name: string): string | null => {\n const prefix = `${name}: `;\n for (const l of lines.slice(1)) {\n if (l.startsWith(prefix)) return l.slice(prefix.length);\n }\n return null;\n };\n const address = field('address');\n const device_id = field('device_id');\n if (!address || !device_id) return null;\n\n if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {\n const device_pk = field('device_pk');\n if (!device_pk) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk: null };\n }\n if (header === `oc-lock:device-bind:${BINDING_VERSION_V3}`) {\n const device_pk = field('device_pk');\n const nostr_pk = field('nostr_pk');\n if (!device_pk || !nostr_pk || !/^[0-9a-f]{64}$/.test(nostr_pk)) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk };\n }\n if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {\n return { kind: 'revoke', address, device_id };\n }\n return null;\n}\n\nexport { authorizedDevices } from './authorize.js';\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/authorize.ts","../src/live.ts","../src/index.ts"],"names":["generateX25519KeyPair","randomBytesN","hexEncode","hkdfSha256","utf8Encode","schnorr","hexDecode","sha256Bytes"],"mappings":"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","file":"index.js","sourcesContent":["// Which Nostr pubkeys a set of device records authorizes. See SPEC §3.4.\n\nimport type { ParsedDeviceEvent } from './index.js';\n\n/**\n * Select the device records whose `nostrPubkey` may act for their address.\n *\n * `records` MUST already be BIP-322-verified, non-revoked bind records for one\n * address. A v3 record signs its Nostr pubkey, so it is always authorized. A v2\n * record does not, so its pubkey is only the event author's claim: it is\n * authorized only while its `device_id` has no v3 record and appears under\n * exactly one Nostr pubkey. Two pubkeys carrying one v2 device authorize neither.\n */\nexport function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {\n const v3Ids = new Set<string>();\n const v2Pubkeys = new Map<string, Set<string>>();\n for (const r of records) {\n if (r.revoked) continue;\n if (r.bindingVersion === 'v3') {\n v3Ids.add(r.device_id);\n } else {\n const set = v2Pubkeys.get(r.device_id) ?? new Set<string>();\n set.add(r.nostrPubkey);\n v2Pubkeys.set(r.device_id, set);\n }\n }\n return records.filter((r) => {\n if (r.revoked) return false;\n if (r.bindingVersion === 'v3') return true;\n if (v3Ids.has(r.device_id)) return false;\n return v2Pubkeys.get(r.device_id)?.size === 1;\n });\n}\n","// Which device records are still live once revocations are applied. See OC Lock\n// LIFECYCLE §1.1 and OC Chat SPEC §1.\n\nimport type { ParsedDeviceEvent } from './index.js';\n\n/** A timestamp line from a signed device statement, as epoch ms, or null. */\nexport function signedTime(statement: string, field: 'created_at' | 'revoked_at'): number | null {\n const line = statement.split('\\n').find((l) => l.startsWith(`${field}: `));\n if (!line) return null;\n const t = Date.parse(line.slice(field.length + 2));\n return Number.isFinite(t) ? t : null;\n}\n\n/**\n * The bind records still live after revocations.\n *\n * `records` MUST already be BIP-322-verified, both binds and revocations: a\n * revocation's authority is the address's signature, so one that was not\n * verified must not be passed in. A revocation retires every bind of its\n * `(address, device_id)` whose signed `created_at` is not later than its signed\n * `revoked_at`, whichever Nostr key carried either record and whichever relay\n * served it. Relay replacement is not needed, so a lost device can be revoked\n * from another device or the wallet, and a relay that missed the revocation\n * cannot keep the device alive. A bind signed after the revocation stays live.\n */\nexport function liveDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {\n const key = (r: ParsedDeviceEvent) => `${r.address}\\n${r.device_id}`;\n const revokedAt = new Map<string, number>();\n for (const r of records) {\n if (!r.revoked) continue;\n const at = signedTime(r.bindingStatement, 'revoked_at');\n if (at !== null) revokedAt.set(key(r), Math.max(revokedAt.get(key(r)) ?? at, at));\n }\n return records.filter((r) => {\n if (r.revoked) return false;\n const cut = revokedAt.get(key(r));\n if (cut === undefined) return true;\n const bound = signedTime(r.bindingStatement, 'created_at');\n return bound !== null && bound > cut;\n });\n}\n","// Device key binding and Nostr directory publication.\n// See SPEC.md §3.\n\nimport { schnorr } from '@noble/curves/secp256k1';\nimport {\n generateX25519KeyPair,\n hexDecode,\n hexEncode,\n hkdfSha256,\n randomBytesN,\n sha256Bytes,\n utf8Encode,\n} from '@orangecheck/lock-crypto';\n\nexport const DEVICE_KIND = 30078;\nexport const BINDING_VERSION = 'v2';\n/** Bind statement version that also signs the device's Nostr inbox pubkey. */\nexport const BINDING_VERSION_V3 = 'v3';\n\nexport interface DeviceKeyPair {\n device_id: string;\n device_pk: string; // 32-byte hex\n device_sk: Uint8Array; // 32 bytes\n created_at: string; // iso8601\n}\n\nexport function generateDeviceKey(): DeviceKeyPair {\n const kp = generateX25519KeyPair();\n const deviceIdBytes = randomBytesN(16);\n return {\n device_id: hexEncode(deviceIdBytes),\n device_pk: hexEncode(kp.public),\n device_sk: kp.secret,\n created_at: new Date().toISOString(),\n };\n}\n\nexport function buildBindingStatement(params: {\n address: string;\n device_pk: string;\n device_id: string;\n created_at: string;\n /** The device's Nostr pubkey (deriveNostrKey). Present ⇒ a v3 statement. */\n nostr_pk?: string;\n}): string {\n // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF\n // beyond the last field's. Order is fixed.\n const lines = params.nostr_pk\n ? [\n `oc-lock:device-bind:${BINDING_VERSION_V3}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `nostr_pk: ${params.nostr_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ]\n : [\n `oc-lock:device-bind:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\nexport function buildRevocationStatement(params: {\n address: string;\n device_id: string;\n revoked_at: string;\n}): string {\n const lines = [\n `oc-lock:device-revoke:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_id: ${params.device_id}`,\n `revoked_at: ${params.revoked_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\n// ---------------------------------------------------------------------------\n// Nostr event helpers\n// ---------------------------------------------------------------------------\n\nexport interface NostrEvent {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/**\n * Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.\n * Returns a 32-byte private key (hex).\n */\nexport function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {\n const sk = hkdfSha256(\n deviceSk,\n utf8Encode('oc-lock/v2/nostr-key'),\n utf8Encode('nostr-sk'),\n 32\n );\n const pk = schnorr.getPublicKey(sk);\n return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };\n}\n\n/**\n * Build a kind-30078 addressable event announcing a device binding.\n * The caller must supply the BIP-322 signature produced by the user's Bitcoin\n * wallet (we cannot sign BIP-322 in this package — that requires a wallet).\n */\nexport function buildDeviceEvent(params: {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {\n const tags: string[][] = [\n ['d', `oc-lock:device:${params.address}`],\n ['addr', params.address],\n ['device_id', params.device_id],\n ['device_pk', params.device_pk],\n ['alg', 'x25519'],\n ['binding_sig', params.bindingSigBase64],\n ];\n const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);\n return {\n kind: DEVICE_KIND,\n tags,\n content: params.bindingStatement,\n created_at,\n } as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };\n}\n\n/**\n * Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces\n * the full NostrEvent with id and sig.\n */\nexport function signNostrEvent(\n event: Omit<NostrEvent, 'id' | 'sig'>,\n nostrSkHex: string\n): NostrEvent {\n const id = computeNostrEventId(event);\n const sk = hexDecode(nostrSkHex);\n const sig = schnorr.sign(hexDecode(id), sk);\n return {\n ...event,\n id,\n sig: hexEncode(sig),\n };\n}\n\nexport function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {\n // Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).\n // The array must be serialized in *exactly* this form with no whitespace.\n const serialized = JSON.stringify([\n 0,\n event.pubkey,\n event.created_at,\n event.kind,\n event.tags,\n event.content,\n ]);\n return hexEncode(sha256Bytes(utf8Encode(serialized)));\n}\n\n/**\n * Finalize a device event: derive Nostr key from device_sk, attach pubkey,\n * compute id, sign. Returns a fully-signed NIP-01 event ready to publish.\n */\nexport function finalizeDeviceEvent(params: {\n deviceSk: Uint8Array;\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): NostrEvent {\n const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);\n const base = buildDeviceEvent({\n address: params.address,\n device_id: params.device_id,\n device_pk: params.device_pk,\n bindingStatement: params.bindingStatement,\n bindingSigBase64: params.bindingSigBase64,\n createdAtUnix: params.createdAtUnix,\n });\n const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;\n return signNostrEvent(withPubkey, nostrSk);\n}\n\n// ---------------------------------------------------------------------------\n// Parsing\n// ---------------------------------------------------------------------------\n\nexport interface ParsedDeviceEvent {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n revoked: boolean;\n createdAtUnix: number;\n nostrPubkey: string;\n eventId: string;\n /** 'v3' binds signed the Nostr pubkey; 'v2' binds and revocations did not. */\n bindingVersion: 'v2' | 'v3';\n}\n\nexport function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {\n if (event.kind !== DEVICE_KIND) {\n throw new Error('not a device event (kind mismatch)');\n }\n const tag = (name: string): string | null => {\n for (const t of event.tags) {\n if (t[0] === name && t.length > 1) return t[1] ?? null;\n }\n return null;\n };\n const address = tag('addr');\n const device_id = tag('device_id');\n const device_pk = tag('device_pk');\n const binding_sig = tag('binding_sig');\n if (!address || !device_id || !device_pk || !binding_sig) {\n throw new Error('device event missing required tags');\n }\n\n // The BIP-322 signature covers `event.content` — the statement — and nothing\n // else. Tags are an index the relay lets anyone write, so a value read from a\n // tag has been authenticated by nobody.\n //\n // Skipping this check was a total confidentiality break, and it needed no key\n // material: republish a victim's device event with their real content and\n // real binding_sig, change only the `device_pk` TAG to your own X25519 key.\n // verifyBip322(content, sig, addr) passes — every signed byte is genuine —\n // and the sender then seals the content key to the attacker's key, because\n // the recipient record was built from the tag. Proven against the published\n // package before this existed.\n //\n // So the statement is the source of truth and the tags must agree with it.\n const signed = parseBindingStatement(event.content);\n if (!signed) {\n throw new Error('device event content is not a v2/v3 bind or revoke statement');\n }\n if (signed.address !== address) {\n throw new Error(\n `device event addr tag (${address}) does not match the signed statement (${signed.address})`,\n );\n }\n // SPEC §3.3 / §3.6: the record lives at `oc-lock:device:<addr>` or the\n // per-device `oc-lock:device:<addr>:<device_id>`. Lookups filter on `d`, so a\n // record for one address filed under another's slot must not parse.\n const d = tag('d');\n if (\n d !== `oc-lock:device:${signed.address}` &&\n d !== `oc-lock:device:${signed.address}:${signed.device_id}`\n ) {\n throw new Error(\n `device event d tag (${d}) is not the slot for the signed address (${signed.address})`,\n );\n }\n if (signed.device_id !== device_id) {\n throw new Error(\n `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`,\n );\n }\n if (signed.kind === 'bind') {\n if (signed.device_pk !== device_pk) {\n throw new Error(\n `device event device_pk tag does not match the signed statement — ` +\n `tag says ${device_pk}, the signature covers ${signed.device_pk}`,\n );\n }\n // A v3 statement names the one Nostr key allowed to carry it.\n if (signed.nostr_pk !== null && signed.nostr_pk !== event.pubkey) {\n throw new Error(\n `device event pubkey (${event.pubkey}) does not match the signed nostr_pk (${signed.nostr_pk})`,\n );\n }\n } else if (device_pk !== 'revoked') {\n throw new Error(\n `device event carries a revocation statement but its device_pk tag is ${device_pk}, not \"revoked\"`,\n );\n }\n\n return {\n address: signed.address,\n device_id: signed.device_id,\n // Take the key from the SIGNED bytes, never the tag.\n device_pk: signed.kind === 'bind' ? signed.device_pk : 'revoked',\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: signed.kind === 'revoke',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n bindingVersion: signed.kind === 'bind' && signed.nostr_pk !== null ? 'v3' : 'v2',\n };\n}\n\n/**\n * Read back the fields the signature actually commits to. Returns null for\n * anything that is not a well-formed v2/v3 bind or v2 revoke statement, which the caller\n * treats as an unusable record.\n */\nfunction parseBindingStatement(\n content: string,\n):\n | {\n kind: 'bind';\n address: string;\n device_pk: string;\n device_id: string;\n nostr_pk: string | null;\n }\n | { kind: 'revoke'; address: string; device_id: string }\n | null {\n const lines = content.split('\\n');\n const header = lines[0];\n const field = (name: string): string | null => {\n const prefix = `${name}: `;\n for (const l of lines.slice(1)) {\n if (l.startsWith(prefix)) return l.slice(prefix.length);\n }\n return null;\n };\n const address = field('address');\n const device_id = field('device_id');\n if (!address || !device_id) return null;\n\n if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {\n const device_pk = field('device_pk');\n if (!device_pk) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk: null };\n }\n if (header === `oc-lock:device-bind:${BINDING_VERSION_V3}`) {\n const device_pk = field('device_pk');\n const nostr_pk = field('nostr_pk');\n if (!device_pk || !nostr_pk || !/^[0-9a-f]{64}$/.test(nostr_pk)) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk };\n }\n if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {\n return { kind: 'revoke', address, device_id };\n }\n return null;\n}\n\nexport { authorizedDevices } from './authorize.js';\nexport { liveDevices, signedTime } from './live.js';\n"]}
|
package/dist/index.mjs
CHANGED
|
@@ -25,6 +25,31 @@ function authorizedDevices(records) {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// src/live.ts
|
|
29
|
+
function signedTime(statement, field) {
|
|
30
|
+
const line = statement.split("\n").find((l) => l.startsWith(`${field}: `));
|
|
31
|
+
if (!line) return null;
|
|
32
|
+
const t = Date.parse(line.slice(field.length + 2));
|
|
33
|
+
return Number.isFinite(t) ? t : null;
|
|
34
|
+
}
|
|
35
|
+
function liveDevices(records) {
|
|
36
|
+
const key = (r) => `${r.address}
|
|
37
|
+
${r.device_id}`;
|
|
38
|
+
const revokedAt = /* @__PURE__ */ new Map();
|
|
39
|
+
for (const r of records) {
|
|
40
|
+
if (!r.revoked) continue;
|
|
41
|
+
const at = signedTime(r.bindingStatement, "revoked_at");
|
|
42
|
+
if (at !== null) revokedAt.set(key(r), Math.max(revokedAt.get(key(r)) ?? at, at));
|
|
43
|
+
}
|
|
44
|
+
return records.filter((r) => {
|
|
45
|
+
if (r.revoked) return false;
|
|
46
|
+
const cut = revokedAt.get(key(r));
|
|
47
|
+
if (cut === void 0) return true;
|
|
48
|
+
const bound = signedTime(r.bindingStatement, "created_at");
|
|
49
|
+
return bound !== null && bound > cut;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
// src/index.ts
|
|
29
54
|
var DEVICE_KIND = 30078;
|
|
30
55
|
var BINDING_VERSION = "v2";
|
|
@@ -223,6 +248,6 @@ function parseBindingStatement(content) {
|
|
|
223
248
|
return null;
|
|
224
249
|
}
|
|
225
250
|
|
|
226
|
-
export { BINDING_VERSION, BINDING_VERSION_V3, DEVICE_KIND, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
|
|
251
|
+
export { BINDING_VERSION, BINDING_VERSION_V3, DEVICE_KIND, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, liveDevices, parseDeviceEvent, signNostrEvent, signedTime };
|
|
227
252
|
//# sourceMappingURL=index.mjs.map
|
|
228
253
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/authorize.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["// Which Nostr pubkeys a set of device records authorizes. See SPEC §3.4.\n\nimport type { ParsedDeviceEvent } from './index.js';\n\n/**\n * Select the device records whose `nostrPubkey` may act for their address.\n *\n * `records` MUST already be BIP-322-verified, non-revoked bind records for one\n * address. A v3 record signs its Nostr pubkey, so it is always authorized. A v2\n * record does not, so its pubkey is only the event author's claim: it is\n * authorized only while its `device_id` has no v3 record and appears under\n * exactly one Nostr pubkey. Two pubkeys carrying one v2 device authorize neither.\n */\nexport function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {\n const v3Ids = new Set<string>();\n const v2Pubkeys = new Map<string, Set<string>>();\n for (const r of records) {\n if (r.revoked) continue;\n if (r.bindingVersion === 'v3') {\n v3Ids.add(r.device_id);\n } else {\n const set = v2Pubkeys.get(r.device_id) ?? new Set<string>();\n set.add(r.nostrPubkey);\n v2Pubkeys.set(r.device_id, set);\n }\n }\n return records.filter((r) => {\n if (r.revoked) return false;\n if (r.bindingVersion === 'v3') return true;\n if (v3Ids.has(r.device_id)) return false;\n return v2Pubkeys.get(r.device_id)?.size === 1;\n });\n}\n","// Device key binding and Nostr directory publication.\n// See SPEC.md §3.\n\nimport { schnorr } from '@noble/curves/secp256k1';\nimport {\n generateX25519KeyPair,\n hexDecode,\n hexEncode,\n hkdfSha256,\n randomBytesN,\n sha256Bytes,\n utf8Encode,\n} from '@orangecheck/lock-crypto';\n\nexport const DEVICE_KIND = 30078;\nexport const BINDING_VERSION = 'v2';\n/** Bind statement version that also signs the device's Nostr inbox pubkey. */\nexport const BINDING_VERSION_V3 = 'v3';\n\nexport interface DeviceKeyPair {\n device_id: string;\n device_pk: string; // 32-byte hex\n device_sk: Uint8Array; // 32 bytes\n created_at: string; // iso8601\n}\n\nexport function generateDeviceKey(): DeviceKeyPair {\n const kp = generateX25519KeyPair();\n const deviceIdBytes = randomBytesN(16);\n return {\n device_id: hexEncode(deviceIdBytes),\n device_pk: hexEncode(kp.public),\n device_sk: kp.secret,\n created_at: new Date().toISOString(),\n };\n}\n\nexport function buildBindingStatement(params: {\n address: string;\n device_pk: string;\n device_id: string;\n created_at: string;\n /** The device's Nostr pubkey (deriveNostrKey). Present ⇒ a v3 statement. */\n nostr_pk?: string;\n}): string {\n // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF\n // beyond the last field's. Order is fixed.\n const lines = params.nostr_pk\n ? [\n `oc-lock:device-bind:${BINDING_VERSION_V3}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `nostr_pk: ${params.nostr_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ]\n : [\n `oc-lock:device-bind:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\nexport function buildRevocationStatement(params: {\n address: string;\n device_id: string;\n revoked_at: string;\n}): string {\n const lines = [\n `oc-lock:device-revoke:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_id: ${params.device_id}`,\n `revoked_at: ${params.revoked_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\n// ---------------------------------------------------------------------------\n// Nostr event helpers\n// ---------------------------------------------------------------------------\n\nexport interface NostrEvent {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/**\n * Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.\n * Returns a 32-byte private key (hex).\n */\nexport function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {\n const sk = hkdfSha256(\n deviceSk,\n utf8Encode('oc-lock/v2/nostr-key'),\n utf8Encode('nostr-sk'),\n 32\n );\n const pk = schnorr.getPublicKey(sk);\n return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };\n}\n\n/**\n * Build a kind-30078 addressable event announcing a device binding.\n * The caller must supply the BIP-322 signature produced by the user's Bitcoin\n * wallet (we cannot sign BIP-322 in this package — that requires a wallet).\n */\nexport function buildDeviceEvent(params: {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {\n const tags: string[][] = [\n ['d', `oc-lock:device:${params.address}`],\n ['addr', params.address],\n ['device_id', params.device_id],\n ['device_pk', params.device_pk],\n ['alg', 'x25519'],\n ['binding_sig', params.bindingSigBase64],\n ];\n const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);\n return {\n kind: DEVICE_KIND,\n tags,\n content: params.bindingStatement,\n created_at,\n } as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };\n}\n\n/**\n * Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces\n * the full NostrEvent with id and sig.\n */\nexport function signNostrEvent(\n event: Omit<NostrEvent, 'id' | 'sig'>,\n nostrSkHex: string\n): NostrEvent {\n const id = computeNostrEventId(event);\n const sk = hexDecode(nostrSkHex);\n const sig = schnorr.sign(hexDecode(id), sk);\n return {\n ...event,\n id,\n sig: hexEncode(sig),\n };\n}\n\nexport function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {\n // Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).\n // The array must be serialized in *exactly* this form with no whitespace.\n const serialized = JSON.stringify([\n 0,\n event.pubkey,\n event.created_at,\n event.kind,\n event.tags,\n event.content,\n ]);\n return hexEncode(sha256Bytes(utf8Encode(serialized)));\n}\n\n/**\n * Finalize a device event: derive Nostr key from device_sk, attach pubkey,\n * compute id, sign. Returns a fully-signed NIP-01 event ready to publish.\n */\nexport function finalizeDeviceEvent(params: {\n deviceSk: Uint8Array;\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): NostrEvent {\n const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);\n const base = buildDeviceEvent({\n address: params.address,\n device_id: params.device_id,\n device_pk: params.device_pk,\n bindingStatement: params.bindingStatement,\n bindingSigBase64: params.bindingSigBase64,\n createdAtUnix: params.createdAtUnix,\n });\n const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;\n return signNostrEvent(withPubkey, nostrSk);\n}\n\n// ---------------------------------------------------------------------------\n// Parsing\n// ---------------------------------------------------------------------------\n\nexport interface ParsedDeviceEvent {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n revoked: boolean;\n createdAtUnix: number;\n nostrPubkey: string;\n eventId: string;\n /** 'v3' binds signed the Nostr pubkey; 'v2' binds and revocations did not. */\n bindingVersion: 'v2' | 'v3';\n}\n\nexport function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {\n if (event.kind !== DEVICE_KIND) {\n throw new Error('not a device event (kind mismatch)');\n }\n const tag = (name: string): string | null => {\n for (const t of event.tags) {\n if (t[0] === name && t.length > 1) return t[1] ?? null;\n }\n return null;\n };\n const address = tag('addr');\n const device_id = tag('device_id');\n const device_pk = tag('device_pk');\n const binding_sig = tag('binding_sig');\n if (!address || !device_id || !device_pk || !binding_sig) {\n throw new Error('device event missing required tags');\n }\n\n // The BIP-322 signature covers `event.content` — the statement — and nothing\n // else. Tags are an index the relay lets anyone write, so a value read from a\n // tag has been authenticated by nobody.\n //\n // Skipping this check was a total confidentiality break, and it needed no key\n // material: republish a victim's device event with their real content and\n // real binding_sig, change only the `device_pk` TAG to your own X25519 key.\n // verifyBip322(content, sig, addr) passes — every signed byte is genuine —\n // and the sender then seals the content key to the attacker's key, because\n // the recipient record was built from the tag. Proven against the published\n // package before this existed.\n //\n // So the statement is the source of truth and the tags must agree with it.\n const signed = parseBindingStatement(event.content);\n if (!signed) {\n throw new Error('device event content is not a v2/v3 bind or revoke statement');\n }\n if (signed.address !== address) {\n throw new Error(\n `device event addr tag (${address}) does not match the signed statement (${signed.address})`,\n );\n }\n // SPEC §3.3 / §3.6: the record lives at `oc-lock:device:<addr>` or the\n // per-device `oc-lock:device:<addr>:<device_id>`. Lookups filter on `d`, so a\n // record for one address filed under another's slot must not parse.\n const d = tag('d');\n if (\n d !== `oc-lock:device:${signed.address}` &&\n d !== `oc-lock:device:${signed.address}:${signed.device_id}`\n ) {\n throw new Error(\n `device event d tag (${d}) is not the slot for the signed address (${signed.address})`,\n );\n }\n if (signed.device_id !== device_id) {\n throw new Error(\n `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`,\n );\n }\n if (signed.kind === 'bind') {\n if (signed.device_pk !== device_pk) {\n throw new Error(\n `device event device_pk tag does not match the signed statement — ` +\n `tag says ${device_pk}, the signature covers ${signed.device_pk}`,\n );\n }\n // A v3 statement names the one Nostr key allowed to carry it.\n if (signed.nostr_pk !== null && signed.nostr_pk !== event.pubkey) {\n throw new Error(\n `device event pubkey (${event.pubkey}) does not match the signed nostr_pk (${signed.nostr_pk})`,\n );\n }\n } else if (device_pk !== 'revoked') {\n throw new Error(\n `device event carries a revocation statement but its device_pk tag is ${device_pk}, not \"revoked\"`,\n );\n }\n\n return {\n address: signed.address,\n device_id: signed.device_id,\n // Take the key from the SIGNED bytes, never the tag.\n device_pk: signed.kind === 'bind' ? signed.device_pk : 'revoked',\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: signed.kind === 'revoke',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n bindingVersion: signed.kind === 'bind' && signed.nostr_pk !== null ? 'v3' : 'v2',\n };\n}\n\n/**\n * Read back the fields the signature actually commits to. Returns null for\n * anything that is not a well-formed v2/v3 bind or v2 revoke statement, which the caller\n * treats as an unusable record.\n */\nfunction parseBindingStatement(\n content: string,\n):\n | {\n kind: 'bind';\n address: string;\n device_pk: string;\n device_id: string;\n nostr_pk: string | null;\n }\n | { kind: 'revoke'; address: string; device_id: string }\n | null {\n const lines = content.split('\\n');\n const header = lines[0];\n const field = (name: string): string | null => {\n const prefix = `${name}: `;\n for (const l of lines.slice(1)) {\n if (l.startsWith(prefix)) return l.slice(prefix.length);\n }\n return null;\n };\n const address = field('address');\n const device_id = field('device_id');\n if (!address || !device_id) return null;\n\n if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {\n const device_pk = field('device_pk');\n if (!device_pk) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk: null };\n }\n if (header === `oc-lock:device-bind:${BINDING_VERSION_V3}`) {\n const device_pk = field('device_pk');\n const nostr_pk = field('nostr_pk');\n if (!device_pk || !nostr_pk || !/^[0-9a-f]{64}$/.test(nostr_pk)) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk };\n }\n if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {\n return { kind: 'revoke', address, device_id };\n }\n return null;\n}\n\nexport { authorizedDevices } from './authorize.js';\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/authorize.ts","../src/live.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["// Which Nostr pubkeys a set of device records authorizes. See SPEC §3.4.\n\nimport type { ParsedDeviceEvent } from './index.js';\n\n/**\n * Select the device records whose `nostrPubkey` may act for their address.\n *\n * `records` MUST already be BIP-322-verified, non-revoked bind records for one\n * address. A v3 record signs its Nostr pubkey, so it is always authorized. A v2\n * record does not, so its pubkey is only the event author's claim: it is\n * authorized only while its `device_id` has no v3 record and appears under\n * exactly one Nostr pubkey. Two pubkeys carrying one v2 device authorize neither.\n */\nexport function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {\n const v3Ids = new Set<string>();\n const v2Pubkeys = new Map<string, Set<string>>();\n for (const r of records) {\n if (r.revoked) continue;\n if (r.bindingVersion === 'v3') {\n v3Ids.add(r.device_id);\n } else {\n const set = v2Pubkeys.get(r.device_id) ?? new Set<string>();\n set.add(r.nostrPubkey);\n v2Pubkeys.set(r.device_id, set);\n }\n }\n return records.filter((r) => {\n if (r.revoked) return false;\n if (r.bindingVersion === 'v3') return true;\n if (v3Ids.has(r.device_id)) return false;\n return v2Pubkeys.get(r.device_id)?.size === 1;\n });\n}\n","// Which device records are still live once revocations are applied. See OC Lock\n// LIFECYCLE §1.1 and OC Chat SPEC §1.\n\nimport type { ParsedDeviceEvent } from './index.js';\n\n/** A timestamp line from a signed device statement, as epoch ms, or null. */\nexport function signedTime(statement: string, field: 'created_at' | 'revoked_at'): number | null {\n const line = statement.split('\\n').find((l) => l.startsWith(`${field}: `));\n if (!line) return null;\n const t = Date.parse(line.slice(field.length + 2));\n return Number.isFinite(t) ? t : null;\n}\n\n/**\n * The bind records still live after revocations.\n *\n * `records` MUST already be BIP-322-verified, both binds and revocations: a\n * revocation's authority is the address's signature, so one that was not\n * verified must not be passed in. A revocation retires every bind of its\n * `(address, device_id)` whose signed `created_at` is not later than its signed\n * `revoked_at`, whichever Nostr key carried either record and whichever relay\n * served it. Relay replacement is not needed, so a lost device can be revoked\n * from another device or the wallet, and a relay that missed the revocation\n * cannot keep the device alive. A bind signed after the revocation stays live.\n */\nexport function liveDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {\n const key = (r: ParsedDeviceEvent) => `${r.address}\\n${r.device_id}`;\n const revokedAt = new Map<string, number>();\n for (const r of records) {\n if (!r.revoked) continue;\n const at = signedTime(r.bindingStatement, 'revoked_at');\n if (at !== null) revokedAt.set(key(r), Math.max(revokedAt.get(key(r)) ?? at, at));\n }\n return records.filter((r) => {\n if (r.revoked) return false;\n const cut = revokedAt.get(key(r));\n if (cut === undefined) return true;\n const bound = signedTime(r.bindingStatement, 'created_at');\n return bound !== null && bound > cut;\n });\n}\n","// Device key binding and Nostr directory publication.\n// See SPEC.md §3.\n\nimport { schnorr } from '@noble/curves/secp256k1';\nimport {\n generateX25519KeyPair,\n hexDecode,\n hexEncode,\n hkdfSha256,\n randomBytesN,\n sha256Bytes,\n utf8Encode,\n} from '@orangecheck/lock-crypto';\n\nexport const DEVICE_KIND = 30078;\nexport const BINDING_VERSION = 'v2';\n/** Bind statement version that also signs the device's Nostr inbox pubkey. */\nexport const BINDING_VERSION_V3 = 'v3';\n\nexport interface DeviceKeyPair {\n device_id: string;\n device_pk: string; // 32-byte hex\n device_sk: Uint8Array; // 32 bytes\n created_at: string; // iso8601\n}\n\nexport function generateDeviceKey(): DeviceKeyPair {\n const kp = generateX25519KeyPair();\n const deviceIdBytes = randomBytesN(16);\n return {\n device_id: hexEncode(deviceIdBytes),\n device_pk: hexEncode(kp.public),\n device_sk: kp.secret,\n created_at: new Date().toISOString(),\n };\n}\n\nexport function buildBindingStatement(params: {\n address: string;\n device_pk: string;\n device_id: string;\n created_at: string;\n /** The device's Nostr pubkey (deriveNostrKey). Present ⇒ a v3 statement. */\n nostr_pk?: string;\n}): string {\n // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF\n // beyond the last field's. Order is fixed.\n const lines = params.nostr_pk\n ? [\n `oc-lock:device-bind:${BINDING_VERSION_V3}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `nostr_pk: ${params.nostr_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ]\n : [\n `oc-lock:device-bind:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\nexport function buildRevocationStatement(params: {\n address: string;\n device_id: string;\n revoked_at: string;\n}): string {\n const lines = [\n `oc-lock:device-revoke:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_id: ${params.device_id}`,\n `revoked_at: ${params.revoked_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\n// ---------------------------------------------------------------------------\n// Nostr event helpers\n// ---------------------------------------------------------------------------\n\nexport interface NostrEvent {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/**\n * Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.\n * Returns a 32-byte private key (hex).\n */\nexport function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {\n const sk = hkdfSha256(\n deviceSk,\n utf8Encode('oc-lock/v2/nostr-key'),\n utf8Encode('nostr-sk'),\n 32\n );\n const pk = schnorr.getPublicKey(sk);\n return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };\n}\n\n/**\n * Build a kind-30078 addressable event announcing a device binding.\n * The caller must supply the BIP-322 signature produced by the user's Bitcoin\n * wallet (we cannot sign BIP-322 in this package — that requires a wallet).\n */\nexport function buildDeviceEvent(params: {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {\n const tags: string[][] = [\n ['d', `oc-lock:device:${params.address}`],\n ['addr', params.address],\n ['device_id', params.device_id],\n ['device_pk', params.device_pk],\n ['alg', 'x25519'],\n ['binding_sig', params.bindingSigBase64],\n ];\n const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);\n return {\n kind: DEVICE_KIND,\n tags,\n content: params.bindingStatement,\n created_at,\n } as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };\n}\n\n/**\n * Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces\n * the full NostrEvent with id and sig.\n */\nexport function signNostrEvent(\n event: Omit<NostrEvent, 'id' | 'sig'>,\n nostrSkHex: string\n): NostrEvent {\n const id = computeNostrEventId(event);\n const sk = hexDecode(nostrSkHex);\n const sig = schnorr.sign(hexDecode(id), sk);\n return {\n ...event,\n id,\n sig: hexEncode(sig),\n };\n}\n\nexport function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {\n // Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).\n // The array must be serialized in *exactly* this form with no whitespace.\n const serialized = JSON.stringify([\n 0,\n event.pubkey,\n event.created_at,\n event.kind,\n event.tags,\n event.content,\n ]);\n return hexEncode(sha256Bytes(utf8Encode(serialized)));\n}\n\n/**\n * Finalize a device event: derive Nostr key from device_sk, attach pubkey,\n * compute id, sign. Returns a fully-signed NIP-01 event ready to publish.\n */\nexport function finalizeDeviceEvent(params: {\n deviceSk: Uint8Array;\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): NostrEvent {\n const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);\n const base = buildDeviceEvent({\n address: params.address,\n device_id: params.device_id,\n device_pk: params.device_pk,\n bindingStatement: params.bindingStatement,\n bindingSigBase64: params.bindingSigBase64,\n createdAtUnix: params.createdAtUnix,\n });\n const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;\n return signNostrEvent(withPubkey, nostrSk);\n}\n\n// ---------------------------------------------------------------------------\n// Parsing\n// ---------------------------------------------------------------------------\n\nexport interface ParsedDeviceEvent {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n revoked: boolean;\n createdAtUnix: number;\n nostrPubkey: string;\n eventId: string;\n /** 'v3' binds signed the Nostr pubkey; 'v2' binds and revocations did not. */\n bindingVersion: 'v2' | 'v3';\n}\n\nexport function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {\n if (event.kind !== DEVICE_KIND) {\n throw new Error('not a device event (kind mismatch)');\n }\n const tag = (name: string): string | null => {\n for (const t of event.tags) {\n if (t[0] === name && t.length > 1) return t[1] ?? null;\n }\n return null;\n };\n const address = tag('addr');\n const device_id = tag('device_id');\n const device_pk = tag('device_pk');\n const binding_sig = tag('binding_sig');\n if (!address || !device_id || !device_pk || !binding_sig) {\n throw new Error('device event missing required tags');\n }\n\n // The BIP-322 signature covers `event.content` — the statement — and nothing\n // else. Tags are an index the relay lets anyone write, so a value read from a\n // tag has been authenticated by nobody.\n //\n // Skipping this check was a total confidentiality break, and it needed no key\n // material: republish a victim's device event with their real content and\n // real binding_sig, change only the `device_pk` TAG to your own X25519 key.\n // verifyBip322(content, sig, addr) passes — every signed byte is genuine —\n // and the sender then seals the content key to the attacker's key, because\n // the recipient record was built from the tag. Proven against the published\n // package before this existed.\n //\n // So the statement is the source of truth and the tags must agree with it.\n const signed = parseBindingStatement(event.content);\n if (!signed) {\n throw new Error('device event content is not a v2/v3 bind or revoke statement');\n }\n if (signed.address !== address) {\n throw new Error(\n `device event addr tag (${address}) does not match the signed statement (${signed.address})`,\n );\n }\n // SPEC §3.3 / §3.6: the record lives at `oc-lock:device:<addr>` or the\n // per-device `oc-lock:device:<addr>:<device_id>`. Lookups filter on `d`, so a\n // record for one address filed under another's slot must not parse.\n const d = tag('d');\n if (\n d !== `oc-lock:device:${signed.address}` &&\n d !== `oc-lock:device:${signed.address}:${signed.device_id}`\n ) {\n throw new Error(\n `device event d tag (${d}) is not the slot for the signed address (${signed.address})`,\n );\n }\n if (signed.device_id !== device_id) {\n throw new Error(\n `device event device_id tag (${device_id}) does not match the signed statement (${signed.device_id})`,\n );\n }\n if (signed.kind === 'bind') {\n if (signed.device_pk !== device_pk) {\n throw new Error(\n `device event device_pk tag does not match the signed statement — ` +\n `tag says ${device_pk}, the signature covers ${signed.device_pk}`,\n );\n }\n // A v3 statement names the one Nostr key allowed to carry it.\n if (signed.nostr_pk !== null && signed.nostr_pk !== event.pubkey) {\n throw new Error(\n `device event pubkey (${event.pubkey}) does not match the signed nostr_pk (${signed.nostr_pk})`,\n );\n }\n } else if (device_pk !== 'revoked') {\n throw new Error(\n `device event carries a revocation statement but its device_pk tag is ${device_pk}, not \"revoked\"`,\n );\n }\n\n return {\n address: signed.address,\n device_id: signed.device_id,\n // Take the key from the SIGNED bytes, never the tag.\n device_pk: signed.kind === 'bind' ? signed.device_pk : 'revoked',\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: signed.kind === 'revoke',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n bindingVersion: signed.kind === 'bind' && signed.nostr_pk !== null ? 'v3' : 'v2',\n };\n}\n\n/**\n * Read back the fields the signature actually commits to. Returns null for\n * anything that is not a well-formed v2/v3 bind or v2 revoke statement, which the caller\n * treats as an unusable record.\n */\nfunction parseBindingStatement(\n content: string,\n):\n | {\n kind: 'bind';\n address: string;\n device_pk: string;\n device_id: string;\n nostr_pk: string | null;\n }\n | { kind: 'revoke'; address: string; device_id: string }\n | null {\n const lines = content.split('\\n');\n const header = lines[0];\n const field = (name: string): string | null => {\n const prefix = `${name}: `;\n for (const l of lines.slice(1)) {\n if (l.startsWith(prefix)) return l.slice(prefix.length);\n }\n return null;\n };\n const address = field('address');\n const device_id = field('device_id');\n if (!address || !device_id) return null;\n\n if (header === `oc-lock:device-bind:${BINDING_VERSION}`) {\n const device_pk = field('device_pk');\n if (!device_pk) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk: null };\n }\n if (header === `oc-lock:device-bind:${BINDING_VERSION_V3}`) {\n const device_pk = field('device_pk');\n const nostr_pk = field('nostr_pk');\n if (!device_pk || !nostr_pk || !/^[0-9a-f]{64}$/.test(nostr_pk)) return null;\n return { kind: 'bind', address, device_pk, device_id, nostr_pk };\n }\n if (header === `oc-lock:device-revoke:${BINDING_VERSION}`) {\n return { kind: 'revoke', address, device_id };\n }\n return null;\n}\n\nexport { authorizedDevices } from './authorize.js';\nexport { liveDevices, signedTime } from './live.js';\n"]}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/live.test.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// A wallet-signed revocation retires a device wherever it is seen, whichever key
|
|
2
|
+
// carried it; relay replacement is not needed.
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildBindingStatement,
|
|
7
|
+
buildRevocationStatement,
|
|
8
|
+
liveDevices,
|
|
9
|
+
parseDeviceEvent,
|
|
10
|
+
signedTime,
|
|
11
|
+
type ParsedDeviceEvent,
|
|
12
|
+
} from './index.js';
|
|
13
|
+
|
|
14
|
+
const ADDR = 'bc1qowner00000000000000000000000000000000';
|
|
15
|
+
const OTHER_ADDR = 'bc1qother00000000000000000000000000000000';
|
|
16
|
+
const K1 = '1'.repeat(64); // the device's own Nostr key
|
|
17
|
+
const K2 = '2'.repeat(64); // a fresh key, from another device or the wallet
|
|
18
|
+
|
|
19
|
+
function bind(device_id: string, created_at: string, pubkey = K1, address = ADDR): ParsedDeviceEvent {
|
|
20
|
+
const content = buildBindingStatement({ address, device_pk: 'aa'.repeat(32), device_id, created_at, nostr_pk: pubkey });
|
|
21
|
+
return parseDeviceEvent({
|
|
22
|
+
id: `${device_id}-${created_at}`,
|
|
23
|
+
pubkey,
|
|
24
|
+
kind: 30078,
|
|
25
|
+
created_at: 1_780_000_000,
|
|
26
|
+
content,
|
|
27
|
+
tags: [
|
|
28
|
+
['d', `oc-lock:device:${address}`],
|
|
29
|
+
['addr', address],
|
|
30
|
+
['device_id', device_id],
|
|
31
|
+
['device_pk', 'aa'.repeat(32)],
|
|
32
|
+
['binding_sig', 'SIG'],
|
|
33
|
+
],
|
|
34
|
+
sig: 'd'.repeat(128),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function revoke(device_id: string, revoked_at: string, pubkey = K2, address = ADDR): ParsedDeviceEvent {
|
|
39
|
+
const content = buildRevocationStatement({ address, device_id, revoked_at });
|
|
40
|
+
return parseDeviceEvent({
|
|
41
|
+
id: `rev-${device_id}-${revoked_at}`,
|
|
42
|
+
pubkey,
|
|
43
|
+
kind: 30078,
|
|
44
|
+
created_at: 1_780_000_100,
|
|
45
|
+
content,
|
|
46
|
+
tags: [
|
|
47
|
+
['d', `oc-lock:device:${address}`],
|
|
48
|
+
['addr', address],
|
|
49
|
+
['device_id', device_id],
|
|
50
|
+
['device_pk', 'revoked'],
|
|
51
|
+
['binding_sig', 'SIG'],
|
|
52
|
+
],
|
|
53
|
+
sig: 'd'.repeat(128),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ids = (rs: ParsedDeviceEvent[]) => rs.map((r) => r.device_id).sort();
|
|
58
|
+
|
|
59
|
+
describe('liveDevices', () => {
|
|
60
|
+
it('keeps every bind when there is no revocation', () => {
|
|
61
|
+
expect(ids(liveDevices([bind('laptop', '2026-09-01T00:00:00Z'), bind('phone', '2026-09-02T00:00:00Z')]))).toEqual(['laptop', 'phone']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('a revocation carried by another key retires the device bound under K1', () => {
|
|
65
|
+
const live = liveDevices([bind('laptop', '2026-09-01T00:00:00Z', K1), revoke('laptop', '2026-10-01T00:00:00Z', K2)]);
|
|
66
|
+
expect(live).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('retires the bind even while other relays still serve it (partial acceptance)', () => {
|
|
70
|
+
// Three relays returned the bind, two the revocation; the query dedupes by id.
|
|
71
|
+
const b = bind('laptop', '2026-09-01T00:00:00Z');
|
|
72
|
+
const live = liveDevices([b, b, b, revoke('laptop', '2026-10-01T00:00:00Z'), bind('phone', '2026-09-03T00:00:00Z')]);
|
|
73
|
+
expect(ids(live)).toEqual(['phone']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('a bind signed after the revocation stays live', () => {
|
|
77
|
+
const live = liveDevices([revoke('laptop', '2026-10-01T00:00:00Z'), bind('laptop', '2026-10-02T00:00:00Z')]);
|
|
78
|
+
expect(ids(live)).toEqual(['laptop']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('a bind signed at the same instant as the revocation is retired', () => {
|
|
82
|
+
expect(liveDevices([bind('laptop', '2026-10-01T00:00:00Z'), revoke('laptop', '2026-10-01T00:00:00Z')])).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('a revocation reaches only its own address and device', () => {
|
|
86
|
+
const live = liveDevices([
|
|
87
|
+
bind('laptop', '2026-09-01T00:00:00Z', K1, ADDR),
|
|
88
|
+
bind('laptop', '2026-09-01T00:00:00Z', K1, OTHER_ADDR),
|
|
89
|
+
bind('phone', '2026-09-01T00:00:00Z', K1, ADDR),
|
|
90
|
+
revoke('laptop', '2026-10-01T00:00:00Z', K2, ADDR),
|
|
91
|
+
]);
|
|
92
|
+
expect(live.map((r) => `${r.address === ADDR ? 'A' : 'B'}:${r.device_id}`).sort()).toEqual(['A:phone', 'B:laptop']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('the latest revocation of a device decides', () => {
|
|
96
|
+
const live = liveDevices([
|
|
97
|
+
revoke('laptop', '2026-09-10T00:00:00Z'),
|
|
98
|
+
bind('laptop', '2026-09-20T00:00:00Z'),
|
|
99
|
+
revoke('laptop', '2026-10-01T00:00:00Z'),
|
|
100
|
+
]);
|
|
101
|
+
expect(live).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('never returns a revocation record itself', () => {
|
|
105
|
+
expect(liveDevices([revoke('laptop', '2026-10-01T00:00:00Z')])).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('signedTime', () => {
|
|
110
|
+
it('reads a timestamp line from a signed statement', () => {
|
|
111
|
+
const s = buildRevocationStatement({ address: ADDR, device_id: 'x', revoked_at: '2026-10-01T00:00:00Z' });
|
|
112
|
+
expect(signedTime(s, 'revoked_at')).toBe(Date.parse('2026-10-01T00:00:00Z'));
|
|
113
|
+
expect(signedTime(s, 'created_at')).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/live.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Which device records are still live once revocations are applied. See OC Lock
|
|
2
|
+
// LIFECYCLE §1.1 and OC Chat SPEC §1.
|
|
3
|
+
|
|
4
|
+
import type { ParsedDeviceEvent } from './index.js';
|
|
5
|
+
|
|
6
|
+
/** A timestamp line from a signed device statement, as epoch ms, or null. */
|
|
7
|
+
export function signedTime(statement: string, field: 'created_at' | 'revoked_at'): number | null {
|
|
8
|
+
const line = statement.split('\n').find((l) => l.startsWith(`${field}: `));
|
|
9
|
+
if (!line) return null;
|
|
10
|
+
const t = Date.parse(line.slice(field.length + 2));
|
|
11
|
+
return Number.isFinite(t) ? t : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The bind records still live after revocations.
|
|
16
|
+
*
|
|
17
|
+
* `records` MUST already be BIP-322-verified, both binds and revocations: a
|
|
18
|
+
* revocation's authority is the address's signature, so one that was not
|
|
19
|
+
* verified must not be passed in. A revocation retires every bind of its
|
|
20
|
+
* `(address, device_id)` whose signed `created_at` is not later than its signed
|
|
21
|
+
* `revoked_at`, whichever Nostr key carried either record and whichever relay
|
|
22
|
+
* served it. Relay replacement is not needed, so a lost device can be revoked
|
|
23
|
+
* from another device or the wallet, and a relay that missed the revocation
|
|
24
|
+
* cannot keep the device alive. A bind signed after the revocation stays live.
|
|
25
|
+
*/
|
|
26
|
+
export function liveDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {
|
|
27
|
+
const key = (r: ParsedDeviceEvent) => `${r.address}\n${r.device_id}`;
|
|
28
|
+
const revokedAt = new Map<string, number>();
|
|
29
|
+
for (const r of records) {
|
|
30
|
+
if (!r.revoked) continue;
|
|
31
|
+
const at = signedTime(r.bindingStatement, 'revoked_at');
|
|
32
|
+
if (at !== null) revokedAt.set(key(r), Math.max(revokedAt.get(key(r)) ?? at, at));
|
|
33
|
+
}
|
|
34
|
+
return records.filter((r) => {
|
|
35
|
+
if (r.revoked) return false;
|
|
36
|
+
const cut = revokedAt.get(key(r));
|
|
37
|
+
if (cut === undefined) return true;
|
|
38
|
+
const bound = signedTime(r.bindingStatement, 'created_at');
|
|
39
|
+
return bound !== null && bound > cut;
|
|
40
|
+
});
|
|
41
|
+
}
|