did-beta-bch 1.3.0 → 2.0.1
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 +16 -1
- package/PROTOCOL.md +146 -116
- package/README.md +84 -20
- package/dist/anchor.d.ts +1 -1
- package/dist/anchor.js +13 -2
- package/dist/binding.d.ts +5 -11
- package/dist/binding.js +16 -6
- package/dist/credentials.d.ts +1 -1
- package/dist/credentials.js +51 -11
- 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/signing.d.ts +16 -0
- package/dist/signing.js +30 -0
- package/dist/verify.d.ts +12 -13
- package/dist/verify.js +327 -99
- package/package.json +5 -3
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,110 +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
|
-
|
|
141
|
-
|
|
142
|
-
if (lines[0] !== `${PROTOCOL_ID}/challenge`)
|
|
277
|
+
if (issuedAt !== undefined &&
|
|
278
|
+
(!Number.isSafeInteger(issuedAt) || Math.floor(Date.now() / 1000) - issuedAt > CHALLENGE_TTL_SECS))
|
|
143
279
|
return false;
|
|
144
|
-
|
|
280
|
+
let expectedPayload;
|
|
281
|
+
try {
|
|
282
|
+
expectedPayload = bchChallengePayload(nonce, audience);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
145
285
|
return false;
|
|
146
|
-
|
|
286
|
+
}
|
|
287
|
+
if (proof.payload !== expectedPayload)
|
|
288
|
+
return false;
|
|
289
|
+
try {
|
|
290
|
+
normalizeBchMessageSignature(proof.signature);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
147
293
|
return false;
|
|
294
|
+
}
|
|
148
295
|
const msgHash = bitcoinMsgHash(proof.payload);
|
|
149
296
|
return recoverBchAddress(proof.signature, msgHash, proof.address) === proof.address;
|
|
150
297
|
}
|
|
@@ -164,46 +311,127 @@ export async function resolveAddress(npub, relays = DEFAULT_RELAYS) {
|
|
|
164
311
|
const result = await verifyIdentity(identity);
|
|
165
312
|
return result.status === "verified" ? identity.bchAddress : null;
|
|
166
313
|
}
|
|
167
|
-
/** Returns
|
|
314
|
+
/** Returns verified binding events newest first, following and validating each supersedes reference. */
|
|
168
315
|
export async function resolveIdentityChain(npub, relays = DEFAULT_RELAYS) {
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
316
|
+
const pubkey = decodeNpub(npub);
|
|
317
|
+
if (!pubkey)
|
|
171
318
|
return [];
|
|
172
|
-
const pubkey = decoded.data;
|
|
173
319
|
const events = await queryRelays(relays, {
|
|
174
320
|
kinds: [BINDING_KIND],
|
|
175
321
|
authors: [pubkey],
|
|
176
322
|
"#t": [PROTOCOL_ID],
|
|
177
323
|
limit: 50,
|
|
178
324
|
});
|
|
179
|
-
const
|
|
180
|
-
for (const event of events
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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))
|
|
185
332
|
continue;
|
|
186
|
-
|
|
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 => {
|
|
187
426
|
try {
|
|
188
|
-
|
|
427
|
+
return verifyEvent(revocation) &&
|
|
428
|
+
revocation.pubkey === event.pubkey &&
|
|
429
|
+
revocation.tags.some(tag => tag[0] === "e" && tag[1] === event.id);
|
|
189
430
|
}
|
|
190
|
-
catch {
|
|
191
|
-
|
|
192
|
-
const supersedesTag = event.tags.find(t => t[0] === "supersedes");
|
|
193
|
-
if (supersedesTag) {
|
|
194
|
-
const claimedId = supersedesTag[1];
|
|
195
|
-
const [prev] = await queryRelays(relays, { "#d": [claimedId], kinds: [BINDING_KIND], limit: 1 });
|
|
196
|
-
if (prev) {
|
|
197
|
-
const prevDTag = prev.tags.find(t => t[0] === "d");
|
|
198
|
-
if (prevDTag?.[1] !== claimedId)
|
|
199
|
-
continue; // broken chain — skip, don't throw
|
|
200
|
-
}
|
|
431
|
+
catch {
|
|
432
|
+
return false;
|
|
201
433
|
}
|
|
202
|
-
|
|
203
|
-
const anchoredAt = anchorTag?.[2] ? Number(anchorTag[2]) : undefined;
|
|
204
|
-
results.push({ npub, pubkey, bchAddress: bchTag[1], network: bchTag[2] ?? "mainnet", bindingId: dTag[1], createdAt, bchProofTag, rawEvent: event, ...(anchoredAt !== undefined ? { anchoredAt } : {}) });
|
|
205
|
-
}
|
|
206
|
-
return results;
|
|
434
|
+
});
|
|
207
435
|
}
|
|
208
436
|
/**
|
|
209
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.1",
|
|
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
|
+
}
|