did-beta-bch 1.2.0 → 2.0.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/CHANGELOG.md +25 -2
- package/PROTOCOL.md +146 -116
- package/README.md +76 -20
- package/dist/anchor.d.ts +2 -1
- package/dist/anchor.js +20 -7
- package/dist/binding.d.ts +5 -11
- package/dist/binding.js +19 -4
- package/dist/credentials.d.ts +8 -2
- package/dist/credentials.js +59 -13
- package/dist/disclosure.d.ts +8 -4
- package/dist/disclosure.js +18 -6
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/nip98.d.ts +2 -0
- package/dist/nip98.js +25 -5
- package/dist/relay.js +10 -6
- package/dist/signing.d.ts +16 -0
- package/dist/signing.js +30 -0
- package/dist/verify.d.ts +12 -13
- package/dist/verify.js +327 -98
- package/package.json +4 -2
package/dist/verify.js
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
import { verifyEvent, nip05 } from "nostr-tools";
|
|
2
2
|
import { secp256k1, hash256, encodeCashAddress, hash160 } from "@bitauth/libauth";
|
|
3
|
-
import { canonicalize, sha256hex, bchSigningPayload, BINDING_KIND, PROTOCOL_ID, } from "./binding.js";
|
|
3
|
+
import { canonicalize, sha256hex, bchSigningPayload, BINDING_KIND, PROTOCOL_ID, PROTOCOL_VERSION, } from "./binding.js";
|
|
4
4
|
import { queryRelays, DEFAULT_RELAYS } from "./relay.js";
|
|
5
5
|
import { CashletDidError } from "./errors.js";
|
|
6
6
|
import { nip19 } from "nostr-tools";
|
|
7
|
-
|
|
7
|
+
import { BCH_PROOF_FORMAT, normalizeBchMessageSignature, } from "./signing.js";
|
|
8
|
+
/** Encode n as a Bitcoin CompactSize integer. */
|
|
8
9
|
function varint(n) {
|
|
9
10
|
if (n < 0xfd)
|
|
10
11
|
return new Uint8Array([n]);
|
|
12
|
+
const value = BigInt(n);
|
|
11
13
|
if (n <= 0xffff)
|
|
12
|
-
return new Uint8Array([0xfd,
|
|
13
|
-
|
|
14
|
+
return new Uint8Array([0xfd, Number(value & 0xffn), Number((value >> 8n) & 0xffn)]);
|
|
15
|
+
if (n <= 0xffffffff)
|
|
16
|
+
return new Uint8Array([
|
|
17
|
+
0xfe,
|
|
18
|
+
Number(value & 0xffn),
|
|
19
|
+
Number((value >> 8n) & 0xffn),
|
|
20
|
+
Number((value >> 16n) & 0xffn),
|
|
21
|
+
Number((value >> 24n) & 0xffn),
|
|
22
|
+
]);
|
|
23
|
+
return new Uint8Array([
|
|
24
|
+
0xff,
|
|
25
|
+
...Array.from({ length: 8 }, (_, i) => Number((value >> BigInt(i * 8)) & 0xffn)),
|
|
26
|
+
]);
|
|
14
27
|
}
|
|
15
28
|
/**
|
|
16
29
|
* Compute the double-SHA256 hash used by Bitcoin signed messages.
|
|
@@ -23,16 +36,28 @@ function bitcoinMsgHash(message) {
|
|
|
23
36
|
const prefix = enc.encode("\x18Bitcoin Signed Message:\n");
|
|
24
37
|
return hash256(new Uint8Array([...prefix, ...varint(msgBytes.length), ...msgBytes]));
|
|
25
38
|
}
|
|
26
|
-
function recoverBchAddress(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
function recoverBchAddress(signatureBase64, msgHash, expectedAddress) {
|
|
40
|
+
try {
|
|
41
|
+
normalizeBchMessageSignature(signatureBase64);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const bytes = Uint8Array.from(atob(signatureBase64), c => c.charCodeAt(0));
|
|
47
|
+
const recoveryId = (bytes[0] - 31);
|
|
48
|
+
const compact = bytes.slice(1);
|
|
49
|
+
const prefixes = expectedAddress
|
|
50
|
+
? [expectedAddress.split(":")[0]]
|
|
51
|
+
: ["bitcoincash", "bchtest"];
|
|
52
|
+
const tryRecover = (prefix) => {
|
|
30
53
|
try {
|
|
31
|
-
|
|
54
|
+
if (prefix !== "bitcoincash" && prefix !== "bchtest")
|
|
55
|
+
return null;
|
|
56
|
+
const pub = secp256k1.recoverPublicKeyCompressed(compact, recoveryId, msgHash);
|
|
32
57
|
if (!(pub instanceof Uint8Array))
|
|
33
58
|
return null;
|
|
34
59
|
for (const type of ["p2pkh", "p2pkhWithTokens"]) {
|
|
35
|
-
const r = encodeCashAddress({ prefix:
|
|
60
|
+
const r = encodeCashAddress({ prefix: prefix, type, payload: hash160(pub) });
|
|
36
61
|
const addr = r?.address ?? (typeof r === "string" ? r : null);
|
|
37
62
|
if (addr && (!expectedAddress || addr === expectedAddress))
|
|
38
63
|
return addr;
|
|
@@ -41,108 +66,232 @@ function recoverBchAddress(sigHex, msgHash, expectedAddress) {
|
|
|
41
66
|
catch { /* try next */ }
|
|
42
67
|
return null;
|
|
43
68
|
};
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return tryRecover(sig.slice(1), (sig[0] & 0x03));
|
|
47
|
-
}
|
|
48
|
-
// 64-byte raw compact: try all recovery IDs
|
|
49
|
-
for (const id of [0, 1, 2, 3]) {
|
|
50
|
-
const addr = tryRecover(sig, id);
|
|
69
|
+
for (const prefix of prefixes) {
|
|
70
|
+
const addr = tryRecover(prefix);
|
|
51
71
|
if (addr)
|
|
52
72
|
return addr;
|
|
53
73
|
}
|
|
54
74
|
return null;
|
|
55
75
|
}
|
|
76
|
+
function decodeNpub(npub) {
|
|
77
|
+
if (typeof npub !== "string")
|
|
78
|
+
return null;
|
|
79
|
+
try {
|
|
80
|
+
const decoded = nip19.decode(npub);
|
|
81
|
+
if (decoded.type !== "npub" || typeof decoded.data !== "string" || !/^[0-9a-f]{64}$/.test(decoded.data))
|
|
82
|
+
return null;
|
|
83
|
+
return decoded.data;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
56
89
|
/** Resolve a BCH address from an npub via Nostr relays */
|
|
57
90
|
export async function resolveIdentity(npub, relays = DEFAULT_RELAYS) {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
91
|
+
const pubkey = decodeNpub(npub);
|
|
92
|
+
if (!pubkey)
|
|
60
93
|
return null;
|
|
61
|
-
const pubkey = decoded.data;
|
|
62
94
|
const [events, revocations] = await Promise.all([
|
|
63
95
|
queryRelays(relays, { kinds: [BINDING_KIND], authors: [pubkey], "#t": [PROTOCOL_ID], limit: 10 }),
|
|
64
96
|
queryRelays(relays, { kinds: [5], authors: [pubkey], limit: 50 }),
|
|
65
97
|
]);
|
|
66
98
|
if (!events.length)
|
|
67
99
|
return null;
|
|
68
|
-
const revokedIds = new Set(revocations.flatMap(e =>
|
|
100
|
+
const revokedIds = new Set(revocations.flatMap(e => Array.isArray(e.tags)
|
|
101
|
+
? e.tags.filter(t => Array.isArray(t) && t[0] === "e" && typeof t[1] === "string").map(t => t[1])
|
|
102
|
+
: []));
|
|
69
103
|
// Pick latest non-revoked event
|
|
70
|
-
const
|
|
104
|
+
const resolvedEvents = events
|
|
105
|
+
.filter(e => e && e.pubkey === pubkey && Array.isArray(e.tags) && typeof e.content === "string")
|
|
71
106
|
.filter(e => !revokedIds.has(e.id))
|
|
72
|
-
.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const dTag = event.tags.find(t => t[0] === "d");
|
|
77
|
-
const bchProofTag = event.tags.find(t => t[0] === "bch-proof");
|
|
78
|
-
if (!bchTag || !dTag || !bchProofTag)
|
|
79
|
-
return null;
|
|
80
|
-
let createdAt = event.created_at;
|
|
81
|
-
try {
|
|
82
|
-
createdAt = JSON.parse(event.content).createdAt ?? createdAt;
|
|
83
|
-
}
|
|
84
|
-
catch { /* use event ts */ }
|
|
85
|
-
const anchorTag = event.tags.find(t => t[0] === "bch-anchor");
|
|
86
|
-
const anchoredAt = anchorTag?.[2] ? Number(anchorTag[2]) : undefined;
|
|
87
|
-
return {
|
|
88
|
-
npub,
|
|
89
|
-
pubkey,
|
|
90
|
-
bchAddress: bchTag[1],
|
|
91
|
-
network: bchTag[2] ?? "mainnet",
|
|
92
|
-
bindingId: dTag[1],
|
|
93
|
-
createdAt,
|
|
94
|
-
bchProofTag,
|
|
95
|
-
rawEvent: event,
|
|
96
|
-
...(anchoredAt !== undefined ? { anchoredAt } : {}),
|
|
97
|
-
};
|
|
107
|
+
.map(event => resolvedIdentityFromEvent(event))
|
|
108
|
+
.filter((identity) => identity !== null)
|
|
109
|
+
.sort((a, b) => b.rawEvent.created_at - a.rawEvent.created_at);
|
|
110
|
+
return resolvedEvents[0] ? { ...resolvedEvents[0], npub } : null;
|
|
98
111
|
}
|
|
99
112
|
/** Verify the BCH + Nostr signatures on a resolved identity */
|
|
100
113
|
export async function verifyIdentity(resolved) {
|
|
114
|
+
let signatureBase64;
|
|
115
|
+
if (!resolved ||
|
|
116
|
+
!Array.isArray(resolved.bchProofTag) ||
|
|
117
|
+
resolved.bchProofTag.length !== 4 ||
|
|
118
|
+
resolved.bchProofTag[0] !== "bch-proof" ||
|
|
119
|
+
resolved.bchProofTag[1] !== BCH_PROOF_FORMAT ||
|
|
120
|
+
typeof resolved.bchProofTag[2] !== "string" ||
|
|
121
|
+
typeof resolved.bchProofTag[3] !== "string" ||
|
|
122
|
+
!resolved.rawEvent ||
|
|
123
|
+
!Array.isArray(resolved.rawEvent.tags) ||
|
|
124
|
+
typeof resolved.rawEvent.content !== "string") {
|
|
125
|
+
return { status: "malformed", bchValid: false, nostrValid: false, details: "Malformed identity proof" };
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
signatureBase64 = normalizeBchMessageSignature(resolved.bchProofTag[3]);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { status: "malformed", bchValid: false, nostrValid: false, details: "Malformed BCH compact signature" };
|
|
132
|
+
}
|
|
101
133
|
const { bchProofTag, rawEvent, bchAddress, bindingId, pubkey } = resolved;
|
|
102
|
-
const [,
|
|
134
|
+
const [, , message] = bchProofTag;
|
|
103
135
|
// 1. Nostr event signature
|
|
104
136
|
let nostrValid = false;
|
|
137
|
+
const tagsAreStrings = rawEvent.tags.every(tag => Array.isArray(tag) && tag.every(value => typeof value === "string"));
|
|
105
138
|
try {
|
|
106
|
-
nostrValid = verifyEvent(rawEvent);
|
|
107
|
-
const dTag = rawEvent.tags.find(t => t[0] === "d");
|
|
108
|
-
const bchTag = rawEvent.tags.find(t => t[0] === "bch");
|
|
109
|
-
if (dTag?.[1] !== bindingId || bchTag?.[1] !== bchAddress)
|
|
110
|
-
nostrValid = false;
|
|
111
|
-
if (rawEvent.pubkey !== pubkey)
|
|
112
|
-
nostrValid = false;
|
|
139
|
+
nostrValid = tagsAreStrings && verifyEvent(rawEvent);
|
|
113
140
|
}
|
|
114
141
|
catch {
|
|
115
142
|
nostrValid = false;
|
|
116
143
|
}
|
|
117
|
-
|
|
144
|
+
let bindingMatches = false;
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(rawEvent.content);
|
|
147
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
148
|
+
throw new Error("Binding content must be an object");
|
|
149
|
+
const content = parsed;
|
|
150
|
+
const bch = content.bch;
|
|
151
|
+
const getTags = (name) => rawEvent.tags.filter(tag => tag[0] === name);
|
|
152
|
+
const exactlyOne = (name) => {
|
|
153
|
+
const matches = getTags(name);
|
|
154
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
155
|
+
};
|
|
156
|
+
const optionalTag = (name) => {
|
|
157
|
+
const matches = getTags(name);
|
|
158
|
+
return matches.length <= 1 ? matches[0] : undefined;
|
|
159
|
+
};
|
|
160
|
+
const dTag = exactlyOne("d");
|
|
161
|
+
const bchTag = exactlyOne("bch");
|
|
162
|
+
const protocolTag = exactlyOne("protocol");
|
|
163
|
+
const markerTag = exactlyOne("t");
|
|
164
|
+
const proofTag = exactlyOne("bch-proof");
|
|
165
|
+
const paymentCodeTag = optionalTag("bch-payment-code");
|
|
166
|
+
const supersedesTag = optionalTag("supersedes");
|
|
167
|
+
const anchorTag = optionalTag("bch-anchor");
|
|
168
|
+
const previousBindingId = content.previousBindingId;
|
|
169
|
+
const contentKeys = Object.keys(content).sort().join(",");
|
|
170
|
+
const allowedContentKeys = ["bch", "bindingId", "createdAt", "nonce", "previousBindingId", "protocol", "version"]
|
|
171
|
+
.filter(key => content[key] !== undefined).sort().join(",");
|
|
172
|
+
const bchKeys = bch && typeof bch === "object" && !Array.isArray(bch)
|
|
173
|
+
? Object.keys(bch).sort().join(",")
|
|
174
|
+
: "";
|
|
175
|
+
const expectedBchKeys = bch?.paymentCode === undefined ? "address,network" : "address,network,paymentCode";
|
|
176
|
+
const expectedPrefix = bch?.network === "mainnet" ? "bitcoincash" : "bchtest";
|
|
177
|
+
if (rawEvent.kind !== BINDING_KIND ||
|
|
178
|
+
rawEvent.created_at !== content.createdAt ||
|
|
179
|
+
contentKeys !== allowedContentKeys ||
|
|
180
|
+
rawEvent.content !== canonicalize(content) ||
|
|
181
|
+
content.protocol !== PROTOCOL_ID ||
|
|
182
|
+
content.version !== PROTOCOL_VERSION ||
|
|
183
|
+
!/^[0-9a-f]{64}$/.test(content.bindingId) ||
|
|
184
|
+
typeof bch?.address !== "string" ||
|
|
185
|
+
!["mainnet", "testnet", "chipnet"].includes(bch?.network) ||
|
|
186
|
+
!bch.address.startsWith(`${expectedPrefix}:`) ||
|
|
187
|
+
bchKeys !== expectedBchKeys ||
|
|
188
|
+
(bch.paymentCode !== undefined && (typeof bch.paymentCode !== "string" || bch.paymentCode.length === 0)) ||
|
|
189
|
+
!/^[0-9a-f]{64}$/.test(content.nonce) ||
|
|
190
|
+
!Number.isSafeInteger(content.createdAt) ||
|
|
191
|
+
(previousBindingId !== undefined && !/^[0-9a-f]{64}$/.test(previousBindingId)) ||
|
|
192
|
+
!dTag || dTag.length !== 2 ||
|
|
193
|
+
!bchTag || bchTag.length !== 3 ||
|
|
194
|
+
!protocolTag || protocolTag.length !== 3 ||
|
|
195
|
+
!markerTag || markerTag.length !== 2 ||
|
|
196
|
+
!proofTag || proofTag.length !== 4 ||
|
|
197
|
+
(bch.paymentCode === undefined ? paymentCodeTag !== undefined : (!paymentCodeTag || paymentCodeTag.length !== 2 || paymentCodeTag[1] !== bch.paymentCode)) ||
|
|
198
|
+
getTags("bch-payment-code").length > 1 ||
|
|
199
|
+
(previousBindingId === undefined ? supersedesTag !== undefined : (!supersedesTag || supersedesTag.length !== 2 || supersedesTag[1] !== previousBindingId)) ||
|
|
200
|
+
getTags("supersedes").length > 1 ||
|
|
201
|
+
getTags("bch-anchor").length > 1 ||
|
|
202
|
+
(anchorTag !== undefined && ((anchorTag.length !== 2 && anchorTag.length !== 3) ||
|
|
203
|
+
!/^[0-9a-f]{64}$/.test(anchorTag[1]) ||
|
|
204
|
+
(anchorTag.length === 3 && !/^\d+$/.test(anchorTag[2])))) ||
|
|
205
|
+
dTag?.[1] !== content.bindingId ||
|
|
206
|
+
bchTag?.[1] !== bch.address ||
|
|
207
|
+
bchTag?.[2] !== bch.network ||
|
|
208
|
+
protocolTag?.[1] !== PROTOCOL_ID ||
|
|
209
|
+
protocolTag?.[2] !== PROTOCOL_VERSION ||
|
|
210
|
+
markerTag?.[1] !== PROTOCOL_ID ||
|
|
211
|
+
proofTag?.[0] !== "bch-proof" ||
|
|
212
|
+
proofTag?.[1] !== BCH_PROOF_FORMAT ||
|
|
213
|
+
proofTag?.[2] !== message ||
|
|
214
|
+
proofTag?.[2] !== bchProofTag[2] ||
|
|
215
|
+
proofTag?.[3] !== signatureBase64 ||
|
|
216
|
+
content.bindingId !== bindingId ||
|
|
217
|
+
bch.address !== bchAddress ||
|
|
218
|
+
bch.network !== resolved.network ||
|
|
219
|
+
resolved.createdAt !== content.createdAt ||
|
|
220
|
+
decodeNpub(resolved.npub) !== pubkey ||
|
|
221
|
+
rawEvent.pubkey !== pubkey) {
|
|
222
|
+
return { status: "id_mismatch", bchValid: false, nostrValid, details: "Binding record does not match the event and resolved identity" };
|
|
223
|
+
}
|
|
224
|
+
const partial = {
|
|
225
|
+
protocol: PROTOCOL_ID,
|
|
226
|
+
version: PROTOCOL_VERSION,
|
|
227
|
+
bch: {
|
|
228
|
+
address: bch.address,
|
|
229
|
+
network: bch.network,
|
|
230
|
+
...(bch.paymentCode !== undefined ? { paymentCode: bch.paymentCode } : {}),
|
|
231
|
+
},
|
|
232
|
+
nostr: { pubkey: rawEvent.pubkey },
|
|
233
|
+
createdAt: content.createdAt,
|
|
234
|
+
nonce: content.nonce,
|
|
235
|
+
purpose: "identity-binding-v2",
|
|
236
|
+
...(previousBindingId !== undefined ? { previousBindingId } : {}),
|
|
237
|
+
};
|
|
238
|
+
const expectedBindingId = await sha256hex(canonicalize(partial));
|
|
239
|
+
const expectedMessage = bchSigningPayload({ ...partial, bindingId: content.bindingId });
|
|
240
|
+
bindingMatches = expectedBindingId === content.bindingId && message === expectedMessage;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return { status: "malformed", bchValid: false, nostrValid, details: "Malformed binding event content" };
|
|
244
|
+
}
|
|
245
|
+
if (!bindingMatches)
|
|
246
|
+
return { status: "id_mismatch", bchValid: false, nostrValid, details: "BCH proof does not commit to this binding" };
|
|
247
|
+
// 3. BCH signature recovery
|
|
118
248
|
const msgHash = bitcoinMsgHash(message);
|
|
119
|
-
const recovered = recoverBchAddress(
|
|
249
|
+
const recovered = recoverBchAddress(signatureBase64, msgHash, bchAddress);
|
|
120
250
|
const bchValid = recovered === bchAddress;
|
|
121
|
-
// 3. Payload integrity
|
|
122
|
-
if (!message.startsWith(`${PROTOCOL_ID}/bch-proof/`)) {
|
|
123
|
-
return { status: "malformed", bchValid: false, nostrValid, details: "Unexpected proof payload format" };
|
|
124
|
-
}
|
|
125
251
|
if (bchValid && nostrValid)
|
|
126
|
-
return { status: "verified", bchValid, nostrValid, details: "Both proofs valid"
|
|
252
|
+
return { status: "verified", bchValid, nostrValid, details: "Both proofs valid" };
|
|
127
253
|
if (!bchValid)
|
|
128
254
|
return { status: "invalid_bch_sig", bchValid, nostrValid, details: "BCH signature invalid" };
|
|
129
255
|
return { status: "invalid_nostr_sig", bchValid, nostrValid, details: "Nostr signature invalid" };
|
|
130
256
|
}
|
|
131
257
|
const CHALLENGE_TTL_SECS = 300; // 5 minutes
|
|
258
|
+
/** Build the exact versioned challenge message that a BCH wallet must sign. */
|
|
259
|
+
export function bchChallengePayload(nonce, audience) {
|
|
260
|
+
if (typeof nonce !== "string" || nonce.length === 0 || /[\r\n]/.test(nonce))
|
|
261
|
+
throw new TypeError("Challenge nonce must be a non-empty single-line string");
|
|
262
|
+
if (audience !== undefined && (typeof audience !== "string" || audience.length === 0 || /[\r\n]/.test(audience)))
|
|
263
|
+
throw new TypeError("Challenge audience must be a non-empty single-line string");
|
|
264
|
+
return `${PROTOCOL_ID}/challenge/v${PROTOCOL_VERSION}\n${nonce}${audience === undefined ? "" : `\n${audience}`}`;
|
|
265
|
+
}
|
|
132
266
|
/** Verify a challenge-response proof.
|
|
133
|
-
*
|
|
134
|
-
* Matches the SIGN_CHALLENGE format used by the Cashlet extension and any conforming client.
|
|
267
|
+
* The wallet signs the exact text produced by `bchChallengePayload`.
|
|
135
268
|
* @param issuedAt - unix timestamp when the challenge was issued; if provided, rejects proofs older than 5 minutes.
|
|
136
269
|
*/
|
|
137
270
|
export function verifyChallengeProof(proof, nonce, audience, issuedAt) {
|
|
138
|
-
if (
|
|
271
|
+
if (!proof ||
|
|
272
|
+
typeof proof.signature !== "string" ||
|
|
273
|
+
typeof proof.payload !== "string" ||
|
|
274
|
+
typeof proof.address !== "string" ||
|
|
275
|
+
typeof nonce !== "string")
|
|
139
276
|
return false;
|
|
140
|
-
if (
|
|
277
|
+
if (issuedAt !== undefined &&
|
|
278
|
+
(!Number.isSafeInteger(issuedAt) || Math.floor(Date.now() / 1000) - issuedAt > CHALLENGE_TTL_SECS))
|
|
141
279
|
return false;
|
|
142
|
-
|
|
280
|
+
let expectedPayload;
|
|
281
|
+
try {
|
|
282
|
+
expectedPayload = bchChallengePayload(nonce, audience);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
143
285
|
return false;
|
|
144
|
-
|
|
286
|
+
}
|
|
287
|
+
if (proof.payload !== expectedPayload)
|
|
288
|
+
return false;
|
|
289
|
+
try {
|
|
290
|
+
normalizeBchMessageSignature(proof.signature);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
145
293
|
return false;
|
|
294
|
+
}
|
|
146
295
|
const msgHash = bitcoinMsgHash(proof.payload);
|
|
147
296
|
return recoverBchAddress(proof.signature, msgHash, proof.address) === proof.address;
|
|
148
297
|
}
|
|
@@ -162,47 +311,127 @@ export async function resolveAddress(npub, relays = DEFAULT_RELAYS) {
|
|
|
162
311
|
const result = await verifyIdentity(identity);
|
|
163
312
|
return result.status === "verified" ? identity.bchAddress : null;
|
|
164
313
|
}
|
|
165
|
-
/** Returns
|
|
314
|
+
/** Returns verified binding events newest first, following and validating each supersedes reference. */
|
|
166
315
|
export async function resolveIdentityChain(npub, relays = DEFAULT_RELAYS) {
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
316
|
+
const pubkey = decodeNpub(npub);
|
|
317
|
+
if (!pubkey)
|
|
169
318
|
return [];
|
|
170
|
-
const pubkey = decoded.data;
|
|
171
319
|
const events = await queryRelays(relays, {
|
|
172
320
|
kinds: [BINDING_KIND],
|
|
173
321
|
authors: [pubkey],
|
|
174
322
|
"#t": [PROTOCOL_ID],
|
|
175
323
|
limit: 50,
|
|
176
324
|
});
|
|
177
|
-
const
|
|
178
|
-
for (const event of events
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
325
|
+
const validCandidates = [];
|
|
326
|
+
for (const event of events) {
|
|
327
|
+
const candidate = resolvedIdentityFromEvent(event);
|
|
328
|
+
if (!candidate)
|
|
329
|
+
continue;
|
|
330
|
+
const verified = await verifyIdentity(candidate);
|
|
331
|
+
if (verified.status !== "verified" || await isBindingRevoked(event, relays))
|
|
183
332
|
continue;
|
|
184
|
-
|
|
333
|
+
validCandidates.push(candidate);
|
|
334
|
+
}
|
|
335
|
+
validCandidates.sort((a, b) => b.rawEvent.created_at - a.rawEvent.created_at);
|
|
336
|
+
if (!validCandidates.length)
|
|
337
|
+
return [];
|
|
338
|
+
const chain = [];
|
|
339
|
+
const visited = new Set();
|
|
340
|
+
let current = validCandidates[0];
|
|
341
|
+
while (current) {
|
|
342
|
+
if (visited.has(current.bindingId))
|
|
343
|
+
return [];
|
|
344
|
+
visited.add(current.bindingId);
|
|
345
|
+
chain.push(current);
|
|
346
|
+
const supersedes = current.rawEvent.tags.find(t => t[0] === "supersedes")?.[1];
|
|
347
|
+
if (!supersedes)
|
|
348
|
+
return chain;
|
|
349
|
+
const predecessors = await queryRelays(relays, {
|
|
350
|
+
kinds: [BINDING_KIND],
|
|
351
|
+
"#t": [PROTOCOL_ID],
|
|
352
|
+
"#d": [supersedes],
|
|
353
|
+
limit: 50,
|
|
354
|
+
});
|
|
355
|
+
let predecessor;
|
|
356
|
+
for (const event of predecessors) {
|
|
357
|
+
const candidate = resolvedIdentityFromEvent(event);
|
|
358
|
+
if (!candidate || candidate.bindingId !== supersedes)
|
|
359
|
+
continue;
|
|
360
|
+
const verified = await verifyIdentity(candidate);
|
|
361
|
+
if (verified.status !== "verified" || await isBindingRevoked(event, relays))
|
|
362
|
+
continue;
|
|
363
|
+
predecessor = candidate;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
if (!predecessor)
|
|
367
|
+
return [];
|
|
368
|
+
current = predecessor;
|
|
369
|
+
}
|
|
370
|
+
return chain;
|
|
371
|
+
}
|
|
372
|
+
function resolvedIdentityFromEvent(event) {
|
|
373
|
+
if (event.kind !== BINDING_KIND ||
|
|
374
|
+
!/^[0-9a-f]{64}$/.test(event.pubkey) ||
|
|
375
|
+
!Array.isArray(event.tags))
|
|
376
|
+
return null;
|
|
377
|
+
const bchTag = event.tags.find(t => Array.isArray(t) && t[0] === "bch");
|
|
378
|
+
const dTag = event.tags.find(t => Array.isArray(t) && t[0] === "d");
|
|
379
|
+
const protocolTags = event.tags.filter(t => Array.isArray(t) && t[0] === "protocol");
|
|
380
|
+
const proofTags = event.tags.filter(t => Array.isArray(t) && t[0] === "bch-proof");
|
|
381
|
+
const bchProofTag = proofTags[0];
|
|
382
|
+
if (!bchTag?.[1] || !dTag?.[1] ||
|
|
383
|
+
protocolTags.length !== 1 || protocolTags[0].length !== 3 ||
|
|
384
|
+
protocolTags[0][1] !== PROTOCOL_ID || protocolTags[0][2] !== PROTOCOL_VERSION ||
|
|
385
|
+
proofTags.length !== 1 || bchProofTag.length !== 4 ||
|
|
386
|
+
bchProofTag[1] !== BCH_PROOF_FORMAT ||
|
|
387
|
+
!bchProofTag[2] || !bchProofTag[3])
|
|
388
|
+
return null;
|
|
389
|
+
let content;
|
|
390
|
+
try {
|
|
391
|
+
const parsed = JSON.parse(event.content);
|
|
392
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
393
|
+
return null;
|
|
394
|
+
content = parsed;
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const createdAt = typeof content.createdAt === "number" ? content.createdAt : event.created_at;
|
|
400
|
+
const anchorTag = event.tags.find(t => Array.isArray(t) && t[0] === "bch-anchor");
|
|
401
|
+
const claimedBlockHeight = anchorTag?.[2] && /^\d+$/.test(anchorTag[2]) ? Number(anchorTag[2]) : undefined;
|
|
402
|
+
return {
|
|
403
|
+
npub: nip19.npubEncode(event.pubkey),
|
|
404
|
+
pubkey: event.pubkey,
|
|
405
|
+
bchAddress: bchTag[1],
|
|
406
|
+
network: bchTag[2],
|
|
407
|
+
bindingId: dTag[1],
|
|
408
|
+
createdAt,
|
|
409
|
+
bchProofTag: bchProofTag,
|
|
410
|
+
rawEvent: event,
|
|
411
|
+
...(anchorTag?.[1] ? {
|
|
412
|
+
anchorClaim: {
|
|
413
|
+
txid: anchorTag[1],
|
|
414
|
+
...(claimedBlockHeight !== undefined ? { blockHeight: claimedBlockHeight } : {}),
|
|
415
|
+
},
|
|
416
|
+
} : {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
async function isBindingRevoked(event, relays) {
|
|
420
|
+
const revocations = await queryRelays(relays, {
|
|
421
|
+
kinds: [5],
|
|
422
|
+
authors: [event.pubkey],
|
|
423
|
+
limit: 100,
|
|
424
|
+
});
|
|
425
|
+
return revocations.some(revocation => {
|
|
185
426
|
try {
|
|
186
|
-
|
|
427
|
+
return verifyEvent(revocation) &&
|
|
428
|
+
revocation.pubkey === event.pubkey &&
|
|
429
|
+
revocation.tags.some(tag => tag[0] === "e" && tag[1] === event.id);
|
|
187
430
|
}
|
|
188
|
-
catch {
|
|
189
|
-
|
|
190
|
-
const supersedesTag = event.tags.find(t => t[0] === "supersedes");
|
|
191
|
-
if (supersedesTag) {
|
|
192
|
-
const claimedId = supersedesTag[1];
|
|
193
|
-
const [prev] = await queryRelays(relays, { "#d": [claimedId], kinds: [BINDING_KIND], limit: 1 });
|
|
194
|
-
if (prev) {
|
|
195
|
-
const prevDTag = prev.tags.find(t => t[0] === "d");
|
|
196
|
-
if (prevDTag?.[1] !== claimedId) {
|
|
197
|
-
throw new CashletDidError("CHAIN_BROKEN", `supersedes tag ${claimedId} does not match referenced event d-tag`);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
431
|
+
catch {
|
|
432
|
+
return false;
|
|
200
433
|
}
|
|
201
|
-
|
|
202
|
-
const anchoredAt = anchorTag?.[2] ? Number(anchorTag[2]) : undefined;
|
|
203
|
-
results.push({ npub, pubkey, bchAddress: bchTag[1], network: bchTag[2] ?? "mainnet", bindingId: dTag[1], createdAt, bchProofTag, rawEvent: event, ...(anchoredAt !== undefined ? { anchoredAt } : {}) });
|
|
204
|
-
}
|
|
205
|
-
return results;
|
|
434
|
+
});
|
|
206
435
|
}
|
|
207
436
|
/**
|
|
208
437
|
* Resolve a BCH address from a NIP-05 handle (e.g. "alice@cashlet.app")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "did-beta-bch",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Decentralised identity protocol binding BCH addresses to Nostr public keys",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"PROTOCOL.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"build": "tsc",
|
|
21
|
+
"build": "tsc && npm run build:demo",
|
|
22
|
+
"build:demo": "esbuild demo/client.js --bundle --format=esm --platform=browser --target=es2022 --outfile=demo/bundle.js",
|
|
22
23
|
"typecheck": "tsc --noEmit",
|
|
23
24
|
"test": "node --test test/cashlet-did.test.mjs",
|
|
24
25
|
"prepublishOnly": "npm run build"
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
"nostr-tools": "^2.0.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
49
|
+
"esbuild": "^0.28.2",
|
|
48
50
|
"typescript": "^5.4.0"
|
|
49
51
|
}
|
|
50
52
|
}
|