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