@orangecheck/lock-device 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,8 @@ This package is WebCrypto-free: it works in Node and in the browser.
25
25
 
26
26
  - `generateDeviceKey()` — new `(device_sk, device_pk, device_id, created_at)`.
27
27
  - `buildBindingStatement({ address, device_pk, device_id, created_at, nostr_pk? })` — exact bytes per SPEC §3.2 (v3 when `nostr_pk` is given).
28
- - `authorizedDevices(records)` — the verified records whose Nostr pubkey may act for their address (SPEC §3.4).
28
+ - `liveDevices(records, { now?, maxSkewMs? })` — the verified bind records no revocation retires (SPEC §3.5). Seal to these, one per `device_id`. `pendingDevices` lists binds dated ahead of the clock; `revokedAtFor` dates a revocation.
29
+ - `authorizedDevices(records, { now? })` — the records whose Nostr pubkey is the device's inbox (SPEC §3.4): every v3 record, and before `V2_INBOX_SUNSET` (2027-01-21T00:00:00Z) a v2 record carried by one key only. For Nostr delivery, never for choosing recipients.
29
30
  - `buildRevocationStatement({ address, device_id, revoked_at })` — for explicit revocation.
30
31
  - `deriveNostrKey(deviceSk)` — deterministic `nostr_sk` from `device_sk` (HKDF).
31
32
  - `finalizeDeviceEvent({ ... bindingSigBase64, ... })` — returns a fully signed kind-30078 `NostrEvent`.
package/dist/index.d.mts CHANGED
@@ -1,7 +1,18 @@
1
- declare function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[];
1
+ declare const V2_INBOX_SUNSET = "2027-01-21T00:00:00Z";
2
+ interface AuthorizeOptions {
3
+ now?: Date;
4
+ }
5
+ declare function authorizedDevices(records: readonly ParsedDeviceEvent[], opts?: AuthorizeOptions): ParsedDeviceEvent[];
2
6
 
3
7
  declare function signedTime(statement: string, field: 'created_at' | 'revoked_at'): number | null;
4
- declare function liveDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[];
8
+ declare const DEFAULT_BIND_SKEW_MS: number;
9
+ interface LiveOptions {
10
+ now?: Date;
11
+ maxSkewMs?: number;
12
+ }
13
+ declare function liveDevices(records: readonly ParsedDeviceEvent[], opts?: LiveOptions): ParsedDeviceEvent[];
14
+ declare function pendingDevices(records: readonly ParsedDeviceEvent[], opts?: LiveOptions): ParsedDeviceEvent[];
15
+ declare function revokedAtFor(records: readonly ParsedDeviceEvent[], address: string, deviceId: string, now?: Date): string;
5
16
 
6
17
  declare const DEVICE_KIND = 30078;
7
18
  declare const BINDING_VERSION = "v2";
@@ -73,4 +84,4 @@ interface ParsedDeviceEvent {
73
84
  }
74
85
  declare function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent;
75
86
 
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 };
87
+ export { type AuthorizeOptions, BINDING_VERSION, BINDING_VERSION_V3, DEFAULT_BIND_SKEW_MS, DEVICE_KIND, type DeviceKeyPair, type LiveOptions, type NostrEvent, type ParsedDeviceEvent, V2_INBOX_SUNSET, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, liveDevices, parseDeviceEvent, pendingDevices, revokedAtFor, signNostrEvent, signedTime };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,18 @@
1
- declare function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[];
1
+ declare const V2_INBOX_SUNSET = "2027-01-21T00:00:00Z";
2
+ interface AuthorizeOptions {
3
+ now?: Date;
4
+ }
5
+ declare function authorizedDevices(records: readonly ParsedDeviceEvent[], opts?: AuthorizeOptions): ParsedDeviceEvent[];
2
6
 
3
7
  declare function signedTime(statement: string, field: 'created_at' | 'revoked_at'): number | null;
4
- declare function liveDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[];
8
+ declare const DEFAULT_BIND_SKEW_MS: number;
9
+ interface LiveOptions {
10
+ now?: Date;
11
+ maxSkewMs?: number;
12
+ }
13
+ declare function liveDevices(records: readonly ParsedDeviceEvent[], opts?: LiveOptions): ParsedDeviceEvent[];
14
+ declare function pendingDevices(records: readonly ParsedDeviceEvent[], opts?: LiveOptions): ParsedDeviceEvent[];
15
+ declare function revokedAtFor(records: readonly ParsedDeviceEvent[], address: string, deviceId: string, now?: Date): string;
5
16
 
6
17
  declare const DEVICE_KIND = 30078;
7
18
  declare const BINDING_VERSION = "v2";
@@ -73,4 +84,4 @@ interface ParsedDeviceEvent {
73
84
  }
74
85
  declare function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent;
75
86
 
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 };
87
+ export { type AuthorizeOptions, BINDING_VERSION, BINDING_VERSION_V3, DEFAULT_BIND_SKEW_MS, DEVICE_KIND, type DeviceKeyPair, type LiveOptions, type NostrEvent, type ParsedDeviceEvent, V2_INBOX_SUNSET, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, liveDevices, parseDeviceEvent, pendingDevices, revokedAtFor, signNostrEvent, signedTime };
package/dist/index.js CHANGED
@@ -6,7 +6,12 @@ var lockCrypto = require('@orangecheck/lock-crypto');
6
6
  // src/index.ts
7
7
 
