did-beta-bch 2.0.1 → 3.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/dist/bip47.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * BIP47 v1 reusable payment code helpers.
3
+ * Notification transactions and scanning are wallet-specific and out of scope.
4
+ */
5
+ import { secp256k1, hash160, encodeCashAddress, deriveHdPublicNodeChild, deriveHdPrivateNodeChild, deriveHdPrivateNodeFromBip39Mnemonic, deriveHdPath, deriveHdPublicNode, } from "@bitauth/libauth";
6
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
7
+ // BIP47 payment code: [0x47][0x01][0x00][pubkey 33][chaincode 32][reserved 13][checksum 4] = 85 bytes
8
+ const PC_VERSION = 0x47;
9
+ const PC_TOTAL_BYTES = 85;
10
+ const PC_PAYLOAD_BYTES = 80;
11
+ function base58Encode(bytes) {
12
+ let n = 0n;
13
+ for (const b of bytes)
14
+ n = n * 256n + BigInt(b);
15
+ let result = "";
16
+ while (n > 0n) {
17
+ result = BASE58_ALPHABET[Number(n % 58n)] + result;
18
+ n /= 58n;
19
+ }
20
+ for (const b of bytes) {
21
+ if (b !== 0)
22
+ break;
23
+ result = "1" + result;
24
+ }
25
+ return result;
26
+ }
27
+ function base58Decode(s) {
28
+ let n = 0n;
29
+ for (const c of s) {
30
+ const d = BASE58_ALPHABET.indexOf(c);
31
+ if (d < 0)
32
+ throw new Error(`Invalid Base58 character: ${c}`);
33
+ n = n * 58n + BigInt(d);
34
+ }
35
+ const hex = n.toString(16).padStart(PC_TOTAL_BYTES * 2, "0");
36
+ const bytes = new Uint8Array(hex.length / 2);
37
+ for (let i = 0; i < bytes.length; i++)
38
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
39
+ let leading = 0;
40
+ for (const c of s) {
41
+ if (c === "1")
42
+ leading++;
43
+ else
44
+ break;
45
+ }
46
+ const result = new Uint8Array(leading + bytes.length);
47
+ result.set(bytes, leading);
48
+ return result;
49
+ }
50
+ async function sha256Once(data) {
51
+ if (typeof crypto !== "undefined" && crypto.subtle)
52
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ const nc = await Function('return import("crypto")')();
55
+ return nc.createHash("sha256").update(data).digest();
56
+ }
57
+ async function paymentCodeChecksum(versionAndPayload) {
58
+ return (await sha256Once(await sha256Once(versionAndPayload))).slice(0, 4);
59
+ }
60
+ /**
61
+ * Encode a BIP47 v1 payment code from a public key and chain code.
62
+ */
63
+ export async function encodePaymentCode(publicKey, chainCode) {
64
+ if (publicKey.length !== 33)
65
+ throw new Error("publicKey must be 33 bytes");
66
+ if (chainCode.length !== 32)
67
+ throw new Error("chainCode must be 32 bytes");
68
+ const full = new Uint8Array(PC_TOTAL_BYTES);
69
+ full[0] = PC_VERSION;
70
+ full[1] = 0x01; // payload version
71
+ full[2] = 0x00; // feature bits
72
+ full.set(publicKey, 3);
73
+ full.set(chainCode, 36);
74
+ // bytes 69-80 are reserved zeros
75
+ full.set(await paymentCodeChecksum(full.slice(0, PC_PAYLOAD_BYTES + 1)), PC_PAYLOAD_BYTES + 1);
76
+ return base58Encode(full);
77
+ }
78
+ /**
79
+ * Decode a BIP47 v1 Base58Check payment code string.
80
+ */
81
+ export function parsePaymentCode(paymentCode) {
82
+ const decoded = base58Decode(paymentCode);
83
+ if (decoded.length !== PC_TOTAL_BYTES)
84
+ throw new Error(`Invalid payment code length: ${decoded.length}`);
85
+ if (decoded[0] !== PC_VERSION)
86
+ throw new Error(`Unsupported payment code version: 0x${decoded[0].toString(16)}`);
87
+ const payload = decoded.slice(1, 1 + PC_PAYLOAD_BYTES);
88
+ if (payload[0] !== 0x01)
89
+ throw new Error(`Unsupported payment code payload version: ${payload[0]}`);
90
+ return {
91
+ publicKey: payload.slice(2, 35),
92
+ chainCode: payload.slice(35, 67),
93
+ };
94
+ }
95
+ /**
96
+ * Derive a BIP47 payment code from a mnemonic (m/47'/0'/<account>').
97
+ */
98
+ export async function derivePaymentCode(mnemonic, account = 0) {
99
+ const root = await deriveHdPrivateNodeFromBip39Mnemonic(mnemonic);
100
+ const node = deriveHdPath(root, `m/47'/0'/${account}'`);
101
+ if (typeof node === "string")
102
+ throw new Error(`BIP47 derivation failed: ${node}`);
103
+ const publicKey = secp256k1.derivePublicKeyCompressed(node.privateKey);
104
+ if (typeof publicKey === "string")
105
+ throw new Error(`Public key derivation failed: ${publicKey}`);
106
+ return { publicKey, chainCode: node.chainCode, encoded: await encodePaymentCode(publicKey, node.chainCode) };
107
+ }
108
+ /**
109
+ * Derive the nth P2PKH CashAddr payment address for sending to a recipient.
110
+ *
111
+ * @param senderScanPrivkey - sender's M/47'/0'/0'/0 private key (32 bytes)
112
+ * @param recipientNode - recipient's BIP47 HD public node (M/47'/0'/0')
113
+ * @param index - address index (0-based)
114
+ * @param prefix - CashAddr prefix, defaults to "bitcoincash"
115
+ */
116
+ export async function derivePaymentAddress(senderScanPrivkey, recipientNode, index, prefix = "bitcoincash") {
117
+ if (index < 0 || !Number.isSafeInteger(index))
118
+ throw new Error("Index must be a non-negative integer");
119
+ const childNode = deriveHdPublicNodeChild(recipientNode, index);
120
+ if (typeof childNode === "string")
121
+ throw new Error(`HD derivation failed: ${childNode}`);
122
+ const sharedPoint = secp256k1.mulTweakPublicKeyCompressed(childNode.publicKey, senderScanPrivkey);
123
+ if (typeof sharedPoint === "string")
124
+ throw new Error(`ECDH failed: ${sharedPoint}`);
125
+ const s = await sha256Once(sharedPoint.slice(1));
126
+ const paymentPubkey = secp256k1.addTweakPublicKeyCompressed(childNode.publicKey, s);
127
+ if (typeof paymentPubkey === "string")
128
+ throw new Error(`Pubkey tweak failed: ${paymentPubkey}`);
129
+ const result = encodeCashAddress({ prefix, type: "p2pkh", payload: hash160(paymentPubkey) });
130
+ return typeof result === "string" ? result : result.address;
131
+ }
132
+ /**
133
+ * Derive the nth payment private key for a receiver to spend funds sent via BIP47.
134
+ *
135
+ * Per BIP47 spec: s = SHA256(b[i] * A) where A = sender's scan pubkey (M/47'/0'/0'/0)
136
+ * b' = b[i] + s
137
+ *
138
+ * @param receiverAccountNode - receiver's M/47'/0'/0' private node
139
+ * @param senderScanPubkey - sender's M/47'/0'/0'/0 compressed public key (33 bytes)
140
+ * @param index - address index (0-based)
141
+ */
142
+ export async function derivePaymentPrivkey(receiverAccountNode, senderScanPubkey, index) {
143
+ if (index < 0 || !Number.isSafeInteger(index))
144
+ throw new Error("Index must be a non-negative integer");
145
+ // Receiver's child private key b[i]
146
+ const receiverChild = deriveHdPrivateNodeChild(receiverAccountNode, index);
147
+ if (typeof receiverChild === "string")
148
+ throw new Error(`HD child derivation failed: ${receiverChild}`);
149
+ // s = sha256(x-coord of b[i] * A)
150
+ const sharedPoint = secp256k1.mulTweakPublicKeyCompressed(senderScanPubkey, receiverChild.privateKey);
151
+ if (typeof sharedPoint === "string")
152
+ throw new Error(`ECDH failed: ${sharedPoint}`);
153
+ const s = await sha256Once(sharedPoint.slice(1));
154
+ // b' = b[i] + s (mod n)
155
+ const tweaked = secp256k1.addTweakPrivateKey(receiverChild.privateKey, s);
156
+ if (typeof tweaked === "string")
157
+ throw new Error(`Private key tweak failed: ${tweaked}`);
158
+ return tweaked;
159
+ }
160
+ export { deriveHdPublicNode, deriveHdPath, deriveHdPrivateNodeFromBip39Mnemonic };
161
+ /** Construct a minimal HdPublicNodeValid from a BIP47 PaymentCode for use with derivePaymentAddress/derivePaymentPrivkey. */
162
+ export function hdPublicNodeFromPaymentCode(pc) {
163
+ return {
164
+ publicKey: pc.publicKey,
165
+ chainCode: pc.chainCode,
166
+ depth: 0,
167
+ childIndex: 0,
168
+ parentFingerprint: new Uint8Array(4),
169
+ parentIdentifier: new Uint8Array(20),
170
+ };
171
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  export { buildDisclosure, verifyDisclosure, type Disclosure, type DisclosureReveal, } from "./disclosure.js";
2
+ export { parsePaymentCode, encodePaymentCode, derivePaymentCode, derivePaymentAddress, derivePaymentPrivkey, hdPublicNodeFromPaymentCode, type PaymentCode, type EncodedPaymentCode, } from "./bip47.js";
2
3
  export { buildAnchorTx, ANCHOR_PREFIX, type AnchorUtxo, type AnchorResult, } from "./anchor.js";
4
+ export { verifyBchAnchorProof, BCH_MAINNET_ASERT_PARAMETERS, BCH_MAINNET_CHECKPOINT, BCH_CHIPNET_ASERT_PARAMETERS, BCH_CHIPNET_CHECKPOINT, type BchSpvCheckpoint, type BchAsertParameters, type BchAnchorProof, type BchAnchorVerificationOptions, type BchAnchorVerificationResult, } from "./anchor-verification.js";
5
+ export { broadcastAnchorTx, fetchAnchorProof, wsTransport, ELECTRUM_MAINNET_TCP, ELECTRUM_MAINNET_WS, ELECTRUM_CHIPNET_TCP, ELECTRUM_CHIPNET_WS, type ElectrumTransport, } from "./anchor-client.js";
3
6
  export { issueCredential, issueRating, revokeCredential, resolveCredentials, scoreReputation, CREDENTIAL_KIND, type CredentialPayload, type IssuedCredential, } from "./credentials.js";
4
- export { PROTOCOL_ID, PROTOCOL_VERSION, BINDING_KIND, type Network, type CanonicalBinding, type IdentityBinding, type NostrEvent, buildBinding, bchSigningPayload, nostrEventTemplate, canonicalize, sha256hex, } from "./binding.js";
7
+ export { PROTOCOL_ID, PROTOCOL_VERSION, BINDING_KIND, ROTATION_AUTH_KIND, type Network, type CanonicalBinding, type IdentityBinding, type NostrEvent, buildBinding, bchSigningPayload, nostrEventTemplate, rotationAuthorizationTemplate, verifyRotationAuthorization, canonicalize, sha256hex, } from "./binding.js";
5
8
  export { BCH_PROOF_FORMAT, normalizeBchMessageSignature, signBchMessage, type BchMessageProof, type BchMessageSigner, type BchProofTag, } from "./signing.js";
6
9
  export { publishToRelay, publishToRelays, queryRelay, queryRelays, DEFAULT_RELAYS, type RelayResult, } from "./relay.js";
7
- export { resolveIdentity, resolveAddress, resolveByNip05, verifyIdentity, bchChallengePayload, verifyChallengeProof, resolveIdentityChain, type VerifyResult, type VerifyStatus, type ResolvedIdentity, type BCHAnchorClaim, } from "./verify.js";
10
+ export { resolveIdentity, resolveAddress, resolveByNip05, verifyIdentity, bchChallengePayload, nostrChallengeTemplate, verifyDualChallengeProof, NOSTR_CHALLENGE_KIND, type DualChallengeProof, verifyChallengeProof, resolveIdentityChain, type VerifyResult, type VerifyStatus, type ResolvedIdentity, type BCHAnchorClaim, bitcoinSignedMessageHash, encodeBitcoinCompactSize, encodeCompactSignatureBase64, } from "./verify.js";
8
11
  export { CashletDidError, type CashletDidErrorCode } from "./errors.js";
9
12
  export { deriveNostrKeypair, type NostrKeypair, } from "./keygen.js";
10
13
  export { buildHttpAuthEventAsync, verifyHttpAuth, type HttpAuthEventTemplate, type HttpAuthResult, } from "./nip98.js";
package/dist/index.js CHANGED
@@ -1,18 +1,21 @@
1
1
  export { buildDisclosure, verifyDisclosure, } from "./disclosure.js";
2
+ export { parsePaymentCode, encodePaymentCode, derivePaymentCode, derivePaymentAddress, derivePaymentPrivkey, hdPublicNodeFromPaymentCode, } from "./bip47.js";
2
3
  export { buildAnchorTx, ANCHOR_PREFIX, } from "./anchor.js";
4
+ export { verifyBchAnchorProof, BCH_MAINNET_ASERT_PARAMETERS, BCH_MAINNET_CHECKPOINT, BCH_CHIPNET_ASERT_PARAMETERS, BCH_CHIPNET_CHECKPOINT, } from "./anchor-verification.js";
5
+ export { broadcastAnchorTx, fetchAnchorProof, wsTransport, ELECTRUM_MAINNET_TCP, ELECTRUM_MAINNET_WS, ELECTRUM_CHIPNET_TCP, ELECTRUM_CHIPNET_WS, } from "./anchor-client.js";
3
6
  export { issueCredential, issueRating, revokeCredential, resolveCredentials, scoreReputation, CREDENTIAL_KIND, } from "./credentials.js";
4
7
  export {
5
8
  // Protocol constants
6
- PROTOCOL_ID, PROTOCOL_VERSION, BINDING_KIND,
9
+ PROTOCOL_ID, PROTOCOL_VERSION, BINDING_KIND, ROTATION_AUTH_KIND,
7
10
  // Binding construction
8
- buildBinding, bchSigningPayload, nostrEventTemplate, canonicalize, sha256hex, } from "./binding.js";
11
+ buildBinding, bchSigningPayload, nostrEventTemplate, rotationAuthorizationTemplate, verifyRotationAuthorization, canonicalize, sha256hex, } from "./binding.js";
9
12
  export { BCH_PROOF_FORMAT, normalizeBchMessageSignature, signBchMessage, } from "./signing.js";
