@orangecheck/lock-device 0.1.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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/index.d.mts +68 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +141 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +129 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -0
- package/src/index.test.ts +124 -0
- package/src/index.ts +229 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OrangeCheck
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @orangecheck/lock-device
|
|
2
|
+
|
|
3
|
+
Device-key binding and Nostr kind-30078 directory publication helpers for [OC Lock](https://github.com/orangecheck/oc-lock-protocol).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm i @orangecheck/lock-device
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What this package does
|
|
12
|
+
|
|
13
|
+
- Generates X25519 device keypairs used by recipients to receive sealed envelopes.
|
|
14
|
+
- Builds canonical binding/revocation statements that a Bitcoin wallet signs via BIP-322.
|
|
15
|
+
- Derives a Nostr Schnorr keypair deterministically from a device secret (so the same browser always publishes under a stable Nostr pubkey, without asking the user to manage Nostr keys).
|
|
16
|
+
- Builds, finalizes, and parses kind-30078 addressable events keyed by `d: oc-lock:device:<btc-address>`.
|
|
17
|
+
|
|
18
|
+
This package is WebCrypto-free: it works in Node and in the browser.
|
|
19
|
+
|
|
20
|
+
## Exports
|
|
21
|
+
|
|
22
|
+
- `generateDeviceKey()` — new `(device_sk, device_pk, device_id, created_at)`.
|
|
23
|
+
- `buildBindingStatement({ address, device_pk, device_id, created_at })` — exact bytes per SPEC §3.2.
|
|
24
|
+
- `buildRevocationStatement({ address, device_id, revoked_at })` — for explicit revocation.
|
|
25
|
+
- `deriveNostrKey(deviceSk)` — deterministic `nostr_sk` from `device_sk` (HKDF).
|
|
26
|
+
- `finalizeDeviceEvent({ ... bindingSigBase64, ... })` — returns a fully signed kind-30078 `NostrEvent`.
|
|
27
|
+
- `parseDeviceEvent(event)` — extracts `{ address, device_pk, device_id, bindingStatement, bindingSigBase64, revoked }`.
|
|
28
|
+
|
|
29
|
+
The caller is responsible for obtaining the BIP-322 signature from the user's wallet and for publishing / querying Nostr relays. See [`SPEC.md`](https://github.com/orangecheck/oc-lock-protocol/blob/main/SPEC.md) §3 for the normative device-binding rules.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
declare const DEVICE_KIND = 30078;
|
|
2
|
+
declare const BINDING_VERSION = "v2";
|
|
3
|
+
interface DeviceKeyPair {
|
|
4
|
+
device_id: string;
|
|
5
|
+
device_pk: string;
|
|
6
|
+
device_sk: Uint8Array;
|
|
7
|
+
created_at: string;
|
|
8
|
+
}
|
|
9
|
+
declare function generateDeviceKey(): DeviceKeyPair;
|
|
10
|
+
declare function buildBindingStatement(params: {
|
|
11
|
+
address: string;
|
|
12
|
+
device_pk: string;
|
|
13
|
+
device_id: string;
|
|
14
|
+
created_at: string;
|
|
15
|
+
}): string;
|
|
16
|
+
declare function buildRevocationStatement(params: {
|
|
17
|
+
address: string;
|
|
18
|
+
device_id: string;
|
|
19
|
+
revoked_at: string;
|
|
20
|
+
}): string;
|
|
21
|
+
interface NostrEvent {
|
|
22
|
+
id: string;
|
|
23
|
+
pubkey: string;
|
|
24
|
+
created_at: number;
|
|
25
|
+
kind: number;
|
|
26
|
+
tags: string[][];
|
|
27
|
+
content: string;
|
|
28
|
+
sig: string;
|
|
29
|
+
}
|
|
30
|
+
declare function deriveNostrKey(deviceSk: Uint8Array): {
|
|
31
|
+
nostrSk: string;
|
|
32
|
+
nostrPk: string;
|
|
33
|
+
};
|
|
34
|
+
declare function buildDeviceEvent(params: {
|
|
35
|
+
address: string;
|
|
36
|
+
device_id: string;
|
|
37
|
+
device_pk: string;
|
|
38
|
+
bindingStatement: string;
|
|
39
|
+
bindingSigBase64: string;
|
|
40
|
+
createdAtUnix?: number;
|
|
41
|
+
}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & {
|
|
42
|
+
pubkey?: undefined;
|
|
43
|
+
};
|
|
44
|
+
declare function signNostrEvent(event: Omit<NostrEvent, 'id' | 'sig'>, nostrSkHex: string): NostrEvent;
|
|
45
|
+
declare function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string;
|
|
46
|
+
declare function finalizeDeviceEvent(params: {
|
|
47
|
+
deviceSk: Uint8Array;
|
|
48
|
+
address: string;
|
|
49
|
+
device_id: string;
|
|
50
|
+
device_pk: string;
|
|
51
|
+
bindingStatement: string;
|
|
52
|
+
bindingSigBase64: string;
|
|
53
|
+
createdAtUnix?: number;
|
|
54
|
+
}): NostrEvent;
|
|
55
|
+
interface ParsedDeviceEvent {
|
|
56
|
+
address: string;
|
|
57
|
+
device_id: string;
|
|
58
|
+
device_pk: string;
|
|
59
|
+
bindingStatement: string;
|
|
60
|
+
bindingSigBase64: string;
|
|
61
|
+
revoked: boolean;
|
|
62
|
+
createdAtUnix: number;
|
|
63
|
+
nostrPubkey: string;
|
|
64
|
+
eventId: string;
|
|
65
|
+
}
|
|
66
|
+
declare function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent;
|
|
67
|
+
|
|
68
|
+
export { BINDING_VERSION, DEVICE_KIND, type DeviceKeyPair, type NostrEvent, type ParsedDeviceEvent, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
declare const DEVICE_KIND = 30078;
|
|
2
|
+
declare const BINDING_VERSION = "v2";
|
|
3
|
+
interface DeviceKeyPair {
|
|
4
|
+
device_id: string;
|
|
5
|
+
device_pk: string;
|
|
6
|
+
device_sk: Uint8Array;
|
|
7
|
+
created_at: string;
|
|
8
|
+
}
|
|
9
|
+
declare function generateDeviceKey(): DeviceKeyPair;
|
|
10
|
+
declare function buildBindingStatement(params: {
|
|
11
|
+
address: string;
|
|
12
|
+
device_pk: string;
|
|
13
|
+
device_id: string;
|
|
14
|
+
created_at: string;
|
|
15
|
+
}): string;
|
|
16
|
+
declare function buildRevocationStatement(params: {
|
|
17
|
+
address: string;
|
|
18
|
+
device_id: string;
|
|
19
|
+
revoked_at: string;
|
|
20
|
+
}): string;
|
|
21
|
+
interface NostrEvent {
|
|
22
|
+
id: string;
|
|
23
|
+
pubkey: string;
|
|
24
|
+
created_at: number;
|
|
25
|
+
kind: number;
|
|
26
|
+
tags: string[][];
|
|
27
|
+
content: string;
|
|
28
|
+
sig: string;
|
|
29
|
+
}
|
|
30
|
+
declare function deriveNostrKey(deviceSk: Uint8Array): {
|
|
31
|
+
nostrSk: string;
|
|
32
|
+
nostrPk: string;
|
|
33
|
+
};
|
|
34
|
+
declare function buildDeviceEvent(params: {
|
|
35
|
+
address: string;
|
|
36
|
+
device_id: string;
|
|
37
|
+
device_pk: string;
|
|
38
|
+
bindingStatement: string;
|
|
39
|
+
bindingSigBase64: string;
|
|
40
|
+
createdAtUnix?: number;
|
|
41
|
+
}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & {
|
|
42
|
+
pubkey?: undefined;
|
|
43
|
+
};
|
|
44
|
+
declare function signNostrEvent(event: Omit<NostrEvent, 'id' | 'sig'>, nostrSkHex: string): NostrEvent;
|
|
45
|
+
declare function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string;
|
|
46
|
+
declare function finalizeDeviceEvent(params: {
|
|
47
|
+
deviceSk: Uint8Array;
|
|
48
|
+
address: string;
|
|
49
|
+
device_id: string;
|
|
50
|
+
device_pk: string;
|
|
51
|
+
bindingStatement: string;
|
|
52
|
+
bindingSigBase64: string;
|
|
53
|
+
createdAtUnix?: number;
|
|
54
|
+
}): NostrEvent;
|
|
55
|
+
interface ParsedDeviceEvent {
|
|
56
|
+
address: string;
|
|
57
|
+
device_id: string;
|
|
58
|
+
device_pk: string;
|
|
59
|
+
bindingStatement: string;
|
|
60
|
+
bindingSigBase64: string;
|
|
61
|
+
revoked: boolean;
|
|
62
|
+
createdAtUnix: number;
|
|
63
|
+
nostrPubkey: string;
|
|
64
|
+
eventId: string;
|
|
65
|
+
}
|
|
66
|
+
declare function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent;
|
|
67
|
+
|
|
68
|
+
export { BINDING_VERSION, DEVICE_KIND, type DeviceKeyPair, type NostrEvent, type ParsedDeviceEvent, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var secp256k1 = require('@noble/curves/secp256k1');
|
|
4
|
+
var lockCrypto = require('@orangecheck/lock-crypto');
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
var DEVICE_KIND = 30078;
|
|
8
|
+
var BINDING_VERSION = "v2";
|
|
9
|
+
function generateDeviceKey() {
|
|
10
|
+
const kp = lockCrypto.generateX25519KeyPair();
|
|
11
|
+
const deviceIdBytes = lockCrypto.randomBytesN(16);
|
|
12
|
+
return {
|
|
13
|
+
device_id: lockCrypto.hexEncode(deviceIdBytes),
|
|
14
|
+
device_pk: lockCrypto.hexEncode(kp.public),
|
|
15
|
+
device_sk: kp.secret,
|
|
16
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function buildBindingStatement(params) {
|
|
20
|
+
const lines = [
|
|
21
|
+
`oc-lock:device-bind:${BINDING_VERSION}`,
|
|
22
|
+
`address: ${params.address}`,
|
|
23
|
+
`device_pk: ${params.device_pk}`,
|
|
24
|
+
`device_id: ${params.device_id}`,
|
|
25
|
+
`created_at: ${params.created_at}`
|
|
26
|
+
];
|
|
27
|
+
return lines.join("\n") + "\n";
|
|
28
|
+
}
|
|
29
|
+
function buildRevocationStatement(params) {
|
|
30
|
+
const lines = [
|
|
31
|
+
`oc-lock:device-revoke:${BINDING_VERSION}`,
|
|
32
|
+
`address: ${params.address}`,
|
|
33
|
+
`device_id: ${params.device_id}`,
|
|
34
|
+
`revoked_at: ${params.revoked_at}`
|
|
35
|
+
];
|
|
36
|
+
return lines.join("\n") + "\n";
|
|
37
|
+
}
|
|
38
|
+
function deriveNostrKey(deviceSk) {
|
|
39
|
+
const sk = lockCrypto.hkdfSha256(
|
|
40
|
+
deviceSk,
|
|
41
|
+
lockCrypto.utf8Encode("oc-lock/v2/nostr-key"),
|
|
42
|
+
lockCrypto.utf8Encode("nostr-sk"),
|
|
43
|
+
32
|
|
44
|
+
);
|
|
45
|
+
const pk = secp256k1.schnorr.getPublicKey(sk);
|
|
46
|
+
return { nostrSk: lockCrypto.hexEncode(sk), nostrPk: lockCrypto.hexEncode(pk) };
|
|
47
|
+
}
|
|
48
|
+
function buildDeviceEvent(params) {
|
|
49
|
+
const tags = [
|
|
50
|
+
["d", `oc-lock:device:${params.address}`],
|
|
51
|
+
["addr", params.address],
|
|
52
|
+
["device_id", params.device_id],
|
|
53
|
+
["device_pk", params.device_pk],
|
|
54
|
+
["alg", "x25519"],
|
|
55
|
+
["binding_sig", params.bindingSigBase64]
|
|
56
|
+
];
|
|
57
|
+
const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1e3);
|
|
58
|
+
return {
|
|
59
|
+
kind: DEVICE_KIND,
|
|
60
|
+
tags,
|
|
61
|
+
content: params.bindingStatement,
|
|
62
|
+
created_at
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function signNostrEvent(event, nostrSkHex) {
|
|
66
|
+
const id = computeNostrEventId(event);
|
|
67
|
+
const sk = lockCrypto.hexDecode(nostrSkHex);
|
|
68
|
+
const sig = secp256k1.schnorr.sign(lockCrypto.hexDecode(id), sk);
|
|
69
|
+
return {
|
|
70
|
+
...event,
|
|
71
|
+
id,
|
|
72
|
+
sig: lockCrypto.hexEncode(sig)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function computeNostrEventId(event) {
|
|
76
|
+
const serialized = JSON.stringify([
|
|
77
|
+
0,
|
|
78
|
+
event.pubkey,
|
|
79
|
+
event.created_at,
|
|
80
|
+
event.kind,
|
|
81
|
+
event.tags,
|
|
82
|
+
event.content
|
|
83
|
+
]);
|
|
84
|
+
return lockCrypto.hexEncode(lockCrypto.sha256Bytes(lockCrypto.utf8Encode(serialized)));
|
|
85
|
+
}
|
|
86
|
+
function finalizeDeviceEvent(params) {
|
|
87
|
+
const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);
|
|
88
|
+
const base = buildDeviceEvent({
|
|
89
|
+
address: params.address,
|
|
90
|
+
device_id: params.device_id,
|
|
91
|
+
device_pk: params.device_pk,
|
|
92
|
+
bindingStatement: params.bindingStatement,
|
|
93
|
+
bindingSigBase64: params.bindingSigBase64,
|
|
94
|
+
createdAtUnix: params.createdAtUnix
|
|
95
|
+
});
|
|
96
|
+
const withPubkey = { ...base, pubkey: nostrPk };
|
|
97
|
+
return signNostrEvent(withPubkey, nostrSk);
|
|
98
|
+
}
|
|
99
|
+
function parseDeviceEvent(event) {
|
|
100
|
+
if (event.kind !== DEVICE_KIND) {
|
|
101
|
+
throw new Error("not a device event (kind mismatch)");
|
|
102
|
+
}
|
|
103
|
+
const tag = (name) => {
|
|
104
|
+
for (const t of event.tags) {
|
|
105
|
+
if (t[0] === name && t.length > 1) return t[1] ?? null;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
};
|
|
109
|
+
const address = tag("addr");
|
|
110
|
+
const device_id = tag("device_id");
|
|
111
|
+
const device_pk = tag("device_pk");
|
|
112
|
+
const binding_sig = tag("binding_sig");
|
|
113
|
+
if (!address || !device_id || !device_pk || !binding_sig) {
|
|
114
|
+
throw new Error("device event missing required tags");
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
address,
|
|
118
|
+
device_id,
|
|
119
|
+
device_pk,
|
|
120
|
+
bindingStatement: event.content,
|
|
121
|
+
bindingSigBase64: binding_sig,
|
|
122
|
+
revoked: device_pk === "revoked",
|
|
123
|
+
createdAtUnix: event.created_at,
|
|
124
|
+
nostrPubkey: event.pubkey,
|
|
125
|
+
eventId: event.id
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
exports.BINDING_VERSION = BINDING_VERSION;
|
|
130
|
+
exports.DEVICE_KIND = DEVICE_KIND;
|
|
131
|
+
exports.buildBindingStatement = buildBindingStatement;
|
|
132
|
+
exports.buildDeviceEvent = buildDeviceEvent;
|
|
133
|
+
exports.buildRevocationStatement = buildRevocationStatement;
|
|
134
|
+
exports.computeNostrEventId = computeNostrEventId;
|
|
135
|
+
exports.deriveNostrKey = deriveNostrKey;
|
|
136
|
+
exports.finalizeDeviceEvent = finalizeDeviceEvent;
|
|
137
|
+
exports.generateDeviceKey = generateDeviceKey;
|
|
138
|
+
exports.parseDeviceEvent = parseDeviceEvent;
|
|
139
|
+
exports.signNostrEvent = signNostrEvent;
|
|
140
|
+
//# sourceMappingURL=index.js.map
|
|
141
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["generateX25519KeyPair","randomBytesN","hexEncode","hkdfSha256","utf8Encode","schnorr","hexDecode","sha256Bytes"],"mappings":"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","file":"index.js","sourcesContent":["// Device key binding and Nostr directory publication.\n// See SPEC.md §3.\n\nimport { schnorr } from '@noble/curves/secp256k1';\nimport {\n generateX25519KeyPair,\n hexDecode,\n hexEncode,\n hkdfSha256,\n randomBytesN,\n sha256Bytes,\n utf8Encode,\n} from '@orangecheck/lock-crypto';\n\nexport const DEVICE_KIND = 30078;\nexport const BINDING_VERSION = 'v2';\n\nexport interface DeviceKeyPair {\n device_id: string;\n device_pk: string; // 32-byte hex\n device_sk: Uint8Array; // 32 bytes\n created_at: string; // iso8601\n}\n\nexport function generateDeviceKey(): DeviceKeyPair {\n const kp = generateX25519KeyPair();\n const deviceIdBytes = randomBytesN(16);\n return {\n device_id: hexEncode(deviceIdBytes),\n device_pk: hexEncode(kp.public),\n device_sk: kp.secret,\n created_at: new Date().toISOString(),\n };\n}\n\nexport function buildBindingStatement(params: {\n address: string;\n device_pk: string;\n device_id: string;\n created_at: string;\n}): string {\n // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF\n // beyond the last field's. Order is fixed.\n const lines = [\n `oc-lock:device-bind:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\nexport function buildRevocationStatement(params: {\n address: string;\n device_id: string;\n revoked_at: string;\n}): string {\n const lines = [\n `oc-lock:device-revoke:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_id: ${params.device_id}`,\n `revoked_at: ${params.revoked_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\n// ---------------------------------------------------------------------------\n// Nostr event helpers\n// ---------------------------------------------------------------------------\n\nexport interface NostrEvent {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/**\n * Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.\n * Returns a 32-byte private key (hex).\n */\nexport function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {\n const sk = hkdfSha256(\n deviceSk,\n utf8Encode('oc-lock/v2/nostr-key'),\n utf8Encode('nostr-sk'),\n 32\n );\n const pk = schnorr.getPublicKey(sk);\n return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };\n}\n\n/**\n * Build a kind-30078 addressable event announcing a device binding.\n * The caller must supply the BIP-322 signature produced by the user's Bitcoin\n * wallet (we cannot sign BIP-322 in this package — that requires a wallet).\n */\nexport function buildDeviceEvent(params: {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {\n const tags: string[][] = [\n ['d', `oc-lock:device:${params.address}`],\n ['addr', params.address],\n ['device_id', params.device_id],\n ['device_pk', params.device_pk],\n ['alg', 'x25519'],\n ['binding_sig', params.bindingSigBase64],\n ];\n const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);\n return {\n kind: DEVICE_KIND,\n tags,\n content: params.bindingStatement,\n created_at,\n } as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };\n}\n\n/**\n * Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces\n * the full NostrEvent with id and sig.\n */\nexport function signNostrEvent(\n event: Omit<NostrEvent, 'id' | 'sig'>,\n nostrSkHex: string\n): NostrEvent {\n const id = computeNostrEventId(event);\n const sk = hexDecode(nostrSkHex);\n const sig = schnorr.sign(hexDecode(id), sk);\n return {\n ...event,\n id,\n sig: hexEncode(sig),\n };\n}\n\nexport function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {\n // Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).\n // The array must be serialized in *exactly* this form with no whitespace.\n const serialized = JSON.stringify([\n 0,\n event.pubkey,\n event.created_at,\n event.kind,\n event.tags,\n event.content,\n ]);\n return hexEncode(sha256Bytes(utf8Encode(serialized)));\n}\n\n/**\n * Finalize a device event: derive Nostr key from device_sk, attach pubkey,\n * compute id, sign. Returns a fully-signed NIP-01 event ready to publish.\n */\nexport function finalizeDeviceEvent(params: {\n deviceSk: Uint8Array;\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): NostrEvent {\n const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);\n const base = buildDeviceEvent({\n address: params.address,\n device_id: params.device_id,\n device_pk: params.device_pk,\n bindingStatement: params.bindingStatement,\n bindingSigBase64: params.bindingSigBase64,\n createdAtUnix: params.createdAtUnix,\n });\n const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;\n return signNostrEvent(withPubkey, nostrSk);\n}\n\n// ---------------------------------------------------------------------------\n// Parsing\n// ---------------------------------------------------------------------------\n\nexport interface ParsedDeviceEvent {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n revoked: boolean;\n createdAtUnix: number;\n nostrPubkey: string;\n eventId: string;\n}\n\nexport function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {\n if (event.kind !== DEVICE_KIND) {\n throw new Error('not a device event (kind mismatch)');\n }\n const tag = (name: string): string | null => {\n for (const t of event.tags) {\n if (t[0] === name && t.length > 1) return t[1] ?? null;\n }\n return null;\n };\n const address = tag('addr');\n const device_id = tag('device_id');\n const device_pk = tag('device_pk');\n const binding_sig = tag('binding_sig');\n if (!address || !device_id || !device_pk || !binding_sig) {\n throw new Error('device event missing required tags');\n }\n return {\n address,\n device_id,\n device_pk,\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: device_pk === 'revoked',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n };\n}\n"]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { schnorr } from '@noble/curves/secp256k1';
|
|
2
|
+
import { generateX25519KeyPair, randomBytesN, hexEncode, hkdfSha256, utf8Encode, hexDecode, sha256Bytes } from '@orangecheck/lock-crypto';
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
var DEVICE_KIND = 30078;
|
|
6
|
+
var BINDING_VERSION = "v2";
|
|
7
|
+
function generateDeviceKey() {
|
|
8
|
+
const kp = generateX25519KeyPair();
|
|
9
|
+
const deviceIdBytes = randomBytesN(16);
|
|
10
|
+
return {
|
|
11
|
+
device_id: hexEncode(deviceIdBytes),
|
|
12
|
+
device_pk: hexEncode(kp.public),
|
|
13
|
+
device_sk: kp.secret,
|
|
14
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function buildBindingStatement(params) {
|
|
18
|
+
const lines = [
|
|
19
|
+
`oc-lock:device-bind:${BINDING_VERSION}`,
|
|
20
|
+
`address: ${params.address}`,
|
|
21
|
+
`device_pk: ${params.device_pk}`,
|
|
22
|
+
`device_id: ${params.device_id}`,
|
|
23
|
+
`created_at: ${params.created_at}`
|
|
24
|
+
];
|
|
25
|
+
return lines.join("\n") + "\n";
|
|
26
|
+
}
|
|
27
|
+
function buildRevocationStatement(params) {
|
|
28
|
+
const lines = [
|
|
29
|
+
`oc-lock:device-revoke:${BINDING_VERSION}`,
|
|
30
|
+
`address: ${params.address}`,
|
|
31
|
+
`device_id: ${params.device_id}`,
|
|
32
|
+
`revoked_at: ${params.revoked_at}`
|
|
33
|
+
];
|
|
34
|
+
return lines.join("\n") + "\n";
|
|
35
|
+
}
|
|
36
|
+
function deriveNostrKey(deviceSk) {
|
|
37
|
+
const sk = hkdfSha256(
|
|
38
|
+
deviceSk,
|
|
39
|
+
utf8Encode("oc-lock/v2/nostr-key"),
|
|
40
|
+
utf8Encode("nostr-sk"),
|
|
41
|
+
32
|
|
42
|
+
);
|
|
43
|
+
const pk = schnorr.getPublicKey(sk);
|
|
44
|
+
return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };
|
|
45
|
+
}
|
|
46
|
+
function buildDeviceEvent(params) {
|
|
47
|
+
const tags = [
|
|
48
|
+
["d", `oc-lock:device:${params.address}`],
|
|
49
|
+
["addr", params.address],
|
|
50
|
+
["device_id", params.device_id],
|
|
51
|
+
["device_pk", params.device_pk],
|
|
52
|
+
["alg", "x25519"],
|
|
53
|
+
["binding_sig", params.bindingSigBase64]
|
|
54
|
+
];
|
|
55
|
+
const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1e3);
|
|
56
|
+
return {
|
|
57
|
+
kind: DEVICE_KIND,
|
|
58
|
+
tags,
|
|
59
|
+
content: params.bindingStatement,
|
|
60
|
+
created_at
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function signNostrEvent(event, nostrSkHex) {
|
|
64
|
+
const id = computeNostrEventId(event);
|
|
65
|
+
const sk = hexDecode(nostrSkHex);
|
|
66
|
+
const sig = schnorr.sign(hexDecode(id), sk);
|
|
67
|
+
return {
|
|
68
|
+
...event,
|
|
69
|
+
id,
|
|
70
|
+
sig: hexEncode(sig)
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function computeNostrEventId(event) {
|
|
74
|
+
const serialized = JSON.stringify([
|
|
75
|
+
0,
|
|
76
|
+
event.pubkey,
|
|
77
|
+
event.created_at,
|
|
78
|
+
event.kind,
|
|
79
|
+
event.tags,
|
|
80
|
+
event.content
|
|
81
|
+
]);
|
|
82
|
+
return hexEncode(sha256Bytes(utf8Encode(serialized)));
|
|
83
|
+
}
|
|
84
|
+
function finalizeDeviceEvent(params) {
|
|
85
|
+
const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);
|
|
86
|
+
const base = buildDeviceEvent({
|
|
87
|
+
address: params.address,
|
|
88
|
+
device_id: params.device_id,
|
|
89
|
+
device_pk: params.device_pk,
|
|
90
|
+
bindingStatement: params.bindingStatement,
|
|
91
|
+
bindingSigBase64: params.bindingSigBase64,
|
|
92
|
+
createdAtUnix: params.createdAtUnix
|
|
93
|
+
});
|
|
94
|
+
const withPubkey = { ...base, pubkey: nostrPk };
|
|
95
|
+
return signNostrEvent(withPubkey, nostrSk);
|
|
96
|
+
}
|
|
97
|
+
function parseDeviceEvent(event) {
|
|
98
|
+
if (event.kind !== DEVICE_KIND) {
|
|
99
|
+
throw new Error("not a device event (kind mismatch)");
|
|
100
|
+
}
|
|
101
|
+
const tag = (name) => {
|
|
102
|
+
for (const t of event.tags) {
|
|
103
|
+
if (t[0] === name && t.length > 1) return t[1] ?? null;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
107
|
+
const address = tag("addr");
|
|
108
|
+
const device_id = tag("device_id");
|
|
109
|
+
const device_pk = tag("device_pk");
|
|
110
|
+
const binding_sig = tag("binding_sig");
|
|
111
|
+
if (!address || !device_id || !device_pk || !binding_sig) {
|
|
112
|
+
throw new Error("device event missing required tags");
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
address,
|
|
116
|
+
device_id,
|
|
117
|
+
device_pk,
|
|
118
|
+
bindingStatement: event.content,
|
|
119
|
+
bindingSigBase64: binding_sig,
|
|
120
|
+
revoked: device_pk === "revoked",
|
|
121
|
+
createdAtUnix: event.created_at,
|
|
122
|
+
nostrPubkey: event.pubkey,
|
|
123
|
+
eventId: event.id
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { BINDING_VERSION, DEVICE_KIND, buildBindingStatement, buildDeviceEvent, buildRevocationStatement, computeNostrEventId, deriveNostrKey, finalizeDeviceEvent, generateDeviceKey, parseDeviceEvent, signNostrEvent };
|
|
128
|
+
//# sourceMappingURL=index.mjs.map
|
|
129
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["// Device key binding and Nostr directory publication.\n// See SPEC.md §3.\n\nimport { schnorr } from '@noble/curves/secp256k1';\nimport {\n generateX25519KeyPair,\n hexDecode,\n hexEncode,\n hkdfSha256,\n randomBytesN,\n sha256Bytes,\n utf8Encode,\n} from '@orangecheck/lock-crypto';\n\nexport const DEVICE_KIND = 30078;\nexport const BINDING_VERSION = 'v2';\n\nexport interface DeviceKeyPair {\n device_id: string;\n device_pk: string; // 32-byte hex\n device_sk: Uint8Array; // 32 bytes\n created_at: string; // iso8601\n}\n\nexport function generateDeviceKey(): DeviceKeyPair {\n const kp = generateX25519KeyPair();\n const deviceIdBytes = randomBytesN(16);\n return {\n device_id: hexEncode(deviceIdBytes),\n device_pk: hexEncode(kp.public),\n device_sk: kp.secret,\n created_at: new Date().toISOString(),\n };\n}\n\nexport function buildBindingStatement(params: {\n address: string;\n device_pk: string;\n device_id: string;\n created_at: string;\n}): string {\n // Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF\n // beyond the last field's. Order is fixed.\n const lines = [\n `oc-lock:device-bind:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_pk: ${params.device_pk}`,\n `device_id: ${params.device_id}`,\n `created_at: ${params.created_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\nexport function buildRevocationStatement(params: {\n address: string;\n device_id: string;\n revoked_at: string;\n}): string {\n const lines = [\n `oc-lock:device-revoke:${BINDING_VERSION}`,\n `address: ${params.address}`,\n `device_id: ${params.device_id}`,\n `revoked_at: ${params.revoked_at}`,\n ];\n return lines.join('\\n') + '\\n';\n}\n\n// ---------------------------------------------------------------------------\n// Nostr event helpers\n// ---------------------------------------------------------------------------\n\nexport interface NostrEvent {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/**\n * Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.\n * Returns a 32-byte private key (hex).\n */\nexport function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {\n const sk = hkdfSha256(\n deviceSk,\n utf8Encode('oc-lock/v2/nostr-key'),\n utf8Encode('nostr-sk'),\n 32\n );\n const pk = schnorr.getPublicKey(sk);\n return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };\n}\n\n/**\n * Build a kind-30078 addressable event announcing a device binding.\n * The caller must supply the BIP-322 signature produced by the user's Bitcoin\n * wallet (we cannot sign BIP-322 in this package — that requires a wallet).\n */\nexport function buildDeviceEvent(params: {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {\n const tags: string[][] = [\n ['d', `oc-lock:device:${params.address}`],\n ['addr', params.address],\n ['device_id', params.device_id],\n ['device_pk', params.device_pk],\n ['alg', 'x25519'],\n ['binding_sig', params.bindingSigBase64],\n ];\n const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);\n return {\n kind: DEVICE_KIND,\n tags,\n content: params.bindingStatement,\n created_at,\n } as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };\n}\n\n/**\n * Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces\n * the full NostrEvent with id and sig.\n */\nexport function signNostrEvent(\n event: Omit<NostrEvent, 'id' | 'sig'>,\n nostrSkHex: string\n): NostrEvent {\n const id = computeNostrEventId(event);\n const sk = hexDecode(nostrSkHex);\n const sig = schnorr.sign(hexDecode(id), sk);\n return {\n ...event,\n id,\n sig: hexEncode(sig),\n };\n}\n\nexport function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {\n // Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).\n // The array must be serialized in *exactly* this form with no whitespace.\n const serialized = JSON.stringify([\n 0,\n event.pubkey,\n event.created_at,\n event.kind,\n event.tags,\n event.content,\n ]);\n return hexEncode(sha256Bytes(utf8Encode(serialized)));\n}\n\n/**\n * Finalize a device event: derive Nostr key from device_sk, attach pubkey,\n * compute id, sign. Returns a fully-signed NIP-01 event ready to publish.\n */\nexport function finalizeDeviceEvent(params: {\n deviceSk: Uint8Array;\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n createdAtUnix?: number;\n}): NostrEvent {\n const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);\n const base = buildDeviceEvent({\n address: params.address,\n device_id: params.device_id,\n device_pk: params.device_pk,\n bindingStatement: params.bindingStatement,\n bindingSigBase64: params.bindingSigBase64,\n createdAtUnix: params.createdAtUnix,\n });\n const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;\n return signNostrEvent(withPubkey, nostrSk);\n}\n\n// ---------------------------------------------------------------------------\n// Parsing\n// ---------------------------------------------------------------------------\n\nexport interface ParsedDeviceEvent {\n address: string;\n device_id: string;\n device_pk: string;\n bindingStatement: string;\n bindingSigBase64: string;\n revoked: boolean;\n createdAtUnix: number;\n nostrPubkey: string;\n eventId: string;\n}\n\nexport function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {\n if (event.kind !== DEVICE_KIND) {\n throw new Error('not a device event (kind mismatch)');\n }\n const tag = (name: string): string | null => {\n for (const t of event.tags) {\n if (t[0] === name && t.length > 1) return t[1] ?? null;\n }\n return null;\n };\n const address = tag('addr');\n const device_id = tag('device_id');\n const device_pk = tag('device_pk');\n const binding_sig = tag('binding_sig');\n if (!address || !device_id || !device_pk || !binding_sig) {\n throw new Error('device event missing required tags');\n }\n return {\n address,\n device_id,\n device_pk,\n bindingStatement: event.content,\n bindingSigBase64: binding_sig,\n revoked: device_pk === 'revoked',\n createdAtUnix: event.created_at,\n nostrPubkey: event.pubkey,\n eventId: event.id,\n };\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orangecheck/lock-device",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Device-key binding and Nostr kind-30078 directory publication for OC Lock.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bitcoin",
|
|
7
|
+
"bip322",
|
|
8
|
+
"nostr",
|
|
9
|
+
"encryption",
|
|
10
|
+
"oc-lock",
|
|
11
|
+
"orangecheck"
|
|
12
|
+
],
|
|
13
|
+
"author": "OrangeCheck",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/orangecheck/oc-packages.git",
|
|
18
|
+
"directory": "lock-device"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/orangecheck/oc-lock-protocol",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/orangecheck/oc-lock-protocol/issues"
|
|
23
|
+
},
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"module": "./dist/index.mjs",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.mjs",
|
|
31
|
+
"require": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsup",
|
|
42
|
+
"dev": "tsup --watch",
|
|
43
|
+
"type-check": "tsc --noEmit",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest",
|
|
46
|
+
"clean": "rm -rf dist",
|
|
47
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@noble/curves": "^1.6.0",
|
|
51
|
+
"@orangecheck/lock-crypto": "^0.1.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.10.2",
|
|
55
|
+
"tsup": "^8.3.5",
|
|
56
|
+
"typescript": "^5.7.2",
|
|
57
|
+
"vitest": "^3.2.4"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
buildBindingStatement,
|
|
4
|
+
buildRevocationStatement,
|
|
5
|
+
computeNostrEventId,
|
|
6
|
+
deriveNostrKey,
|
|
7
|
+
finalizeDeviceEvent,
|
|
8
|
+
generateDeviceKey,
|
|
9
|
+
parseDeviceEvent,
|
|
10
|
+
} from './index.js';
|
|
11
|
+
|
|
12
|
+
describe('device', () => {
|
|
13
|
+
it('generates a device key with expected shape', () => {
|
|
14
|
+
const k = generateDeviceKey();
|
|
15
|
+
expect(k.device_pk).toMatch(/^[0-9a-f]{64}$/);
|
|
16
|
+
expect(k.device_id).toMatch(/^[0-9a-f]{32}$/);
|
|
17
|
+
expect(k.device_sk.length).toBe(32);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('binding statement is deterministic and canonical', () => {
|
|
21
|
+
const s = buildBindingStatement({
|
|
22
|
+
address: 'bc1qalice',
|
|
23
|
+
device_pk: 'aa'.repeat(32),
|
|
24
|
+
device_id: '01'.repeat(16),
|
|
25
|
+
created_at: '2026-04-22T14:03:11.000Z',
|
|
26
|
+
});
|
|
27
|
+
expect(s).toBe(
|
|
28
|
+
'oc-lock:device-bind:v2\n' +
|
|
29
|
+
'address: bc1qalice\n' +
|
|
30
|
+
'device_pk: ' + 'aa'.repeat(32) + '\n' +
|
|
31
|
+
'device_id: ' + '01'.repeat(16) + '\n' +
|
|
32
|
+
'created_at: 2026-04-22T14:03:11.000Z\n'
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('revocation statement is canonical', () => {
|
|
37
|
+
const s = buildRevocationStatement({
|
|
38
|
+
address: 'bc1qalice',
|
|
39
|
+
device_id: '01'.repeat(16),
|
|
40
|
+
revoked_at: '2026-05-01T00:00:00.000Z',
|
|
41
|
+
});
|
|
42
|
+
expect(s).toBe(
|
|
43
|
+
'oc-lock:device-revoke:v2\n' +
|
|
44
|
+
'address: bc1qalice\n' +
|
|
45
|
+
'device_id: ' + '01'.repeat(16) + '\n' +
|
|
46
|
+
'revoked_at: 2026-05-01T00:00:00.000Z\n'
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('derives a deterministic Nostr key from device secret', () => {
|
|
51
|
+
const k = generateDeviceKey();
|
|
52
|
+
const n1 = deriveNostrKey(k.device_sk);
|
|
53
|
+
const n2 = deriveNostrKey(k.device_sk);
|
|
54
|
+
expect(n1.nostrSk).toBe(n2.nostrSk);
|
|
55
|
+
expect(n1.nostrPk).toBe(n2.nostrPk);
|
|
56
|
+
expect(n1.nostrPk).toMatch(/^[0-9a-f]{64}$/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('finalized device event has stable id and valid signature', () => {
|
|
60
|
+
const k = generateDeviceKey();
|
|
61
|
+
const binding = buildBindingStatement({
|
|
62
|
+
address: 'bc1qalice',
|
|
63
|
+
device_pk: k.device_pk,
|
|
64
|
+
device_id: k.device_id,
|
|
65
|
+
created_at: k.created_at,
|
|
66
|
+
});
|
|
67
|
+
const event = finalizeDeviceEvent({
|
|
68
|
+
deviceSk: k.device_sk,
|
|
69
|
+
address: 'bc1qalice',
|
|
70
|
+
device_id: k.device_id,
|
|
71
|
+
device_pk: k.device_pk,
|
|
72
|
+
bindingStatement: binding,
|
|
73
|
+
bindingSigBase64: 'fakeBip322Sig==',
|
|
74
|
+
createdAtUnix: 1_700_000_000,
|
|
75
|
+
});
|
|
76
|
+
expect(event.id).toMatch(/^[0-9a-f]{64}$/);
|
|
77
|
+
expect(event.sig).toMatch(/^[0-9a-f]{128}$/);
|
|
78
|
+
// Recomputed id must match.
|
|
79
|
+
const { id: _id, sig: _sig, ...rest } = event;
|
|
80
|
+
const recomputed = computeNostrEventId(rest);
|
|
81
|
+
expect(recomputed).toBe(event.id);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('round-trips through parseDeviceEvent', () => {
|
|
85
|
+
const k = generateDeviceKey();
|
|
86
|
+
const binding = buildBindingStatement({
|
|
87
|
+
address: 'bc1qalice',
|
|
88
|
+
device_pk: k.device_pk,
|
|
89
|
+
device_id: k.device_id,
|
|
90
|
+
created_at: k.created_at,
|
|
91
|
+
});
|
|
92
|
+
const event = finalizeDeviceEvent({
|
|
93
|
+
deviceSk: k.device_sk,
|
|
94
|
+
address: 'bc1qalice',
|
|
95
|
+
device_id: k.device_id,
|
|
96
|
+
device_pk: k.device_pk,
|
|
97
|
+
bindingStatement: binding,
|
|
98
|
+
bindingSigBase64: 'sig==',
|
|
99
|
+
});
|
|
100
|
+
const parsed = parseDeviceEvent(event);
|
|
101
|
+
expect(parsed.address).toBe('bc1qalice');
|
|
102
|
+
expect(parsed.device_pk).toBe(k.device_pk);
|
|
103
|
+
expect(parsed.device_id).toBe(k.device_id);
|
|
104
|
+
expect(parsed.revoked).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('detects revoked device records', () => {
|
|
108
|
+
const k = generateDeviceKey();
|
|
109
|
+
const event = finalizeDeviceEvent({
|
|
110
|
+
deviceSk: k.device_sk,
|
|
111
|
+
address: 'bc1qalice',
|
|
112
|
+
device_id: k.device_id,
|
|
113
|
+
device_pk: 'revoked',
|
|
114
|
+
bindingStatement: buildRevocationStatement({
|
|
115
|
+
address: 'bc1qalice',
|
|
116
|
+
device_id: k.device_id,
|
|
117
|
+
revoked_at: new Date().toISOString(),
|
|
118
|
+
}),
|
|
119
|
+
bindingSigBase64: 'revoked-sig==',
|
|
120
|
+
});
|
|
121
|
+
const parsed = parseDeviceEvent(event);
|
|
122
|
+
expect(parsed.revoked).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Device key binding and Nostr directory publication.
|
|
2
|
+
// See SPEC.md §3.
|
|
3
|
+
|
|
4
|
+
import { schnorr } from '@noble/curves/secp256k1';
|
|
5
|
+
import {
|
|
6
|
+
generateX25519KeyPair,
|
|
7
|
+
hexDecode,
|
|
8
|
+
hexEncode,
|
|
9
|
+
hkdfSha256,
|
|
10
|
+
randomBytesN,
|
|
11
|
+
sha256Bytes,
|
|
12
|
+
utf8Encode,
|
|
13
|
+
} from '@orangecheck/lock-crypto';
|
|
14
|
+
|
|
15
|
+
export const DEVICE_KIND = 30078;
|
|
16
|
+
export const BINDING_VERSION = 'v2';
|
|
17
|
+
|
|
18
|
+
export interface DeviceKeyPair {
|
|
19
|
+
device_id: string;
|
|
20
|
+
device_pk: string; // 32-byte hex
|
|
21
|
+
device_sk: Uint8Array; // 32 bytes
|
|
22
|
+
created_at: string; // iso8601
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function generateDeviceKey(): DeviceKeyPair {
|
|
26
|
+
const kp = generateX25519KeyPair();
|
|
27
|
+
const deviceIdBytes = randomBytesN(16);
|
|
28
|
+
return {
|
|
29
|
+
device_id: hexEncode(deviceIdBytes),
|
|
30
|
+
device_pk: hexEncode(kp.public),
|
|
31
|
+
device_sk: kp.secret,
|
|
32
|
+
created_at: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildBindingStatement(params: {
|
|
37
|
+
address: string;
|
|
38
|
+
device_pk: string;
|
|
39
|
+
device_id: string;
|
|
40
|
+
created_at: string;
|
|
41
|
+
}): string {
|
|
42
|
+
// Exact byte layout per SPEC §3.2. Each line ends with LF; no trailing LF
|
|
43
|
+
// beyond the last field's. Order is fixed.
|
|
44
|
+
const lines = [
|
|
45
|
+
`oc-lock:device-bind:${BINDING_VERSION}`,
|
|
46
|
+
`address: ${params.address}`,
|
|
47
|
+
`device_pk: ${params.device_pk}`,
|
|
48
|
+
`device_id: ${params.device_id}`,
|
|
49
|
+
`created_at: ${params.created_at}`,
|
|
50
|
+
];
|
|
51
|
+
return lines.join('\n') + '\n';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildRevocationStatement(params: {
|
|
55
|
+
address: string;
|
|
56
|
+
device_id: string;
|
|
57
|
+
revoked_at: string;
|
|
58
|
+
}): string {
|
|
59
|
+
const lines = [
|
|
60
|
+
`oc-lock:device-revoke:${BINDING_VERSION}`,
|
|
61
|
+
`address: ${params.address}`,
|
|
62
|
+
`device_id: ${params.device_id}`,
|
|
63
|
+
`revoked_at: ${params.revoked_at}`,
|
|
64
|
+
];
|
|
65
|
+
return lines.join('\n') + '\n';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Nostr event helpers
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export interface NostrEvent {
|
|
73
|
+
id: string;
|
|
74
|
+
pubkey: string;
|
|
75
|
+
created_at: number;
|
|
76
|
+
kind: number;
|
|
77
|
+
tags: string[][];
|
|
78
|
+
content: string;
|
|
79
|
+
sig: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Derive an ephemeral Nostr keypair from a device secret per SPEC §3.4.
|
|
84
|
+
* Returns a 32-byte private key (hex).
|
|
85
|
+
*/
|
|
86
|
+
export function deriveNostrKey(deviceSk: Uint8Array): { nostrSk: string; nostrPk: string } {
|
|
87
|
+
const sk = hkdfSha256(
|
|
88
|
+
deviceSk,
|
|
89
|
+
utf8Encode('oc-lock/v2/nostr-key'),
|
|
90
|
+
utf8Encode('nostr-sk'),
|
|
91
|
+
32
|
|
92
|
+
);
|
|
93
|
+
const pk = schnorr.getPublicKey(sk);
|
|
94
|
+
return { nostrSk: hexEncode(sk), nostrPk: hexEncode(pk) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a kind-30078 addressable event announcing a device binding.
|
|
99
|
+
* The caller must supply the BIP-322 signature produced by the user's Bitcoin
|
|
100
|
+
* wallet (we cannot sign BIP-322 in this package — that requires a wallet).
|
|
101
|
+
*/
|
|
102
|
+
export function buildDeviceEvent(params: {
|
|
103
|
+
address: string;
|
|
104
|
+
device_id: string;
|
|
105
|
+
device_pk: string;
|
|
106
|
+
bindingStatement: string;
|
|
107
|
+
bindingSigBase64: string;
|
|
108
|
+
createdAtUnix?: number;
|
|
109
|
+
}): Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined } {
|
|
110
|
+
const tags: string[][] = [
|
|
111
|
+
['d', `oc-lock:device:${params.address}`],
|
|
112
|
+
['addr', params.address],
|
|
113
|
+
['device_id', params.device_id],
|
|
114
|
+
['device_pk', params.device_pk],
|
|
115
|
+
['alg', 'x25519'],
|
|
116
|
+
['binding_sig', params.bindingSigBase64],
|
|
117
|
+
];
|
|
118
|
+
const created_at = params.createdAtUnix ?? Math.floor(Date.now() / 1000);
|
|
119
|
+
return {
|
|
120
|
+
kind: DEVICE_KIND,
|
|
121
|
+
tags,
|
|
122
|
+
content: params.bindingStatement,
|
|
123
|
+
created_at,
|
|
124
|
+
} as unknown as Omit<NostrEvent, 'id' | 'sig' | 'pubkey'> & { pubkey?: undefined };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Sign a Nostr event with a Schnorr key (derived via deriveNostrKey). Produces
|
|
129
|
+
* the full NostrEvent with id and sig.
|
|
130
|
+
*/
|
|
131
|
+
export function signNostrEvent(
|
|
132
|
+
event: Omit<NostrEvent, 'id' | 'sig'>,
|
|
133
|
+
nostrSkHex: string
|
|
134
|
+
): NostrEvent {
|
|
135
|
+
const id = computeNostrEventId(event);
|
|
136
|
+
const sk = hexDecode(nostrSkHex);
|
|
137
|
+
const sig = schnorr.sign(hexDecode(id), sk);
|
|
138
|
+
return {
|
|
139
|
+
...event,
|
|
140
|
+
id,
|
|
141
|
+
sig: hexEncode(sig),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function computeNostrEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {
|
|
146
|
+
// Per NIP-01: id = sha256(utf8(JSON.stringify([0, pubkey, created_at, kind, tags, content]))).
|
|
147
|
+
// The array must be serialized in *exactly* this form with no whitespace.
|
|
148
|
+
const serialized = JSON.stringify([
|
|
149
|
+
0,
|
|
150
|
+
event.pubkey,
|
|
151
|
+
event.created_at,
|
|
152
|
+
event.kind,
|
|
153
|
+
event.tags,
|
|
154
|
+
event.content,
|
|
155
|
+
]);
|
|
156
|
+
return hexEncode(sha256Bytes(utf8Encode(serialized)));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Finalize a device event: derive Nostr key from device_sk, attach pubkey,
|
|
161
|
+
* compute id, sign. Returns a fully-signed NIP-01 event ready to publish.
|
|
162
|
+
*/
|
|
163
|
+
export function finalizeDeviceEvent(params: {
|
|
164
|
+
deviceSk: Uint8Array;
|
|
165
|
+
address: string;
|
|
166
|
+
device_id: string;
|
|
167
|
+
device_pk: string;
|
|
168
|
+
bindingStatement: string;
|
|
169
|
+
bindingSigBase64: string;
|
|
170
|
+
createdAtUnix?: number;
|
|
171
|
+
}): NostrEvent {
|
|
172
|
+
const { nostrSk, nostrPk } = deriveNostrKey(params.deviceSk);
|
|
173
|
+
const base = buildDeviceEvent({
|
|
174
|
+
address: params.address,
|
|
175
|
+
device_id: params.device_id,
|
|
176
|
+
device_pk: params.device_pk,
|
|
177
|
+
bindingStatement: params.bindingStatement,
|
|
178
|
+
bindingSigBase64: params.bindingSigBase64,
|
|
179
|
+
createdAtUnix: params.createdAtUnix,
|
|
180
|
+
});
|
|
181
|
+
const withPubkey = { ...base, pubkey: nostrPk } as Omit<NostrEvent, 'id' | 'sig'>;
|
|
182
|
+
return signNostrEvent(withPubkey, nostrSk);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Parsing
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
export interface ParsedDeviceEvent {
|
|
190
|
+
address: string;
|
|
191
|
+
device_id: string;
|
|
192
|
+
device_pk: string;
|
|
193
|
+
bindingStatement: string;
|
|
194
|
+
bindingSigBase64: string;
|
|
195
|
+
revoked: boolean;
|
|
196
|
+
createdAtUnix: number;
|
|
197
|
+
nostrPubkey: string;
|
|
198
|
+
eventId: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function parseDeviceEvent(event: NostrEvent): ParsedDeviceEvent {
|
|
202
|
+
if (event.kind !== DEVICE_KIND) {
|
|
203
|
+
throw new Error('not a device event (kind mismatch)');
|
|
204
|
+
}
|
|
205
|
+
const tag = (name: string): string | null => {
|
|
206
|
+
for (const t of event.tags) {
|
|
207
|
+
if (t[0] === name && t.length > 1) return t[1] ?? null;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
};
|
|
211
|
+
const address = tag('addr');
|
|
212
|
+
const device_id = tag('device_id');
|
|
213
|
+
const device_pk = tag('device_pk');
|
|
214
|
+
const binding_sig = tag('binding_sig');
|
|
215
|
+
if (!address || !device_id || !device_pk || !binding_sig) {
|
|
216
|
+
throw new Error('device event missing required tags');
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
address,
|
|
220
|
+
device_id,
|
|
221
|
+
device_pk,
|
|
222
|
+
bindingStatement: event.content,
|
|
223
|
+
bindingSigBase64: binding_sig,
|
|
224
|
+
revoked: device_pk === 'revoked',
|
|
225
|
+
createdAtUnix: event.created_at,
|
|
226
|
+
nostrPubkey: event.pubkey,
|
|
227
|
+
eventId: event.id,
|
|
228
|
+
};
|
|
229
|
+
}
|