8
8
  // src/authorize.ts
9
- function authorizedDevices(records) {
9
+ var V2_INBOX_SUNSET = "2027-01-21T00:00:00Z";
10
+ function authorizedDevices(records, opts = {}) {
11
+ for (const k of Object.keys(opts)) {
12
+ if (k !== "now") throw new TypeError(`authorizedDevices does not read option "${k}"`);
13
+ }
14
+ const v2Counts = (opts.now ?? /* @__PURE__ */ new Date()).getTime() < Date.parse(V2_INBOX_SUNSET);
10
15
  const v3Ids = /* @__PURE__ */ new Set();
11
16
  const v2Pubkeys = /* @__PURE__ */ new Map();
12
17
  for (const r of records) {
@@ -22,7 +27,7 @@ function authorizedDevices(records) {
22
27
  return records.filter((r) => {
23
28
  if (r.revoked) return false;
24
29
  if (r.bindingVersion === "v3") return true;
25
- if (v3Ids.has(r.device_id)) return false;
30
+ if (!v2Counts || v3Ids.has(r.device_id)) return false;
26
31
  return v2Pubkeys.get(r.device_id)?.size === 1;
27
32
  });
28
33
  }
@@ -34,7 +39,8 @@ function signedTime(statement, field) {
34
39
  const t = Date.parse(line.slice(field.length + 2));
35
40
  return Number.isFinite(t) ? t : null;
36
41
  }
37
- function liveDevices(records) {
42
+ var DEFAULT_BIND_SKEW_MS = 10 * 6e4;
43
+ function standing(records) {
38
44
  const key = (r) => `${r.address}
39
45
  ${r.device_id}`;
40
46
  const revokedAt = /* @__PURE__ */ new Map();
@@ -45,12 +51,30 @@ ${r.device_id}`;
45
51
  }
46
52
  return records.filter((r) => {
47
53
  if (r.revoked) return false;
48
- const cut = revokedAt.get(key(r));
49
- if (cut === void 0) return true;
50
54
  const bound = signedTime(r.bindingStatement, "created_at");
51
- return bound !== null && bound > cut;
55
+ if (bound === null) return false;
56
+ const cut = revokedAt.get(key(r));
57
+ return cut === void 0 || bound > cut;
52
58
  });
53
59
  }
60
+ var limitOf = (opts) => (opts.now ?? /* @__PURE__ */ new Date()).getTime() + (opts.maxSkewMs ?? DEFAULT_BIND_SKEW_MS);
61
+ function liveDevices(records, opts = {}) {
62
+ const limit = limitOf(opts);
63
+ return standing(records).filter((r) => signedTime(r.bindingStatement, "created_at") <= limit);
64
+ }
65
+ function pendingDevices(records, opts = {}) {
66
+ const limit = limitOf(opts);
67
+ return standing(records).filter((r) => signedTime(r.bindingStatement, "created_at") > limit);
68
+ }
69
+ function revokedAtFor(records, address, deviceId, now = /* @__PURE__ */ new Date()) {
70
+ let at = now.getTime();
71
+ for (const r of records) {
72
+ if (r.revoked || r.address !== address || r.device_id !== deviceId) continue;
73
+ const t = signedTime(r.bindingStatement, "created_at");
74
+ if (t !== null && t > at) at = t;
75
+ }
76
+ return new Date(at).toISOString();
77
+ }
54
78
 
55
79
  // src/index.ts
56
80
  var DEVICE_KIND = 30078;
@@ -252,7 +276,9 @@ function parseBindingStatement(content) {
252
276
 
253
277
  exports.BINDING_VERSION = BINDING_VERSION;
254
278
  exports.BINDING_VERSION_V3 = BINDING_VERSION_V3;
279
+ exports.DEFAULT_BIND_SKEW_MS = DEFAULT_BIND_SKEW_MS;
255
280
  exports.DEVICE_KIND = DEVICE_KIND;
281
+ exports.V2_INBOX_SUNSET = V2_INBOX_SUNSET;
256
282
  exports.authorizedDevices = authorizedDevices;
257
283
  exports.buildBindingStatement = buildBindingStatement;
258
284
  exports.buildDeviceEvent = buildDeviceEvent;
@@ -263,6 +289,8 @@ exports.finalizeDeviceEvent = finalizeDeviceEvent;
263
289
  exports.generateDeviceKey = generateDeviceKey;
264
290
  exports.liveDevices = liveDevices;
265
291
  exports.parseDeviceEvent = parseDeviceEvent;
292
+ exports.pendingDevices = pendingDevices;
293
+ exports.revokedAtFor = revokedAtFor;
266
294
  exports.signNostrEvent = signNostrEvent;
267
295
  exports.signedTime = signedTime;
268
296
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
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"]}
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/** From this instant, by the reader's clock, a v2 record authorizes no Nostr key (SPEC §3.4). */\nexport const V2_INBOX_SUNSET = '2027-01-21T00:00:00Z';\n\nexport interface AuthorizeOptions {\n /** The reader's clock. Default: the current time. */\n now?: Date;\n}\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. Before\n * `V2_INBOX_SUNSET` it is authorized only while its `device_id` has no v3 record\n * and appears under exactly one Nostr pubkey; from then on it authorizes no key.\n * A v2 bind stays live for sealing to its `device_pk` either way (liveDevices).\n */\nexport function authorizedDevices(\n records: readonly ParsedDeviceEvent[],\n opts: AuthorizeOptions = {}\n): ParsedDeviceEvent[] {\n for (const k of Object.keys(opts)) {\n if (k !== 'now') throw new TypeError(`authorizedDevices does not read option \"${k}\"`);\n }\n const v2Counts = (opts.now ?? new Date()).getTime() < Date.parse(V2_INBOX_SUNSET);\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 (!v2Counts || 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 and the reader's clock\n// are applied. See OC Lock SPEC §3.5 and LIFECYCLE §1.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/** How far a bind's signed `created_at` may run ahead of the reader's clock and still count. */\nexport const DEFAULT_BIND_SKEW_MS = 10 * 60_000;\n\nexport interface LiveOptions {\n /** The reader's clock. Default: the current time. */\n now?: Date;\n /** Default DEFAULT_BIND_SKEW_MS. */\n maxSkewMs?: number;\n}\n\n/** Binds that no revocation retires, before the reader's clock is applied. */\nfunction standing(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 bound = signedTime(r.bindingStatement, 'created_at');\n if (bound === null) return false;\n const cut = revokedAt.get(key(r));\n return cut === undefined || bound > cut;\n });\n}\n\nconst limitOf = (opts: LiveOptions) =>\n (opts.now ?? new Date()).getTime() + (opts.maxSkewMs ?? DEFAULT_BIND_SKEW_MS);\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. A bind signed after the revocation stays live. A bind whose signed\n * `created_at` is later than the reader's clock plus `maxSkewMs` is not live yet\n * (see pendingDevices).\n */\nexport function liveDevices(\n records: readonly ParsedDeviceEvent[],\n opts: LiveOptions = {}\n): ParsedDeviceEvent[] {\n const limit = limitOf(opts);\n return standing(records).filter((r) => signedTime(r.bindingStatement, 'created_at')! <= limit);\n}\n\n/**\n * Binds that no revocation retires but that are dated later than the reader's\n * clock allows: nobody seals to them yet, and a device list shows them so they\n * can be revoked before their date.\n */\nexport function pendingDevices(\n records: readonly ParsedDeviceEvent[],\n opts: LiveOptions = {}\n): ParsedDeviceEvent[] {\n const limit = limitOf(opts);\n return standing(records).filter((r) => signedTime(r.bindingStatement, 'created_at')! > limit);\n}\n\n/**\n * The `revoked_at` to sign when revoking `(address, deviceId)`: the current\n * time, or the latest signed `created_at` among that device's binds in\n * `records` if later, so the revocation also retires a bind dated ahead.\n */\nexport function revokedAtFor(\n records: readonly ParsedDeviceEvent[],\n address: string,\n deviceId: string,\n now: Date = new Date()\n): string {\n let at = now.getTime();\n for (const r of records) {\n if (r.revoked || r.address !== address || r.device_id !== deviceId) continue;\n const t = signedTime(r.bindingStatement, 'created_at');\n if (t !== null && t > at) at = t;\n }\n return new Date(at).toISOString();\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, V2_INBOX_SUNSET } from './authorize.js';\nexport type { AuthorizeOptions } from './authorize.js';\nexport {\n DEFAULT_BIND_SKEW_MS,\n liveDevices,\n pendingDevices,\n revokedAtFor,\n signedTime,\n} from './live.js';\nexport type { LiveOptions } from './live.js';\n"]}
package/dist/index.mjs CHANGED
@@ -4,7 +4,12 @@ import { generateX25519KeyPair, randomBytesN, hexEncode, hkdfSha256, utf8Encode,
4
4
  // src/index.ts
5
5
 
6
6
  // src/authorize.ts
7
- function authorizedDevices(records) {
7
+ var V2_INBOX_SUNSET = "2027-01-21T00:00:00Z";
8
+ function authorizedDevices(records, opts = {}) {
9
+ for (const k of Object.keys(opts)) {
10
+ if (k !== "now") throw new TypeError(`authorizedDevices does not read option "${k}"`);
11
+ }
12
+ const v2Counts = (opts.now ?? /* @__PURE__ */ new Date()).getTime() < Date.parse(V2_INBOX_SUNSET);
8
13
  const v3Ids = /* @__PURE__ */ new Set();
9
14
  const v2Pubkeys = /* @__PURE__ */ new Map();
10
15
  for (const r of records) {
@@ -20,7 +25,7 @@ function authorizedDevices(records) {
20
25
  return records.filter((r) => {
21
26
  if (r.revoked) return false;
22
27
  if (r.bindingVersion === "v3") return true;
23
- if (v3Ids.has(r.device_id)) return false;
28
+ if (!v2Counts || v3Ids.has(r.device_id)) return false;
24
29
  return v2Pubkeys.get(r.device_id)?.size === 1;
25
30
  });
26
31
  }
@@ -32,7 +37,8 @@ function signedTime(statement, field) {
32
37
  const t = Date.parse(line.slice(field.length + 2));
33
38
  return Number.isFinite(t) ? t : null;
34
39
  }
35
- function liveDevices(records) {
40
+ var DEFAULT_BIND_SKEW_MS = 10 * 6e4;
41
+ function standing(records) {
36
42
  const key = (r) => `${r.address}
37
43
  ${r.device_id}`;
38
44
  const revokedAt = /* @__PURE__ */ new Map();
@@ -43,12 +49,30 @@ ${r.device_id}`;
43
49
  }
44
50
  return records.filter((r) => {
45
51
  if (r.revoked) return false;
46
- const cut = revokedAt.get(key(r));
47
- if (cut === void 0) return true;
48
52
  const bound = signedTime(r.bindingStatement, "created_at");
49
- return bound !== null && bound > cut;
53
+ if (bound === null) return false;
54
+ const cut = revokedAt.get(key(r));
55
+ return cut === void 0 || bound > cut;
50
56
  });
51
57
  }
58
+ var limitOf = (opts) => (opts.now ?? /* @__PURE__ */ new Date()).getTime() + (opts.maxSkewMs ?? DEFAULT_BIND_SKEW_MS);
59
+ function liveDevices(records, opts = {}) {
60
+ const limit = limitOf(opts);
61
+ return standing(records).filter((r) => signedTime(r.bindingStatement, "created_at") <= limit);
62
+ }
63
+ function pendingDevices(records, opts = {}) {
64
+ const limit = limitOf(opts);
65
+ return standing(records).filter((r) => signedTime(r.bindingStatement, "created_at") > limit);
66
+ }
67
+ function revokedAtFor(records, address, deviceId, now = /* @__PURE__ */ new Date()) {
68
+ let at = now.getTime();
69
+ for (const r of records) {
70
+ if (r.revoked || r.address !== address || r.device_id !== deviceId) continue;
71
+ const t = signedTime(r.bindingStatement, "created_at");
72
+ if (t !== null && t > at) at = t;
73
+ }
74
+ return new Date(at).toISOString();
75
+ }
52
76
 
53
77
  // src/index.ts
54
78
  var DEVICE_KIND = 30078;
@@ -248,6 +272,6 @@ function parseBindingStatement(content) {
248
272
  return null;
249
273
  }
250
274
 
251
- export { BINDING_VERSION, BINDING_VERSION_V3, DEVICE_KIND, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, liveDevices, parseDeviceEvent, signNostrEvent, signedTime };
275
+ export { BINDING_VERSION, BINDING_VERSION_V3, DEFAULT_BIND_SKEW_MS, DEVICE_KIND, V2_INBOX_SUNSET, authorizedDevices, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, liveDevices, parseDeviceEvent, pendingDevices, revokedAtFor, signNostrEvent, signedTime };
252
276
  //# sourceMappingURL=index.mjs.map
253
277
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
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"]}
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/** From this instant, by the reader's clock, a v2 record authorizes no Nostr key (SPEC §3.4). */\nexport const V2_INBOX_SUNSET = '2027-01-21T00:00:00Z';\n\nexport interface AuthorizeOptions {\n /** The reader's clock. Default: the current time. */\n now?: Date;\n}\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. Before\n * `V2_INBOX_SUNSET` it is authorized only while its `device_id` has no v3 record\n * and appears under exactly one Nostr pubkey; from then on it authorizes no key.\n * A v2 bind stays live for sealing to its `device_pk` either way (liveDevices).\n */\nexport function authorizedDevices(\n records: readonly ParsedDeviceEvent[],\n opts: AuthorizeOptions = {}\n): ParsedDeviceEvent[] {\n for (const k of Object.keys(opts)) {\n if (k !== 'now') throw new TypeError(`authorizedDevices does not read option \"${k}\"`);\n }\n const v2Counts = (opts.now ?? new Date()).getTime() < Date.parse(V2_INBOX_SUNSET);\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 (!v2Counts || 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 and the reader's clock\n// are applied. See OC Lock SPEC §3.5 and LIFECYCLE §1.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/** How far a bind's signed `created_at` may run ahead of the reader's clock and still count. */\nexport const DEFAULT_BIND_SKEW_MS = 10 * 60_000;\n\nexport interface LiveOptions {\n /** The reader's clock. Default: the current time. */\n now?: Date;\n /** Default DEFAULT_BIND_SKEW_MS. */\n maxSkewMs?: number;\n}\n\n/** Binds that no revocation retires, before the reader's clock is applied. */\nfunction standing(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 bound = signedTime(r.bindingStatement, 'created_at');\n if (bound === null) return false;\n const cut = revokedAt.get(key(r));\n return cut === undefined || bound > cut;\n });\n}\n\nconst limitOf = (opts: LiveOptions) =>\n (opts.now ?? new Date()).getTime() + (opts.maxSkewMs ?? DEFAULT_BIND_SKEW_MS);\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. A bind signed after the revocation stays live. A bind whose signed\n * `created_at` is later than the reader's clock plus `maxSkewMs` is not live yet\n * (see pendingDevices).\n */\nexport function liveDevices(\n records: readonly ParsedDeviceEvent[],\n opts: LiveOptions = {}\n): ParsedDeviceEvent[] {\n const limit = limitOf(opts);\n return standing(records).filter((r) => signedTime(r.bindingStatement, 'created_at')! <= limit);\n}\n\n/**\n * Binds that no revocation retires but that are dated later than the reader's\n * clock allows: nobody seals to them yet, and a device list shows them so they\n * can be revoked before their date.\n */\nexport function pendingDevices(\n records: readonly ParsedDeviceEvent[],\n opts: LiveOptions = {}\n): ParsedDeviceEvent[] {\n const limit = limitOf(opts);\n return standing(records).filter((r) => signedTime(r.bindingStatement, 'created_at')! > limit);\n}\n\n/**\n * The `revoked_at` to sign when revoking `(address, deviceId)`: the current\n * time, or the latest signed `created_at` among that device's binds in\n * `records` if later, so the revocation also retires a bind dated ahead.\n */\nexport function revokedAtFor(\n records: readonly ParsedDeviceEvent[],\n address: string,\n deviceId: string,\n now: Date = new Date()\n): string {\n let at = now.getTime();\n for (const r of records) {\n if (r.revoked || r.address !== address || r.device_id !== deviceId) continue;\n const t = signedTime(r.bindingStatement, 'created_at');\n if (t !== null && t > at) at = t;\n }\n return new Date(at).toISOString();\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, V2_INBOX_SUNSET } from './authorize.js';\nexport type { AuthorizeOptions } from './authorize.js';\nexport {\n DEFAULT_BIND_SKEW_MS,\n liveDevices,\n pendingDevices,\n revokedAtFor,\n signedTime,\n} from './live.js';\nexport type { LiveOptions } from './live.js';\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangecheck/lock-device",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Device-key binding and Nostr kind-30078 directory publication for OC Lock.",
5
5
  "keywords": [
6
6
  "bitcoin",
@@ -2,7 +2,13 @@
2
2
  // so a v2 record's event pubkey is only its author's claim.
3
3
  import { describe, expect, it } from 'vitest';
4
4
 
5
- import { authorizedDevices, buildBindingStatement, parseDeviceEvent } from './index.js';
5
+ import {
6
+ authorizedDevices,
7
+ buildBindingStatement,
8
+ liveDevices,
9
+ parseDeviceEvent,
10
+ V2_INBOX_SUNSET,
11
+ } from './index.js';
6
12
 
7
13
  const ADDR = 'bc1qowner00000000000000000000000000000000';
8
14
  const DEVICE_PK = 'aa'.repeat(32);
@@ -72,33 +78,70 @@ describe('v3 bind statement', () => {
72
78
  });
73
79
  });
74
80
 
75
- describe('authorizedDevices', () => {
81
+ const SUNSET = new Date(V2_INBOX_SUNSET);
82
+ const BEFORE = { now: new Date(SUNSET.getTime() - 1000) };
83
+ const AT = { now: SUNSET };
84
+
85
+ describe('authorizedDevices before the v2 sunset', () => {
76
86
  const v2Owner = parseDeviceEvent(event(OWNER_NOSTR, statement()));
77
87
  const v2Other = parseDeviceEvent(event(OTHER_NOSTR, statement()));
78
88
  const v3Owner = parseDeviceEvent(event(OWNER_NOSTR, statement(OWNER_NOSTR)));
79
89
 
80
90
  it('authorizes a v2 device seen under exactly one pubkey', () => {
81
- expect(authorizedDevices([v2Owner]).map((d) => d.nostrPubkey)).toEqual([OWNER_NOSTR]);
91
+ expect(authorizedDevices([v2Owner], BEFORE).map((d) => d.nostrPubkey)).toEqual([
92
+ OWNER_NOSTR,
93
+ ]);
82
94
  });
83
95
 
84
96
  it('authorizes neither pubkey when one v2 device appears under two', () => {
85
- expect(authorizedDevices([v2Owner, v2Other])).toEqual([]);
97
+ expect(authorizedDevices([v2Owner, v2Other], BEFORE)).toEqual([]);
86
98
  });
87
99
 
88
100
  it('a v3 record for the device supersedes every v2 copy of it', () => {
89
- expect(authorizedDevices([v2Other, v3Owner, v2Owner]).map((d) => d.nostrPubkey)).toEqual(
90
- [OWNER_NOSTR]
91
- );
92
- expect(authorizedDevices([v2Other, v3Owner])).toEqual([v3Owner]);
101
+ expect(
102
+ authorizedDevices([v2Other, v3Owner, v2Owner], BEFORE).map((d) => d.nostrPubkey)
103
+ ).toEqual([OWNER_NOSTR]);
104
+ expect(authorizedDevices([v2Other, v3Owner], BEFORE)).toEqual([v3Owner]);
93
105
  });
94
106
 
95
107
  it('scopes the v2 rule per device_id', () => {
96
108
  const second = parseDeviceEvent(
97
109
  event(OTHER_NOSTR, statement(undefined, 'owner-phone'), 'owner-phone')
98
110
  );
99
- expect(authorizedDevices([v2Owner, second]).map((d) => d.device_id)).toEqual([
111
+ expect(authorizedDevices([v2Owner, second], BEFORE).map((d) => d.device_id)).toEqual([
100
112
  DEVICE_ID,
101
113
  'owner-phone',
102
114
  ]);
103
115
  });
104
116
  });
117
+
118
+ describe('authorizedDevices from the v2 sunset (SPEC §3.4)', () => {
119
+ const v2Owner = parseDeviceEvent(event(OWNER_NOSTR, statement()));
120
+ const v2Other = parseDeviceEvent(event(OTHER_NOSTR, statement()));
121
+ const v3Owner = parseDeviceEvent(event(OWNER_NOSTR, statement(OWNER_NOSTR)));
122
+
123
+ it('a v2 record authorizes no key, however many carry it', () => {
124
+ expect(authorizedDevices([v2Owner], AT)).toEqual([]);
125
+ expect(authorizedDevices([v2Other], AT)).toEqual([]);
126
+ expect(authorizedDevices([v2Owner, v2Other], AT)).toEqual([]);
127
+ });
128
+
129
+ it('a v3 record still authorizes its signed key', () => {
130
+ expect(authorizedDevices([v2Other, v3Owner, v2Owner], AT)).toEqual([v3Owner]);
131
+ });
132
+
133
+ it('a v2 bind stays live for sealing to its device_pk', () => {
134
+ expect(liveDevices([v2Owner], AT)).toEqual([v2Owner]);
135
+ });
136
+
137
+ it("reads the reader's clock when no now is given", () => {
138
+ const v2Counts = Date.now() < SUNSET.getTime();
139
+ expect(authorizedDevices([v2Owner]).length).toBe(v2Counts ? 1 : 0);
140
+ });
141
+
142
+ it('refuses an option it does not read', () => {
143
+ expect(() =>
144
+ authorizedDevices([v2Owner], { at: SUNSET } as unknown as { now?: Date })
145
+ ).toThrow(/does not read option "at"/);
146
+ });
147
+ });
package/src/authorize.ts CHANGED
@@ -2,16 +2,32 @@
2
2
 
3
3
  import type { ParsedDeviceEvent } from './index.js';
4
4
 
5
+ /** From this instant, by the reader's clock, a v2 record authorizes no Nostr key (SPEC §3.4). */
6
+ export const V2_INBOX_SUNSET = '2027-01-21T00:00:00Z';
7
+
8
+ export interface AuthorizeOptions {
9
+ /** The reader's clock. Default: the current time. */
10
+ now?: Date;
11
+ }
12
+
5
13
  /**
6
14
  * Select the device records whose `nostrPubkey` may act for their address.
7
15
  *
8
16
  * `records` MUST already be BIP-322-verified, non-revoked bind records for one
9
17
  * address. A v3 record signs its Nostr pubkey, so it is always authorized. A v2
10
- * record does not, so its pubkey is only the event author's claim: it is
11
- * authorized only while its `device_id` has no v3 record and appears under
12
- * exactly one Nostr pubkey. Two pubkeys carrying one v2 device authorize neither.
18
+ * record does not, so its pubkey is only the event author's claim. Before
19
+ * `V2_INBOX_SUNSET` it is authorized only while its `device_id` has no v3 record
20
+ * and appears under exactly one Nostr pubkey; from then on it authorizes no key.
21
+ * A v2 bind stays live for sealing to its `device_pk` either way (liveDevices).
13
22
  */
14
- export function authorizedDevices(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {
23
+ export function authorizedDevices(
24
+ records: readonly ParsedDeviceEvent[],
25
+ opts: AuthorizeOptions = {}
26
+ ): ParsedDeviceEvent[] {
27
+ for (const k of Object.keys(opts)) {
28
+ if (k !== 'now') throw new TypeError(`authorizedDevices does not read option "${k}"`);
29
+ }
30
+ const v2Counts = (opts.now ?? new Date()).getTime() < Date.parse(V2_INBOX_SUNSET);
15
31
  const v3Ids = new Set<string>();
16
32
  const v2Pubkeys = new Map<string, Set<string>>();
17
33
  for (const r of records) {
@@ -27,7 +43,7 @@ export function authorizedDevices(records: readonly ParsedDeviceEvent[]): Parsed
27
43
  return records.filter((r) => {
28
44
  if (r.revoked) return false;
29
45
  if (r.bindingVersion === 'v3') return true;
30
- if (v3Ids.has(r.device_id)) return false;
46
+ if (!v2Counts || v3Ids.has(r.device_id)) return false;
31
47
  return v2Pubkeys.get(r.device_id)?.size === 1;
32
48
  });
33
49
  }
package/src/index.ts CHANGED
@@ -351,5 +351,13 @@ function parseBindingStatement(
351
351
  return null;
352
352
  }
353
353
 
354
- export { authorizedDevices } from './authorize.js';
355
- export { liveDevices, signedTime } from './live.js';
354
+ export { authorizedDevices, V2_INBOX_SUNSET } from './authorize.js';
355
+ export type { AuthorizeOptions } from './authorize.js';
356
+ export {
357
+ DEFAULT_BIND_SKEW_MS,
358
+ liveDevices,
359
+ pendingDevices,
360
+ revokedAtFor,
361
+ signedTime,
362
+ } from './live.js';
363
+ export type { LiveOptions } from './live.js';
package/src/live.test.ts CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  buildRevocationStatement,
8
8
  liveDevices,
9
9
  parseDeviceEvent,
10
+ pendingDevices,
11
+ revokedAtFor,
10
12
  signedTime,
11
13
  type ParsedDeviceEvent,
12
14
  } from './index.js';
@@ -106,6 +108,42 @@ describe('liveDevices', () => {
106
108
  });
107
109
  });
108
110
 
111
+ describe('the reader\'s clock', () => {
112
+ const NOW = new Date('2026-10-22T00:00:00Z');
113
+
114
+ it('a bind dated more than the skew ahead of the reader is not live yet', () => {
115
+ const ahead = bind('laptop', '2027-10-22T00:00:00Z');
116
+ expect(liveDevices([ahead], { now: NOW })).toEqual([]);
117
+ expect(ids(pendingDevices([ahead], { now: NOW }))).toEqual(['laptop']);
118
+ });
119
+
120
+ it('a bind within the skew is live', () => {
121
+ const slightly = bind('laptop', '2026-10-22T00:05:00Z');
122
+ expect(ids(liveDevices([slightly], { now: NOW }))).toEqual(['laptop']);
123
+ expect(pendingDevices([slightly], { now: NOW })).toEqual([]);
124
+ });
125
+
126
+ it('a revocation dated today does not reach a bind dated ahead', () => {
127
+ const ahead = bind('laptop', '2027-10-22T00:00:00Z');
128
+ const today = revoke('laptop', NOW.toISOString());
129
+ expect(ids(liveDevices([ahead, today], { now: new Date('2027-10-23T00:00:00Z') }))).toEqual(['laptop']);
130
+ });
131
+
132
+ it('revokedAtFor dates a revocation at the latest bind of that device, or now', () => {
133
+ const ahead = bind('laptop', '2027-10-22T00:00:00Z');
134
+ const other = bind('phone', '2028-01-01T00:00:00Z');
135
+ expect(revokedAtFor([ahead, other], ADDR, 'laptop', NOW)).toBe('2027-10-22T00:00:00.000Z');
136
+ expect(revokedAtFor([other], ADDR, 'laptop', NOW)).toBe(NOW.toISOString());
137
+ });
138
+
139
+ it('a revocation dated by revokedAtFor retires a bind dated ahead, when its date comes', () => {
140
+ const ahead = bind('laptop', '2027-10-22T00:00:00Z');
141
+ const r = revoke('laptop', revokedAtFor([ahead], ADDR, 'laptop', NOW));
142
+ expect(liveDevices([ahead, r], { now: new Date('2027-10-23T00:00:00Z') })).toEqual([]);
143
+ expect(pendingDevices([ahead, r], { now: NOW })).toEqual([]);
144
+ });
145
+ });
146
+
109
147
  describe('signedTime', () => {
110
148
  it('reads a timestamp line from a signed statement', () => {
111
149
  const s = buildRevocationStatement({ address: ADDR, device_id: 'x', revoked_at: '2026-10-01T00:00:00Z' });
package/src/live.ts CHANGED
@@ -1,5 +1,5 @@
1
- // Which device records are still live once revocations are applied. See OC Lock
2
- // LIFECYCLE §1.1 and OC Chat SPEC §1.
1
+ // Which device records are still live once revocations and the reader's clock
2
+ // are applied. See OC Lock SPEC §3.5 and LIFECYCLE §1.1.
3
3
 
4
4
  import type { ParsedDeviceEvent } from './index.js';
5
5
 
@@ -11,19 +11,18 @@ export function signedTime(statement: string, field: 'created_at' | 'revoked_at'
11
11
  return Number.isFinite(t) ? t : null;
12
12
  }
13
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[] {
14
+ /** How far a bind's signed `created_at` may run ahead of the reader's clock and still count. */
15
+ export const DEFAULT_BIND_SKEW_MS = 10 * 60_000;
16
+
17
+ export interface LiveOptions {
18
+ /** The reader's clock. Default: the current time. */
19
+ now?: Date;
20
+ /** Default DEFAULT_BIND_SKEW_MS. */
21
+ maxSkewMs?: number;
22
+ }
23
+
24
+ /** Binds that no revocation retires, before the reader's clock is applied. */
25
+ function standing(records: readonly ParsedDeviceEvent[]): ParsedDeviceEvent[] {
27
26
  const key = (r: ParsedDeviceEvent) => `${r.address}\n${r.device_id}`;
28
27
  const revokedAt = new Map<string, number>();
29
28
  for (const r of records) {
@@ -33,9 +32,65 @@ export function liveDevices(records: readonly ParsedDeviceEvent[]): ParsedDevice
33
32
  }
34
33
  return records.filter((r) => {
35
34
  if (r.revoked) return false;
36
- const cut = revokedAt.get(key(r));
37
- if (cut === undefined) return true;
38
35
  const bound = signedTime(r.bindingStatement, 'created_at');
39
- return bound !== null && bound > cut;
36
+ if (bound === null) return false;
37
+ const cut = revokedAt.get(key(r));
38
+ return cut === undefined || bound > cut;
40
39
  });
41
40
  }
41
+
42
+ const limitOf = (opts: LiveOptions) =>
43
+ (opts.now ?? new Date()).getTime() + (opts.maxSkewMs ?? DEFAULT_BIND_SKEW_MS);
44
+
45
+ /**
46
+ * The bind records still live after revocations.
47
+ *
48
+ * `records` MUST already be BIP-322-verified, both binds and revocations: a
49
+ * revocation's authority is the address's signature, so one that was not
50
+ * verified must not be passed in. A revocation retires every bind of its
51
+ * `(address, device_id)` whose signed `created_at` is not later than its signed
52
+ * `revoked_at`, whichever Nostr key carried either record and whichever relay
53
+ * served it. A bind signed after the revocation stays live. A bind whose signed
54
+ * `created_at` is later than the reader's clock plus `maxSkewMs` is not live yet
55
+ * (see pendingDevices).
56
+ */
57
+ export function liveDevices(
58
+ records: readonly ParsedDeviceEvent[],
59
+ opts: LiveOptions = {}
60
+ ): ParsedDeviceEvent[] {
61
+ const limit = limitOf(opts);
62
+ return standing(records).filter((r) => signedTime(r.bindingStatement, 'created_at')! <= limit);
63
+ }
64
+
65
+ /**
66
+ * Binds that no revocation retires but that are dated later than the reader's
67
+ * clock allows: nobody seals to them yet, and a device list shows them so they
68
+ * can be revoked before their date.
69
+ */
70
+ export function pendingDevices(
71
+ records: readonly ParsedDeviceEvent[],
72
+ opts: LiveOptions = {}
73
+ ): ParsedDeviceEvent[] {
74
+ const limit = limitOf(opts);
75
+ return standing(records).filter((r) => signedTime(r.bindingStatement, 'created_at')! > limit);
76
+ }
77
+
78
+ /**
79
+ * The `revoked_at` to sign when revoking `(address, deviceId)`: the current
80
+ * time, or the latest signed `created_at` among that device's binds in
81
+ * `records` if later, so the revocation also retires a bind dated ahead.
82
+ */
83
+ export function revokedAtFor(
84
+ records: readonly ParsedDeviceEvent[],
85
+ address: string,
86
+ deviceId: string,
87
+ now: Date = new Date()
88
+ ): string {
89
+ let at = now.getTime();
90
+ for (const r of records) {
91
+ if (r.revoked || r.address !== address || r.device_id !== deviceId) continue;
92
+ const t = signedTime(r.bindingStatement, 'created_at');
93
+ if (t !== null && t > at) at = t;
94
+ }
95
+ return new Date(at).toISOString();
96
+ }
@@ -0,0 +1,94 @@
1
+ // OC Lock test vector a01 (oc-lock-protocol test-vectors/device-binding): the v2 inbox
2
+ // sunset and the v3 re-bind of a v2 device, checked byte for byte and case by case.
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ import { x25519 } from '@noble/curves/ed25519';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ import {
9
+ authorizedDevices,
10
+ buildBindingStatement,
11
+ buildRevocationStatement,
12
+ computeNostrEventId,
13
+ deriveNostrKey,
14
+ liveDevices,
15
+ parseDeviceEvent,
16
+ V2_INBOX_SUNSET,
17
+ type NostrEvent,
18
+ } from './index.js';
19
+
20
+ interface Case {
21
+ records: string[];
22
+ reader_time: string;
23
+ live: string[];
24
+ authorized: { record: string; pubkey: string }[];
25
+ }
26
+
27
+ const v = JSON.parse(
28
+ readFileSync(new URL('../test-vectors/a01-v2-sunset.json', import.meta.url), 'utf8')
29
+ );
30
+ const hex = (u: Uint8Array) => Buffer.from(u).toString('hex');
31
+ const bytes = (h: string) => Uint8Array.from(Buffer.from(h, 'hex'));
32
+ const NAMES = ['v2', 'v2_copy', 'v3_rebind', 'revocation', 'v3_rebind_later'];
33
+ const named: Record<string, NostrEvent> = Object.fromEntries(
34
+ NAMES.map((n) => [n, v.expected[n].event])
35
+ );
36
+ const parsed = (name: string) =>
37
+ Object.assign(
38
+ parseDeviceEvent({ ...named[name]!, id: '0'.repeat(64), sig: '0'.repeat(128) }),
39
+ { name }
40
+ );
41
+
42
+ describe('test vector a01: the v2 inbox sunset', () => {
43
+ it('is judged at the sunset this package applies', () => {
44
+ expect(v.inputs.sunset).toBe(V2_INBOX_SUNSET);
45
+ });
46
+
47
+ it('derives the keys and builds both statements byte for byte', () => {
48
+ const sk = bytes(v.inputs.device_sk);
49
+ expect(hex(x25519.getPublicKey(sk))).toBe(v.expected.device_pk);
50
+ expect(deriveNostrKey(sk)).toEqual({
51
+ nostrSk: v.expected.nostr_sk,
52
+ nostrPk: v.expected.nostr_pk,
53
+ });
54
+ const base = {
55
+ address: v.inputs.address,
56
+ device_pk: v.expected.device_pk,
57
+ device_id: v.inputs.device_id,
58
+ created_at: v.inputs.created_at,
59
+ };
60
+ expect(buildBindingStatement(base)).toBe(v.expected.v2.statement);
61
+ expect(buildBindingStatement({ ...base, nostr_pk: v.expected.nostr_pk })).toBe(
62
+ v.expected.v3_rebind.statement
63
+ );
64
+ expect(
65
+ buildBindingStatement({
66
+ ...base,
67
+ nostr_pk: v.expected.nostr_pk,
68
+ created_at: v.inputs.rebind_later_created_at,
69
+ })
70
+ ).toBe(v.expected.v3_rebind_later.statement);
71
+ expect(
72
+ buildRevocationStatement({
73
+ address: v.inputs.address,
74
+ device_id: v.inputs.device_id,
75
+ revoked_at: v.inputs.revoked_at,
76
+ })
77
+ ).toBe(v.expected.revocation.statement);
78
+ });
79
+
80
+ it('computes every event id', () => {
81
+ for (const n of NAMES) expect(computeNostrEventId(named[n]!)).toBe(v.expected[n].event_id);
82
+ });
83
+
84
+ it.each(v.expected.cases.cases as Case[])('$records at $reader_time', (c) => {
85
+ const now = new Date(c.reader_time);
86
+ const live = liveDevices(c.records.map(parsed), { now });
87
+ expect(live.map((r) => r.name).sort()).toEqual(c.live);
88
+ const authorized = authorizedDevices(live, { now }).map((r) => ({
89
+ record: r.name,
90
+ pubkey: r.nostrPubkey,
91
+ }));
92
+ expect(authorized).toEqual(c.authorized);
93
+ });
94
+ });