10
13
  export {
11
14
  // Relay communication
12
15
  publishToRelay, publishToRelays, queryRelay, queryRelays, DEFAULT_RELAYS, } from "./relay.js";
13
16
  export {
14
17
  // Verification
15
- resolveIdentity, resolveAddress, resolveByNip05, verifyIdentity, bchChallengePayload, verifyChallengeProof, resolveIdentityChain, } from "./verify.js";
18
+ resolveIdentity, resolveAddress, resolveByNip05, verifyIdentity, bchChallengePayload, nostrChallengeTemplate, verifyDualChallengeProof, NOSTR_CHALLENGE_KIND, verifyChallengeProof, resolveIdentityChain, bitcoinSignedMessageHash, encodeBitcoinCompactSize, encodeCompactSignatureBase64, } from "./verify.js";
16
19
  export { CashletDidError } from "./errors.js";
17
20
  export { deriveNostrKeypair, } from "./keygen.js";
18
21
  export { buildHttpAuthEventAsync, verifyHttpAuth, } from "./nip98.js";
package/dist/verify.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { canonicalize, sha256hex, bchSigningPayload, type CanonicalBinding, type IdentityBinding, type NostrEvent } from "./binding.js";
2
2
  import { type BchProofTag } from "./signing.js";
3
- export type VerifyStatus = "verified" | "invalid_bch_sig" | "invalid_nostr_sig" | "id_mismatch" | "malformed" | "expired";
3
+ export type VerifyStatus = "verified" | "invalid_bch_sig" | "invalid_nostr_sig" | "id_mismatch" | "invalid_rotation" | "malformed" | "expired";
4
4
  export interface VerifyResult {
5
5
  status: VerifyStatus;
6
6
  bchValid: boolean;
@@ -11,6 +11,13 @@ export interface BCHAnchorClaim {
11
11
  txid: string;
12
12
  blockHeight?: number;
13
13
  }
14
+ export declare const NOSTR_CHALLENGE_KIND = 27235;
15
+ export interface DualChallengeProof {
16
+ signature: string;
17
+ payload: string;
18
+ address: string;
19
+ nostrEvent: NostrEvent;
20
+ }
14
21
  export interface ResolvedIdentity {
15
22
  npub: string;
16
23
  pubkey: string;
@@ -20,15 +27,43 @@ export interface ResolvedIdentity {
20
27
  createdAt: number;
21
28
  bchProofTag: BchProofTag;
22
29
  rawEvent: NostrEvent;
30
+ rotationAuthorization?: NostrEvent;
23
31
  /** Unverified metadata claimed by the event; this library does not verify chain inclusion. */
24
32
  anchorClaim?: BCHAnchorClaim;
25
33
  }
34
+ /** Encode n as a Bitcoin CompactSize integer. */
35
+ export declare function encodeBitcoinCompactSize(n: number): Uint8Array;
36
+ /**
37
+ * Compute the double-SHA256 hash used by Bitcoin signed messages.
38
+ * Format: SHA256d("\x18Bitcoin Signed Message:\n" + varint(len) + message)
39
+ * Uses proper varint encoding — required for messages longer than 252 bytes.
40
+ */
41
+ export declare function bitcoinSignedMessageHash(message: string): Uint8Array;
42
+ /**
43
+ * Encode a 65-byte compact signature (recovery ID + 64-byte r||s) as Base64.
44
+ * Header byte = 31 + recoveryId (compressed key, recovery ID 0–3).
45
+ */
46
+ export declare function encodeCompactSignatureBase64(recoveryId: number, signature: Uint8Array): string;
26
47
  /** Resolve a BCH address from an npub via Nostr relays */
27
48
  export declare function resolveIdentity(npub: string, relays?: string[]): Promise<ResolvedIdentity | null>;
28
49
  /** Verify the BCH + Nostr signatures on a resolved identity */
29
50
  export declare function verifyIdentity(resolved: ResolvedIdentity): Promise<VerifyResult>;
30
51
  /** Build the exact versioned challenge message that a BCH wallet must sign. */
31
52
  export declare function bchChallengePayload(nonce: string, audience?: string): string;
53
+ export declare function nostrChallengeTemplate(nonce: string, audience: string, endpoint: string, issuedAt: number): {
54
+ kind: number;
55
+ created_at: number;
56
+ tags: string[][];
57
+ content: string;
58
+ };
59
+ export declare function verifyDualChallengeProof(proof: DualChallengeProof, expected: {
60
+ nonce: string;
61
+ audience: string;
62
+ endpoint: string;
63
+ issuedAt: number;
64
+ address: string;
65
+ pubkey: string;
66
+ }): boolean;
32
67
  /** Verify a challenge-response proof.
33
68
  * The wallet signs the exact text produced by `bchChallengePayload`.
34
69
  * @param issuedAt - unix timestamp when the challenge was issued; if provided, rejects proofs older than 5 minutes.
package/dist/verify.js CHANGED
@@ -1,12 +1,13 @@
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, PROTOCOL_VERSION, } from "./binding.js";
3
+ import { canonicalize, sha256hex, bchSigningPayload, BINDING_KIND, PROTOCOL_ID, PROTOCOL_VERSION, verifyRotationAuthorization, } 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
+ export const NOSTR_CHALLENGE_KIND = 27235;
8
9
  /** Encode n as a Bitcoin CompactSize integer. */
9
- function varint(n) {
10
+ export function encodeBitcoinCompactSize(n) {
10
11
  if (n < 0xfd)
11
12
  return new Uint8Array([n]);
12
13
  const value = BigInt(n);
@@ -30,12 +31,28 @@ function varint(n) {
30
31
  * Format: SHA256d("\x18Bitcoin Signed Message:\n" + varint(len) + message)
31
32
  * Uses proper varint encoding — required for messages longer than 252 bytes.
32
33
  */
33
- function bitcoinMsgHash(message) {
34
+ export function bitcoinSignedMessageHash(message) {
34
35
  const enc = new TextEncoder();
35
36
  const msgBytes = enc.encode(message);
36
37
  const prefix = enc.encode("\x18Bitcoin Signed Message:\n");
37
- return hash256(new Uint8Array([...prefix, ...varint(msgBytes.length), ...msgBytes]));
38
+ return hash256(new Uint8Array([...prefix, ...encodeBitcoinCompactSize(msgBytes.length), ...msgBytes]));
38
39
  }
40
+ /**
41
+ * Encode a 65-byte compact signature (recovery ID + 64-byte r||s) as Base64.
42
+ * Header byte = 31 + recoveryId (compressed key, recovery ID 0–3).
43
+ */
44
+ export function encodeCompactSignatureBase64(recoveryId, signature) {
45
+ if (!Number.isInteger(recoveryId) || recoveryId < 0 || recoveryId > 3)
46
+ throw new RangeError("Recovery ID must be 0–3");
47
+ if (signature.length !== 64)
48
+ throw new TypeError("Signature must be 64 bytes");
49
+ const compact = new Uint8Array(65);
50
+ compact[0] = 31 + recoveryId;
51
+ compact.set(signature, 1);
52
+ return btoa(String.fromCharCode(...compact));
53
+ }
54
+ // Internal alias used within this file
55
+ function bitcoinMsgHash(message) { return bitcoinSignedMessageHash(message); }
39
56
  function recoverBchAddress(signatureBase64, msgHash, expectedAddress) {
40
57
  try {
41
58
  normalizeBchMessageSignature(signatureBase64);
@@ -164,10 +181,12 @@ export async function verifyIdentity(resolved) {
164
181
  const proofTag = exactlyOne("bch-proof");
165
182
  const paymentCodeTag = optionalTag("bch-payment-code");
166
183
  const supersedesTag = optionalTag("supersedes");
184
+ const rotationTag = optionalTag("rotation-proof");
167
185
  const anchorTag = optionalTag("bch-anchor");
186
+ const rotationAuthorization = content.rotationAuthorization;
168
187
  const previousBindingId = content.previousBindingId;
169
188
  const contentKeys = Object.keys(content).sort().join(",");
170
- const allowedContentKeys = ["bch", "bindingId", "createdAt", "nonce", "previousBindingId", "protocol", "version"]
189
+ const allowedContentKeys = ["bch", "bindingId", "createdAt", "nonce", "previousBindingId", "protocol", "rotationAuthorization", "version"]
171
190
  .filter(key => content[key] !== undefined).sort().join(",");
172
191
  const bchKeys = bch && typeof bch === "object" && !Array.isArray(bch)
173
192
  ? Object.keys(bch).sort().join(",")
@@ -197,7 +216,16 @@ export async function verifyIdentity(resolved) {
197
216
  (bch.paymentCode === undefined ? paymentCodeTag !== undefined : (!paymentCodeTag || paymentCodeTag.length !== 2 || paymentCodeTag[1] !== bch.paymentCode)) ||
198
217
  getTags("bch-payment-code").length > 1 ||
199
218
  (previousBindingId === undefined ? supersedesTag !== undefined : (!supersedesTag || supersedesTag.length !== 2 || supersedesTag[1] !== previousBindingId)) ||
219
+ (rotationAuthorization === undefined ? rotationTag !== undefined : (!rotationAuthorization ||
220
+ typeof rotationAuthorization !== "object" ||
221
+ !rotationTag ||
222
+ rotationTag.length !== 3 ||
223
+ rotationTag[1] !== rotationAuthorization.id ||
224
+ rotationTag[2] !== rotationAuthorization.pubkey ||
225
+ !verifyEvent(rotationAuthorization))) ||
226
+ (rotationAuthorization !== undefined && previousBindingId === undefined) ||
200
227
  getTags("supersedes").length > 1 ||
228
+ getTags("rotation-proof").length > 1 ||
201
229
  getTags("bch-anchor").length > 1 ||
202
230
  (anchorTag !== undefined && ((anchorTag.length !== 2 && anchorTag.length !== 3) ||
203
231
  !/^[0-9a-f]{64}$/.test(anchorTag[1]) ||
@@ -232,7 +260,7 @@ export async function verifyIdentity(resolved) {
232
260
  nostr: { pubkey: rawEvent.pubkey },
233
261
  createdAt: content.createdAt,
234
262
  nonce: content.nonce,
235
- purpose: "identity-binding-v2",
263
+ purpose: "identity-binding-v3",
236
264
  ...(previousBindingId !== undefined ? { previousBindingId } : {}),
237
265
  };
238
266
  const expectedBindingId = await sha256hex(canonicalize(partial));
@@ -263,6 +291,61 @@ export function bchChallengePayload(nonce, audience) {
263
291
  throw new TypeError("Challenge audience must be a non-empty single-line string");
264
292
  return `${PROTOCOL_ID}/challenge/v${PROTOCOL_VERSION}\n${nonce}${audience === undefined ? "" : `\n${audience}`}`;
265
293
  }
294
+ export function nostrChallengeTemplate(nonce, audience, endpoint, issuedAt) {
295
+ const payload = bchChallengePayload(nonce, audience);
296
+ if (!Number.isSafeInteger(issuedAt) || issuedAt <= 0)
297
+ throw new TypeError("Challenge issue time must be a positive Unix timestamp");
298
+ if (typeof endpoint !== "string" || endpoint.length === 0 || /[\r\n]/.test(endpoint))
299
+ throw new TypeError("Challenge endpoint must be a non-empty single-line URL");
300
+ return {
301
+ kind: NOSTR_CHALLENGE_KIND,
302
+ created_at: issuedAt,
303
+ tags: [
304
+ ["u", endpoint],
305
+ ["method", "POST"],
306
+ ["audience", audience],
307
+ ["challenge", nonce],
308
+ ["expiration", String(issuedAt + CHALLENGE_TTL_SECS)],
309
+ ],
310
+ content: payload,
311
+ };
312
+ }
313
+ export function verifyDualChallengeProof(proof, expected) {
314
+ if (!proof ||
315
+ proof.address !== expected.address ||
316
+ !verifyChallengeProof(proof, expected.nonce, expected.audience, expected.issuedAt))
317
+ return false;
318
+ const event = proof.nostrEvent;
319
+ if (!event ||
320
+ event.kind !== NOSTR_CHALLENGE_KIND ||
321
+ event.pubkey !== expected.pubkey ||
322
+ event.created_at !== expected.issuedAt ||
323
+ event.content !== bchChallengePayload(expected.nonce, expected.audience) ||
324
+ !Array.isArray(event.tags))
325
+ return false;
326
+ const uniqueTag = (name) => {
327
+ const matches = event.tags.filter(tag => Array.isArray(tag) && tag[0] === name);
328
+ return matches.length === 1 ? matches[0] : undefined;
329
+ };
330
+ const endpointTag = uniqueTag("u");
331
+ const methodTag = uniqueTag("method");
332
+ const audienceTag = uniqueTag("audience");
333
+ const challengeTag = uniqueTag("challenge");
334
+ const expirationTag = uniqueTag("expiration");
335
+ const expectedTags = nostrChallengeTemplate(expected.nonce, expected.audience, expected.endpoint, expected.issuedAt).tags;
336
+ try {
337
+ return endpointTag?.[1] === expected.endpoint &&
338
+ methodTag?.[1] === "POST" &&
339
+ audienceTag?.[1] === expected.audience &&
340
+ challengeTag?.[1] === expected.nonce &&
341
+ expirationTag?.[1] === String(expected.issuedAt + CHALLENGE_TTL_SECS) &&
342
+ event.tags.length === expectedTags.length &&
343
+ verifyEvent(event);
344
+ }
345
+ catch {
346
+ return false;
347
+ }
348
+ }
266
349
  /** Verify a challenge-response proof.
267
350
  * The wallet signs the exact text produced by `bchChallengePayload`.
268
351
  * @param issuedAt - unix timestamp when the challenge was issued; if provided, rejects proofs older than 5 minutes.
@@ -275,7 +358,9 @@ export function verifyChallengeProof(proof, nonce, audience, issuedAt) {
275
358
  typeof nonce !== "string")
276
359
  return false;
277
360
  if (issuedAt !== undefined &&
278
- (!Number.isSafeInteger(issuedAt) || Math.floor(Date.now() / 1000) - issuedAt > CHALLENGE_TTL_SECS))
361
+ (!Number.isSafeInteger(issuedAt) ||
362
+ issuedAt > Math.floor(Date.now() / 1000) + 60 ||
363
+ Math.floor(Date.now() / 1000) - issuedAt > CHALLENGE_TTL_SECS))
279
364
  return false;
280
365
  let expectedPayload;
281
366
  try {
@@ -365,10 +450,53 @@ export async function resolveIdentityChain(npub, relays = DEFAULT_RELAYS) {
365
450
  }
366
451
  if (!predecessor)
367
452
  return [];
453
+ const predecessorBinding = canonicalBindingFromResolved(predecessor);
454
+ const successorBinding = canonicalBindingFromResolved(current);
455
+ if (!predecessorBinding || !successorBinding)
456
+ return [];
457
+ if (predecessor.pubkey !== current.pubkey) {
458
+ if (!current.rotationAuthorization ||
459
+ !verifyRotationAuthorization(current.rotationAuthorization, predecessorBinding, successorBinding))
460
+ return [];
461
+ }
462
+ else if (current.rotationAuthorization &&
463
+ !verifyRotationAuthorization(current.rotationAuthorization, predecessorBinding, successorBinding)) {
464
+ return [];
465
+ }
368
466
  current = predecessor;
369
467
  }
370
468
  return chain;
371
469
  }
470
+ function canonicalBindingFromResolved(identity) {
471
+ try {
472
+ const content = JSON.parse(identity.rawEvent.content);
473
+ const bch = content.bch;
474
+ if (typeof content.nonce !== "string" ||
475
+ typeof content.createdAt !== "number" ||
476
+ typeof bch.address !== "string" ||
477
+ typeof bch.network !== "string" ||
478
+ !["mainnet", "testnet", "chipnet"].includes(bch.network))
479
+ return null;
480
+ return {
481
+ protocol: PROTOCOL_ID,
482
+ version: PROTOCOL_VERSION,
483
+ bindingId: identity.bindingId,
484
+ bch: {
485
+ address: bch.address,
486
+ network: bch.network,
487
+ ...(typeof bch.paymentCode === "string" ? { paymentCode: bch.paymentCode } : {}),
488
+ },
489
+ nostr: { pubkey: identity.pubkey },
490
+ createdAt: content.createdAt,
491
+ nonce: content.nonce,
492
+ purpose: "identity-binding-v3",
493
+ ...(typeof content.previousBindingId === "string" ? { previousBindingId: content.previousBindingId } : {}),
494
+ };
495
+ }
496
+ catch {
497
+ return null;
498
+ }
499
+ }
372
500
  function resolvedIdentityFromEvent(event) {
373
501
  if (event.kind !== BINDING_KIND ||
374
502
  !/^[0-9a-f]{64}$/.test(event.pubkey) ||
@@ -408,6 +536,9 @@ function resolvedIdentityFromEvent(event) {
408
536
  createdAt,
409
537
  bchProofTag: bchProofTag,
410
538
  rawEvent: event,
539
+ ...(content.rotationAuthorization && typeof content.rotationAuthorization === "object"
540
+ ? { rotationAuthorization: content.rotationAuthorization }
541
+ : {}),
411
542
  ...(anchorTag?.[1] ? {
412
543
  anchorClaim: {
413
544
  txid: anchorTag[1],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "did-beta-bch",
3
- "version": "2.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Decentralised identity protocol binding BCH addresses to Nostr public keys",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,8 +20,8 @@
20
20
  "scripts": {
21
21
  "build": "tsc && npm run build:demo",
22
22
  "build:demo": "esbuild demo/client.js --bundle --format=esm --platform=browser --target=es2022 --outfile=demo/bundle.js",
23
- "typecheck": "tsc --noEmit",
24
23
  "test": "node --test test/cashlet-did.test.mjs",
24
+ "typecheck": "tsc --noEmit",
25
25
  "prepublishOnly": "npm run build"
26
26
  },
27
27
  "keywords": [
@@ -49,4 +49,4 @@
49
49
  "esbuild": "^0.28.2",
50
50
  "typescript": "^5.4.0"
51
51
  }
52
- }
52
+ }