did-beta-bch 2.0.0 → 3.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/PROTOCOL.md CHANGED
@@ -1,75 +1,55 @@
1
1
  # Cashlet DID Protocol Specification
2
2
 
3
- Version: `2.0.0`
3
+ Version: `3.0.0`
4
4
  Status: Draft
5
5
 
6
- This is the normative specification for Cashlet DID v2. V2 is a breaking
7
- signature-format update: conforming verifiers MUST reject v1 proof tags and
8
- signatures. Existing v1 bindings must be re-created and re-published.
9
-
10
- ---
6
+ Cashlet DID v3 is a breaking protocol update. Conforming verifiers MUST reject
7
+ v1 and v2 binding records. Existing identities must be rebound and republished.
11
8
 
12
9
  ## 1. Identifiers
13
10
 
14
11
  | Constant | Value |
15
12
  |---|---|
16
13
  | Protocol ID | `cashlet-did` |
17
- | Protocol version | `2.0.0` |
18
- | Binding event kind | `30078` (NIP-78 addressable data) |
14
+ | Protocol version | `3.0.0` |
15
+ | Binding event kind | `30078` |
16
+ | Rotation authorization kind | `30080` |
19
17
  | Credential event kind | `30085` |
20
18
  | Revocation event kind | `5` (NIP-09) |
19
+ | Dual-key challenge kind | `27235` (NIP-98 event format) |
21
20
  | Nostr derivation path | `m/44'/1237'/<account>'/0/0` (NIP-06) |
22
21
 
23
- ## 2. Canonical binding record
22
+ ## 2. Binding and BCH ownership proof
24
23
 
25
24
  A binding record has recursively sorted object keys, no whitespace, and omits
26
25
  undefined/absent fields:
27
26
 
28
27
  ```json
29
28
  {
30
- "bch": {
31
- "address": "<prefixed-cashaddr>",
32
- "network": "mainnet" | "testnet" | "chipnet",
33
- "paymentCode": "<BIP47 payment code>" // optional
34
- },
35
- "createdAt": <unix seconds>,
29
+ "bch": { "address": "<prefixed-cashaddr>", "network": "mainnet|testnet|chipnet" },
30
+ "createdAt": 0,
36
31
  "nostr": { "pubkey": "<32-byte lowercase hex>" },
37
32
  "nonce": "<64 lowercase hex chars>",
38
- "previousBindingId": "<64 lowercase hex chars>", // optional
39
33
  "protocol": "cashlet-did",
40
- "purpose": "identity-binding-v2",
41
- "version": "2.0.0"
34
+ "purpose": "identity-binding-v3",
35
+ "version": "3.0.0"
42
36
  }
43
37
  ```
44
38
 
45
- **Binding ID** = lowercase hex `SHA-256(canonicalize(record_without_bindingId))`.
46
- Arrays preserve order. `mainnet` addresses use the `bitcoincash:` prefix;
47
- `testnet` and `chipnet` use `bchtest:`.
48
-
49
- ## 3. BCH signing payload and wallet contract
39
+ Optional fields are `bch.paymentCode` and `previousBindingId`. The binding ID
40
+ is lowercase hex `SHA-256(canonicalize(record_without_bindingId))`. Arrays
41
+ preserve order. Mainnet addresses use `bitcoincash:`; testnet and chipnet use
42
+ `bchtest:`.
50
43
 
51
- The exact UTF-8 message text supplied to a wallet is:
44
+ The exact UTF-8 message supplied to the BCH wallet is:
52
45
 
53
46
  ```text
54
- cashlet-did/bch-proof/v2.0.0\n<canonicalized subset>
55
- ```
56
-
57
- The subset contains exactly these keys:
58
-
59
- ```json
60
- {
61
- "bchAddress": "<prefixed-cashaddr>",
62
- "bchNetwork": "mainnet" | "testnet" | "chipnet",
63
- "bindingId": "<64 lowercase hex chars>",
64
- "createdAt": <unix seconds>,
65
- "nonce": "<64 lowercase hex chars>",
66
- "nostrPubkey": "<32-byte lowercase hex>",
67
- "purpose": "identity-binding-v2"
68
- }
47
+ cashlet-did/bch-proof/v3.0.0\n<canonicalized subset>
69
48
  ```
70
49
 
71
- Pass this exact message string to the wallet's message-signing method. Do not
72
- pre-hash, add a Bitcoin prefix, or otherwise transform it in the adapter. The
50
+ The subset contains exactly `bindingId`, `bchAddress`, `bchNetwork`,
51
+ `nostrPubkey`, `nonce`, `purpose`, and `createdAt`. Pass this exact text to the
52
+ wallet; do not pre-hash, add a Bitcoin prefix, or otherwise transform it. The
73
53
  wallet computes:
74
54
 
75
55
  ```text
@@ -77,168 +57,150 @@ SHA256d(0x18 || "Bitcoin Signed Message:\n" ||
77
57
  CompactSize(UTF8(message).length) || UTF8(message))
78
58
  ```
79
59
 
80
- `CompactSize` uses Bitcoin's little-endian encodings: one byte below `0xfd`,
81
- `fd` plus 2 bytes through `0xffff`, `fe` plus 4 bytes through `0xffffffff`,
82
- then `ff` plus 8 bytes.
60
+ The response MUST be canonical padded Base64 for exactly 65 bytes:
61
+ `[header 31–34] || [32-byte r] || [32-byte s]`. The header selects a compressed
62
+ public key and recovery ID 0–3. Raw hex, 64-byte signatures, uncompressed
63
+ headers, non-canonical Base64, and v1/v2 proof formats are rejected.
83
64
 
84
- The wallet response is standard padded Base64 encoding of exactly 65 bytes:
65
+ ## 3. Binding Nostr event
85
66
 
86
- ```text
87
- [header byte 31–34] || [32-byte r] || [32-byte s]
88
- ```
89
-
90
- The header is `27 + 4 + recovery_id`; recovery IDs are `0–3`, and `+4`
91
- specifies a compressed public key. V2 accepts only this compressed-key form.
92
- Verifiers decode Base64, subtract 31 from the header, recover the compressed
93
- public key, and derive its prefixed BCH CashAddr. Raw hex, 64-byte signatures,
94
- uncompressed headers, non-canonical Base64, and v1 signatures are rejected.
95
-
96
- ### Wallet interoperability evidence
97
-
98
- Paytaca's WalletConnect implementation signs the supplied message with the
99
- Bitcoin message prefix, `bigIntToCompactUint`, double-SHA256, and recoverable
100
- compact ECDSA; it adds `31` for compressed keys and returns Base64:
101
- [Paytaca source](https://github.com/Paytaca/paytaca-app/blob/41c785b9f48dee337d2da3f33e082e5210d462d0/src/wallet/walletconnect2/index.js).
102
- The BCH WalletConnect BCR also specifies Electron Cash-compatible signed
103
- messages: [BCR](https://github.com/mainnet-pat/wc2-bch-bcr/blob/55e3e37142ddc7b30ad63c7330ef8df252fec715/README.md).
104
- The library keeps this contract transport-neutral; validate each wallet
105
- release's response in integration tests before advertising support.
106
-
107
- ## 4. Nostr event (kind 30078)
108
-
109
- The event timestamp MUST equal the binding's `createdAt`. Required tags:
67
+ Publish a kind `30078` event whose timestamp equals `binding.createdAt`.
68
+ Required tags:
110
69
 
111
70
  ```json
112
71
  [
113
72
  ["d", "<bindingId>"],
114
73
  ["bch", "<cashaddr>", "<network>"],
115
- ["protocol", "cashlet-did", "2.0.0"],
74
+ ["protocol", "cashlet-did", "3.0.0"],
116
75
  ["t", "cashlet-did"],
117
76
  ["bch-proof", "bitcoin-message/compact-base64", "<exact message>", "<signature Base64>"]
118
77
  ]
119
78
  ```
120
79
 
121
- Optional tags:
122
-
123
- ```json
124
- ["bch-payment-code", "<BIP47 code>"] // must equal content.bch.paymentCode
125
- ["supersedes", "<previous bindingId>"] // must equal content.previousBindingId
126
- ["bch-anchor", "<txid>", "<claimed height>"] // unverified chain claim
127
- ```
80
+ Optional tags are `bch-payment-code`, `supersedes`, `rotation-proof`, and
81
+ `bch-anchor`. Content is canonical JSON with `protocol`, `version`,
82
+ `bindingId`, `bch`, `nonce`, and `createdAt`, plus optional `previousBindingId`
83
+ and embedded `rotationAuthorization`. The Nostr event signature covers all
84
+ tags and content. A `bch-anchor` tag is an untrusted claim until independently
85
+ verified under §8.
128
86
 
129
- `content` is the canonicalized JSON object containing `protocol`, `version`,
130
- `bindingId`, `bch`, `nonce`, `createdAt`, and optional `previousBindingId`.
131
- `bch` contains `address`, `network`, and optional `paymentCode`. Nostr event
132
- signatures protect all event tags and content.
87
+ ## 4. Discovery, revocation, and verification
133
88
 
134
- ## 5. Discovery and revocation
135
-
136
- Discover v2 records with this NIP-01 filter:
89
+ Discover v3 bindings with:
137
90
 
138
91
  ```json
139
92
  { "kinds": [30078], "authors": ["<pubkey hex>"], "#t": ["cashlet-did"] }
140
93
  ```
141
94
 
142
95
  Revocations are kind `5` events from the same author with `["e", "<event id>"]`
143
- tags. Filter revoked records before selecting the newest v2 binding.
96
+ tags. Filter revoked records before selecting the newest binding.
144
97
 
145
- ## 6. Verification algorithm
98
+ A verifier MUST:
146
99
 
147
100
  1. Verify the NIP-01 event signature and require kind `30078`.
148
101
  2. Require exactly one correctly shaped `d`, `bch`, `protocol`, `t`, and
149
- `bch-proof` tag. The proof tag MUST have exactly four elements and the v2
150
- format marker. Check optional payment-code and supersedes tags against
151
- content; reject duplicate or malformed known tags.
152
- 3. Parse content and require its canonical serialization, exact v2 fields,
153
- event timestamp, `d`, `bch`, protocol/version tags, and resolved identity
154
- (`npub`, Nostr pubkey, address, network, and binding ID) to agree.
155
- 4. Recompute the binding ID and reconstruct the exact v2 BCH signing payload.
156
- The proof tag's message must be byte-for-byte identical.
157
- 5. Decode the canonical 65-byte Base64 compact signature, recover the
158
- compressed public key from the Bitcoin signed-message hash, and derive
159
- the network-appropriate BCH CashAddr. It must equal the bound address.
102
+ `bch-proof` tag; validate all known optional tags and reject duplicates.
103
+ 3. Require canonical content with the exact v3 fields, timestamp, binding ID,
104
+ event tags, and resolved identity to agree.
105
+ 4. Recompute the binding ID and exact v3 BCH signing payload.
106
+ 5. Recover the compressed BCH public key from the canonical compact signature
107
+ and require its CashAddr to equal the bound address.
160
108
  6. Report `verified` only when both the Nostr and BCH signatures are valid.
161
109
 
162
- V1 tags do not satisfy the v2 proof-tag structure and MUST NOT be accepted as
163
- fallbacks.
110
+ V1 and v2 bindings MUST NOT be accepted as fallbacks.
164
111
 
165
- ## 7. Challenge-response authentication
112
+ ## 5. Cross-key rotation
166
113
 
167
- The exact v2 message is either:
114
+ A successor sets `previousBindingId` and includes the matching `supersedes`
115
+ tag. Resolvers MUST verify every event in the chain and fail closed if a
116
+ predecessor is missing, revoked, or invalid.
168
117
 
169
- ```text
170
- cashlet-did/challenge/v2.0.0\n<nonce>
171
- ```
118
+ When the Nostr key changes, the successor MUST embed a kind `30080`
119
+ authorization event signed by the previous Nostr key. Its canonical content
120
+ names the previous binding ID/key and the exact successor binding ID/key,
121
+ BCH address/network, and creation time. The successor includes
122
+ `["rotation-proof", "<authorization event id>", "<old pubkey>"]`. A resolver
123
+ MUST verify the old-key event signature and match every field and tag to the
124
+ successor. Same-key succession does not need this extra signature. Lost-key
125
+ recovery is out of scope; without old-key authorization, cross-key rotation is
126
+ rejected.
172
127
 
173
- or, with an audience:
128
+ ## 6. Dual-key challenge-response authentication
129
+
130
+ The BCH-signed payload is either:
174
131
 
175
132
  ```text
176
- cashlet-did/challenge/v2.0.0\n<nonce>\n<audience>
133
+ cashlet-did/challenge/v3.0.0\n<nonce>
134
+ cashlet-did/challenge/v3.0.0\n<nonce>\n<audience>
177
135
  ```
178
136
 
179
- The no-audience form has exactly two lines; the audience form has exactly three.
180
- No extra lines or trailing newline are allowed. The nonce and optional audience
181
- are single-line strings. The BCH wallet signs the exact text using §3's Bitcoin
182
- signed-message hash and 65-byte Base64 signature.
183
- `bchChallengePayload(nonce, audience?)` constructs the payload;
184
- `verifyChallengeProof` checks exact equality, signature, address, and—when
185
- `issuedAt` is provided—a 5-minute TTL. Applications must issue and consume
186
- one-time challenges and separately verify Nostr control if required.
187
-
188
- ## 8. Key rotation
137
+ The audience form has exactly three lines; no extra line or trailing newline
138
+ is allowed. The Nostr key also signs a kind `27235` event with exactly one each
139
+ of these tags: `u` (verification endpoint), `method` (`POST`), `audience`,
140
+ `challenge`, and `expiration`. Its content is the exact BCH payload above.
141
+ Both proofs MUST match the same verified binding's BCH address and Nostr
142
+ pubkey, nonce, audience, endpoint, issue time, and expiration.
189
143
 
190
- Set `previousBindingId` in the new binding and add the matching
191
- `["supersedes", "<previousBindingId>"]` tag. Resolvers can follow references
192
- backwards, but must independently verify each referenced binding and fail
193
- closed if a predecessor is unavailable or invalid. A supersedes tag authored
194
- by the new key does not prove that the previous Nostr key authorized rotation.
144
+ The server MUST issue an unpredictable, one-time challenge, persist it, and
145
+ atomically consume it only after validating both signatures. Browser-local
146
+ challenge state alone is not server-enforced authentication.
147
+ `verifyDualChallengeProof` checks cryptographic and context binding; the
148
+ application is responsible for durable single-use challenge state.
195
149
 
196
- ## 9. Selective disclosure
150
+ ## 7. NIP-05 and NIP-98
197
151
 
198
- The library's selective-disclosure helper commits each attribute using an
199
- independent random 32-byte salt:
200
-
201
- ```text
202
- SHA-256("cashlet-did/attr/v2\n" + JSON([key, value, salt]))
203
- ```
152
+ NIP-05 resolution is `user@domain` → `GET
153
+ https://domain/.well-known/nostr.json?name=user` → extract pubkey → resolve
154
+ and verify the v3 binding. A registration endpoint MUST verify the candidate
155
+ event's Nostr signature and BCH ownership proof before reserving a handle.
204
156
 
205
- Keep salts private until revealing their associated values; include the value
206
- and salt in each reveal so a verifier can recompute the commitment. The helper
207
- does not publish commitments or attach them to a binding event automatically.
157
+ NIP-98 uses kind `27235`. The `verifyBch: true` option additionally resolves
158
+ the signer's v3 Cashlet DID binding. The server verifier must receive the
159
+ actual request body (`null` when absent), compare its SHA-256 hash with the
160
+ `payload` tag, reject missing/duplicate/mismatched hashes, and enforce the
161
+ documented 60-second event window.
208
162
 
209
- ## 10. On-chain commitment (OP_RETURN)
163
+ ## 8. BCH transaction anchor
210
164
 
211
- Embed the binding ID in a BCH transaction output:
165
+ The OP_RETURN output commits to:
212
166
 
213
167
  ```text
214
168
  OP_RETURN <"cashlet-did:" + bindingId>
215
169
  ```
216
170
 
217
- The library can construct an anchor transaction, but it does not broadcast the
218
- transaction or verify its inclusion in a confirmed block. A `bch-anchor` event
219
- tag is only an issuer-provided claim; applications that need confirmed
220
- anchoring must independently verify the transaction and block inclusion.
171
+ `buildAnchorTx` constructs a transaction but does not broadcast it. A binding's
172
+ `anchor` field and `bch-anchor` tag only identify a claimed transaction and
173
+ height; they do not prove inclusion. `verifyBchAnchorProof` checks the exact
174
+ zero-value commitment, txid, Merkle inclusion, BCH ASERT difficulty, proof of
175
+ work, median-time-past, future-time bound, and confirmation depth for a header
176
+ chain extending from an application-trusted checkpoint.
221
177
 
222
- ## 11. NIP-05 handle resolution
178
+ The exported `BCH_MAINNET_CHECKPOINT` is a fixed mainnet trust root. The result
179
+ is **checkpointed SPV**, not full-node validation: proof sources are untrusted,
180
+ the checkpoint must not be fetched from the same endpoint, and the proof does
181
+ not establish that a supplied branch is the globally highest-work branch.
182
+ There is no chipnet checkpoint or proof-fetch adapter in this package, so
183
+ chipnet anchors remain unverifiable unless an application supplies an
184
+ independently reviewed checkpoint and consensus configuration.
223
185
 
224
- `user@domain` → `GET https://domain/.well-known/nostr.json?name=user` →
225
- extract `pubkey` → resolve the v2 binding via §5.
186
+ ## 9. Other helpers
226
187
 
227
- ## 12. NIP-98 HTTP Auth
188
+ Selective disclosure commits each attribute using an independent random
189
+ 32-byte salt:
228
190
 
229
- Build a kind `27235` event per NIP-98. The `verifyBch: true` option on the
230
- server additionally resolves the signer's v2 Cashlet DID binding. The verifier
231
- must receive the actual request body (`null` when absent) and compare its
232
- SHA-256 hash with the event's `payload` tag. Reject missing, duplicate, or
233
- mismatched payload hashes and enforce the documented 60-second event window.
191
+ ```text
192
+ SHA-256("cashlet-did/attr/v2\n" + JSON([key, value, salt]))
193
+ ```
234
194
 
235
- ## 13. Credentials
195
+ Keep salts private until revealing their associated values; include the value
196
+ and salt in each reveal so a verifier can recompute the commitment. The helper
197
+ does not publish commitments or attach them to a binding event automatically.
236
198
 
237
- Credential events use kind `30085`. A resolver must verify the Nostr event
199
+ Credential events use kind `30085`. A resolver must verify the issuer's Nostr
238
200
  signature, require the event's `p` tag and credential payload subject to match
239
201
  the requested pubkey, and require the `credential-type` tag to match the
240
- payload type. `issuedAt` must match the event timestamp. If `expiry` is present,
241
- reject the credential once that Unix timestamp has passed. A revocation is
242
- effective only when a valid kind `5` event from the credential's issuer
243
- references that credential event ID. Cryptographic validity does not establish
244
- that an issuer is trusted; trust policy is application-specific.
202
+ payload type. `issuedAt` must match the event timestamp. If `expiry` is
203
+ present, reject the credential once that Unix timestamp has passed. A
204
+ revocation is effective only when a valid kind `5` event from the issuer
205
+ references that credential event. Cryptographic validity does not establish
206
+ issuer trust; that policy is application-specific.
package/README.md CHANGED
@@ -4,8 +4,9 @@ A standalone, framework-agnostic library implementing the **Cashlet DID protocol
4
4
 
5
5
  Any BCH wallet or Nostr client can use this library to publish, resolve, and verify BCH–Nostr identity bindings without depending on the Cashlet extension.
6
6
 
7
- This release is Cashlet DID v2.0.0, a breaking proof-format update. V2 verifiers
8
- do not accept v1 bindings; existing identities must be rebound and republished.
7
+ This release is Cashlet DID v3.0.0, a breaking protocol update. V3 verifiers
8
+ do not accept v1 or v2 bindings; existing identities must be rebound and
9
+ republished.
9
10
 
10
11
  ## Real-world use cases
11
12
 
@@ -13,9 +14,9 @@ do not accept v1 bindings; existing identities must be rebound and republished.
13
14
 
14
15
  **Nostr client** — call `verifyIdentity` when rendering a profile to show a cryptographically-proven "verified BCH address" badge. No issuing authority — the proof is in the signature.
15
16
 
16
- **Merchant checkout** — call `resolveByNip05('alice@shop.com')` at payment time to resolve the current NIP-05 pubkey and its verified binding. Cross-key rotation continuity is not established by this library, so merchants must update their NIP-05 record and clients must not assume a new key is authorized by the old one.
17
+ **Merchant checkout** — call `resolveByNip05('alice@shop.com')` at payment time to resolve the current NIP-05 pubkey and its verified binding. The v3 resolver checks old-key authorization when following a cross-key rotation.
17
18
 
18
- **Passwordless login** — use `verifyHttpAuth` in a server with a NIP-07 signer. The server verifies the signed Nostr request and can optionally resolve the signer's BCH binding; it still needs application-specific authorization and replay policy.
19
+ **Passwordless login** — issue a server-side one-time challenge, collect BCH and Nostr signatures, and verify them with `verifyDualChallengeProof`. The application must atomically consume challenges and apply its own authorization policy. `verifyHttpAuth` remains available for NIP-98 requests.
19
20
 
20
21
  ## Install
21
22
 
@@ -89,6 +90,14 @@ For WalletConnect, the adapter maps this call to `bch_signMessage` with the
89
90
  message in its params. WizardConnect needs a separate wallet-side signing action
90
91
  and adapter; neither is implemented in this package.
91
92
 
93
+ The signer is supplied by the integrating wallet, so this library does not
94
+ require users to connect to Selene, Paytaca, or any other external wallet. A
95
+ wallet can pass its own non-custodial BCH signer directly. For example, Cashlet
96
+ uses its own BCH keys by default; its optional WalletConnect adapter is only for
97
+ users who choose to bind an address held by another compatible wallet. In either
98
+ case, the BCH key signs the proof while a separate Nostr key signs the published
99
+ event.
100
+
92
101
  ### Key derivation (NIP-06)
93
102
 
94
103
  ```ts
@@ -102,35 +111,53 @@ const social = deriveNostrKeypair(mnemonic, 1)
102
111
  ### Challenge-response authentication
103
112
 
104
113
  ```ts
105
- import { bchChallengePayload, signBchMessage, verifyChallengeProof } from 'did-beta-bch'
114
+ import {
115
+ bchChallengePayload, nostrChallengeTemplate, verifyDualChallengeProof,
116
+ } from 'did-beta-bch'
106
117
 
107
118
  const payload = bchChallengePayload(nonce, audience)
108
- const signature = await signBchMessage(walletSigner, payload)
109
- // issuedAt enforces 5-minute TTL
110
- const valid = verifyChallengeProof(
111
- { signature, payload, address },
112
- nonce,
113
- audience,
114
- issuedAt, // unix seconds — optional, rejects if > 5 min old
119
+ const nostrTemplate = nostrChallengeTemplate(
120
+ nonce, audience, 'https://app.example/api/auth/verify', issuedAt,
121
+ )
122
+ // Collect the BCH signature over `payload` and Nostr signature over `nostrTemplate`.
123
+ const valid = verifyDualChallengeProof(
124
+ { signature, payload, address, nostrEvent },
125
+ {
126
+ nonce,
127
+ audience,
128
+ endpoint: 'https://app.example/api/auth/verify',
129
+ issuedAt,
130
+ address,
131
+ pubkey,
132
+ },
115
133
  )
116
134
  ```
117
135
 
136
+ Challenge issuance and one-time consumption belong on a server with durable,
137
+ atomic storage. Do not treat browser-only challenge state as authentication.
138
+
118
139
  ### Key rotation
119
140
 
120
141
  ```ts
121
- import { buildBinding, resolveIdentityChain } from 'did-beta-bch'
142
+ import {
143
+ buildBinding, rotationAuthorizationTemplate, nostrEventTemplate,
144
+ resolveIdentityChain,
145
+ } from 'did-beta-bch'
122
146
 
123
- // Rotate: reference the old binding
147
+ // For a changed Nostr key, have the old key sign this authorization event.
124
148
  const newBinding = await buildBinding(newAddress, 'mainnet', newNostrPub, undefined, oldBindingId)
149
+ const authorizationTemplate = rotationAuthorizationTemplate(oldBinding, newBinding)
150
+ const oldKeyAuthorization = finalizeEvent(authorizationTemplate, oldNostrPrivkey)
151
+ // Publish the successor with its BCH proof and the old-key authorization.
152
+ const successorTemplate = nostrEventTemplate(newBinding, bchProof, oldKeyAuthorization)
125
153
 
126
154
  // Resolve linked, individually verified binding events, newest first
127
155
  const chain = await resolveIdentityChain('npub1...')
128
156
  ```
129
157
 
130
- The resolver verifies each event and requires every `supersedes` target to be
131
- present, unrevoked, and valid. A reference signed only by the new key does not
132
- prove that the previous Nostr key authorized the rotation; do not treat this
133
- chain alone as proof of cross-key continuity.
158
+ For cross-key rotation, the resolver verifies the old-key authorization and
159
+ requires every `supersedes` target to be present, unrevoked, and valid. Lost-key
160
+ recovery is not supported.
134
161
 
135
162
  ### Verifiable credentials + reputation
136
163
 
@@ -170,16 +197,26 @@ commitments or attach them to a binding event automatically.
170
197
  ### BCH-anchored identity commitment
171
198
 
172
199
  ```ts
173
- import { buildAnchorTx } from 'did-beta-bch'
200
+ import {
201
+ buildAnchorTx, verifyBchAnchorProof,
202
+ BCH_MAINNET_CHECKPOINT, BCH_MAINNET_ASERT_PARAMETERS,
203
+ } from 'did-beta-bch'
174
204
 
175
205
  // Embed bindingId in an OP_RETURN transaction for on-chain commitment
176
206
  const { txHex } = await buildAnchorTx(bindingId, { txid, vout, valueSats, lockingBytecode, privateKey })
207
+ // Independently verify transaction, Merkle proof, headers, and depth
208
+ const result = verifyBchAnchorProof(proof, bindingId, {
209
+ checkpoint: BCH_MAINNET_CHECKPOINT,
210
+ asert: BCH_MAINNET_ASERT_PARAMETERS,
211
+ minConfirmations: 6,
212
+ })
177
213
  ```
178
214
 
179
- This builds transaction hex from the supplied UTXO; it
180
- does not broadcast the transaction or verify its confirmation. A `bch-anchor`
181
- tag is only a claim until an application independently verifies the transaction
182
- and its block inclusion.
215
+ The library does not broadcast the transaction or fetch proofs. Mainnet
216
+ verification uses a fixed checkpoint and is checkpointed SPV, not full-node
217
+ validation; a supplied chain does not prove it is the globally highest-work
218
+ branch. There is no built-in chipnet checkpoint, so chipnet anchors must not
219
+ be presented as verified.
183
220
 
184
221
  ### NIP-05 handle resolution
185
222
 
@@ -238,17 +275,15 @@ Bindings are published as Nostr events of kind `30078` (NIP-78 addressable data)
238
275
  | `supersedes` | previous binding ID (key rotation) |
239
276
  | `t` | `cashlet-did` |
240
277
 
241
- The wallet receives the exact UTF-8 v2 message text and applies the Bitcoin
278
+ The wallet receives the exact UTF-8 v3 message text and applies the Bitcoin
242
279
  Signed Message prefix, CompactSize byte length, and double-SHA256. Its response
243
280
  is standard padded Base64 for a 65-byte compact signature: header 31–34
244
- (compressed key plus recovery ID 0–3), followed by 64-byte `r || s`. V2
245
- verifiers strictly reject v1 hex/raw signature encodings. Revocation uses
281
+ (compressed key plus recovery ID 0–3), followed by 64-byte `r || s`. V3
282
+ verifiers strictly reject v1/v2 and hex/raw signature encodings. Revocation uses
246
283
  NIP-09 kind `5` deletion events.
247
284
 
248
285
  The local demo previews and verifies a binding but does **not** publish or query
249
- Nostr relays. Its separate HTTP verifier demo demonstrates challenge issue,
250
- verification, and replay rejection locally; `/resolve` needs a published
251
- binding and relay access.
286
+ Nostr relays; `/resolve` needs a published binding and relay access.
252
287
 
253
288
  ## Relays
254
289
 
@@ -0,0 +1,44 @@
1
+ export interface BchSpvCheckpoint {
2
+ height: number;
3
+ headerHex: string;
4
+ hash: string;
5
+ recentTimestamps: number[];
6
+ }
7
+ export interface BchAsertParameters {
8
+ anchorHeight: number;
9
+ anchorBits: number;
10
+ anchorPreviousBlockTime: number;
11
+ powLimitBits: number;
12
+ allowMinDifficultyBlocks?: boolean;
13
+ }
14
+ export interface BchAnchorProof {
15
+ transactionHex: string;
16
+ blockHeight: number;
17
+ transactionIndex: number;
18
+ merkleBranch: string[];
19
+ headers: string[];
20
+ }
21
+ export interface BchAnchorVerificationOptions {
22
+ checkpoint: BchSpvCheckpoint;
23
+ asert: BchAsertParameters;
24
+ minConfirmations?: number;
25
+ maxHeaders?: number;
26
+ now?: number;
27
+ }
28
+ export interface BchAnchorVerificationResult {
29
+ status: "confirmed" | "pending" | "invalid" | "unverifiable";
30
+ txid?: string;
31
+ blockHeight?: number;
32
+ tipHeight?: number;
33
+ confirmations?: number;
34
+ error?: string;
35
+ trustModel: "checkpointed-spv";
36
+ }
37
+ /**
38
+ * Verify a BCH transaction commitment, Merkle inclusion, and an ASERT header
39
+ * chain extending from an application-trusted checkpoint. The proof source is
40
+ * untrusted; checkpoint selection remains the caller's explicit trust root.
41
+ */
42
+ export declare function verifyBchAnchorProof(proof: BchAnchorProof, bindingId: string, options: BchAnchorVerificationOptions): BchAnchorVerificationResult;
43
+ export declare const BCH_MAINNET_ASERT_PARAMETERS: BchAsertParameters;
44
+ export declare const BCH_MAINNET_CHECKPOINT: BchSpvCheckpoint;
@@ -0,0 +1,250 @@
1
+ import { decodeTransaction, hash256, hexToBin } from "@bitauth/libauth";
2
+ import { ANCHOR_PREFIX } from "./anchor.js";
3
+ const HEADER_BYTES = 80;
4
+ const MAX_PROOF_HEADERS = 2048;
5
+ const TARGET_SPACING_SECS = 600n;
6
+ const ASERT_HALF_LIFE_SECS = 172800n;
7
+ const ASERT_RADIX = 1n << 16n;
8
+ function bytesToHex(bytes) {
9
+ return Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
10
+ }
11
+ function reverse(bytes) {
12
+ return Uint8Array.from(bytes).reverse();
13
+ }
14
+ function parseHex(value, byteLength) {
15
+ if (!/^(?:[0-9a-f]{2})+$/i.test(value))
16
+ throw new Error("Expected even-length hexadecimal data");
17
+ const bytes = hexToBin(value);
18
+ if (typeof bytes === "string" || (byteLength !== undefined && bytes.length !== byteLength))
19
+ throw new Error("Invalid hexadecimal data length");
20
+ return bytes;
21
+ }
22
+ function parseHeader(headerHex) {
23
+ const raw = parseHex(headerHex, HEADER_BYTES);
24
+ const hash = hash256(raw);
25
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
26
+ return {
27
+ raw,
28
+ hash,
29
+ hashHex: bytesToHex(reverse(hash)),
30
+ previousHash: raw.slice(4, 36),
31
+ merkleRoot: raw.slice(36, 68),
32
+ timestamp: view.getUint32(68, true),
33
+ bits: view.getUint32(72, true),
34
+ };
35
+ }
36
+ function compactToTarget(bits) {
37
+ const exponent = bits >>> 24;
38
+ const mantissa = bits & 0x007fffff;
39
+ const negative = (bits & 0x00800000) !== 0;
40
+ const overflow = mantissa !== 0 && (exponent > 34 ||
41
+ (mantissa > 0xff && exponent > 33) ||
42
+ (mantissa > 0xffff && exponent > 32));
43
+ if (negative || overflow || mantissa === 0)
44
+ throw new Error("Invalid compact difficulty target");
45
+ const target = exponent <= 3
46
+ ? BigInt(mantissa) >> (8n * BigInt(3 - exponent))
47
+ : BigInt(mantissa) << (8n * BigInt(exponent - 3));
48
+ if (target <= 0n || target >= (1n << 256n))
49
+ throw new Error("Difficulty target is out of range");
50
+ if (targetToCompact(target) !== bits)
51
+ throw new Error("Non-canonical compact difficulty target");
52
+ return target;
53
+ }
54
+ function targetToCompact(target) {
55
+ let size = Math.ceil(target.toString(16).length / 2);
56
+ let compact = size <= 3
57
+ ? Number(target << (8n * BigInt(3 - size)))
58
+ : Number(target >> (8n * BigInt(size - 3)));
59
+ if (compact & 0x00800000) {
60
+ compact >>>= 8;
61
+ size++;
62
+ }
63
+ return ((size << 24) | compact) >>> 0;
64
+ }
65
+ function asertTarget(parameters, previousHeight, previousTime) {
66
+ const anchorTarget = compactToTarget(parameters.anchorBits);
67
+ const timeDifference = BigInt(previousTime - parameters.anchorPreviousBlockTime);
68
+ const heightDifference = BigInt(previousHeight - parameters.anchorHeight);
69
+ if (heightDifference < 0n)
70
+ throw new Error("Checkpoint predates the ASERT anchor");
71
+ const exponent = ((timeDifference - TARGET_SPACING_SECS * heightDifference) * ASERT_RADIX) /
72
+ ASERT_HALF_LIFE_SECS;
73
+ const shifts = exponent >> 16n;
74
+ const fraction = exponent & 0xffffn;
75
+ const factor = ASERT_RADIX + ((195766423245049n * fraction +
76
+ 971821376n * fraction * fraction +
77
+ 5127n * fraction * fraction * fraction +
78
+ (1n << 47n)) >> 48n);
79
+ let target = anchorTarget * factor;
80
+ const shift = shifts - 16n;
81
+ target = shift <= 0n ? target >> -shift : target << shift;
82
+ const powLimit = compactToTarget(parameters.powLimitBits);
83
+ if (target <= 0n)
84
+ return 1n;
85
+ return target > powLimit ? powLimit : target;
86
+ }
87
+ function expectedBits(parameters, previousHeight, previous, candidateTime) {
88
+ if (parameters.allowMinDifficultyBlocks &&
89
+ candidateTime > previous.timestamp + Number(2n * TARGET_SPACING_SECS))
90
+ return parameters.powLimitBits;
91
+ return targetToCompact(asertTarget(parameters, previousHeight, previous.timestamp));
92
+ }
93
+ function assertProofOfWork(header, powLimit) {
94
+ const target = compactToTarget(header.bits);
95
+ if (target > powLimit)
96
+ throw new Error("Header target exceeds the network proof-of-work limit");
97
+ const hashValue = BigInt(`0x${header.hashHex}`);
98
+ if (hashValue > target)
99
+ throw new Error("Header does not satisfy proof of work");
100
+ return target;
101
+ }
102
+ function transactionId(transactionHex) {
103
+ const raw = parseHex(transactionHex);
104
+ const decoded = decodeTransaction(raw);
105
+ if (typeof decoded === "string")
106
+ throw new Error(`Invalid transaction: ${decoded}`);
107
+ return { txid: bytesToHex(reverse(hash256(raw))), outputs: decoded.outputs };
108
+ }
109
+ function hasBindingCommitment(outputs, bindingId) {
110
+ const data = new TextEncoder().encode(`${ANCHOR_PREFIX}${bindingId}`);
111
+ const expected = new Uint8Array([0x6a, 0x4c, data.length, ...data]);
112
+ return outputs.filter(output => output.valueSatoshis === 0n &&
113
+ output.lockingBytecode.length === expected.length &&
114
+ output.lockingBytecode.every((byte, index) => byte === expected[index])).length === 1;
115
+ }
116
+ function verifyMerkleBranch(txid, transactionIndex, branch, merkleRoot) {
117
+ if (!Number.isSafeInteger(transactionIndex) || transactionIndex < 0 || branch.length > 32)
118
+ return false;
119
+ let index = transactionIndex;
120
+ let hash = reverse(parseHex(txid, 32));
121
+ for (const siblingHex of branch) {
122
+ const sibling = reverse(parseHex(siblingHex, 32));
123
+ hash = index & 1
124
+ ? hash256(new Uint8Array([...sibling, ...hash]))
125
+ : hash256(new Uint8Array([...hash, ...sibling]));
126
+ index = Math.floor(index / 2);
127
+ }
128
+ return index === 0 && hash.every((byte, position) => byte === merkleRoot[position]);
129
+ }
130
+ function result(status, error, details = {}) {
131
+ return {
132
+ status,
133
+ trustModel: "checkpointed-spv",
134
+ ...(error ? { error } : {}),
135
+ ...details,
136
+ };
137
+ }
138
+ /**
139
+ * Verify a BCH transaction commitment, Merkle inclusion, and an ASERT header
140
+ * chain extending from an application-trusted checkpoint. The proof source is
141
+ * untrusted; checkpoint selection remains the caller's explicit trust root.
142
+ */
143
+ export function verifyBchAnchorProof(proof, bindingId, options) {
144
+ try {
145
+ if (!/^[0-9a-f]{64}$/.test(bindingId))
146
+ return result("invalid", "Invalid binding ID");
147
+ const minConfirmations = options.minConfirmations ?? 6;
148
+ const maxHeaders = options.maxHeaders ?? MAX_PROOF_HEADERS;
149
+ if (!Number.isSafeInteger(minConfirmations) || minConfirmations < 1)
150
+ return result("invalid", "Minimum confirmations must be a positive integer");
151
+ if (!Number.isSafeInteger(maxHeaders) || maxHeaders < 1)
152
+ return result("invalid", "Maximum header count must be a positive integer");
153
+ if (!proof || !Array.isArray(proof.headers) || proof.headers.length === 0)
154
+ return result("unverifiable", "Header chain proof is missing");
155
+ if (proof.headers.length > maxHeaders)
156
+ return result("unverifiable", "Header chain exceeds the configured verification limit");
157
+ const checkpoint = options.checkpoint;
158
+ const checkpointHeader = parseHeader(checkpoint.headerHex);
159
+ if (!Number.isSafeInteger(checkpoint.height) ||
160
+ checkpoint.height < options.asert.anchorHeight ||
161
+ !Number.isSafeInteger(options.asert.anchorHeight) ||
162
+ !Number.isSafeInteger(options.asert.anchorPreviousBlockTime) ||
163
+ !Number.isSafeInteger(options.asert.powLimitBits) ||
164
+ checkpointHeader.hashHex !== checkpoint.hash.toLowerCase() ||
165
+ !Array.isArray(checkpoint.recentTimestamps) ||
166
+ checkpoint.recentTimestamps.length !== 11 ||
167
+ checkpoint.recentTimestamps[checkpoint.recentTimestamps.length - 1] !== checkpointHeader.timestamp)
168
+ return result("unverifiable", "Invalid or unsupported trusted checkpoint");
169
+ if (!Number.isSafeInteger(proof.blockHeight) || proof.blockHeight <= checkpoint.height)
170
+ return result("unverifiable", "Anchor predates the configured trusted checkpoint");
171
+ if (!Number.isSafeInteger(proof.blockHeight) ||
172
+ !Number.isSafeInteger(proof.transactionIndex) ||
173
+ !Array.isArray(proof.merkleBranch))
174
+ return result("invalid", "Malformed Merkle proof");
175
+ const transaction = transactionId(proof.transactionHex);
176
+ if (!hasBindingCommitment(transaction.outputs, bindingId))
177
+ return result("invalid", "Transaction does not contain the exact binding commitment", { txid: transaction.txid });
178
+ let previous = checkpointHeader;
179
+ let previousHeight = checkpoint.height;
180
+ let timestamps = checkpoint.recentTimestamps.slice(-11);
181
+ let inclusionHeader;
182
+ const powLimit = compactToTarget(options.asert.powLimitBits);
183
+ const now = options.now ?? Math.floor(Date.now() / 1000);
184
+ for (let offset = 0; offset < proof.headers.length; offset++) {
185
+ const height = checkpoint.height + offset + 1;
186
+ const header = parseHeader(proof.headers[offset]);
187
+ if (!header.previousHash.every((byte, index) => byte === previous.hash[index]))
188
+ return result("invalid", `Header chain link failed at height ${height}`);
189
+ if (header.timestamp <= [...timestamps].sort((a, b) => a - b)[Math.floor(timestamps.length / 2)])
190
+ return result("invalid", `Header timestamp violates median-time-past at height ${height}`);
191
+ if (header.timestamp > now + 2 * 60 * 60)
192
+ return result("invalid", `Header timestamp is too far in the future at height ${height}`);
193
+ const requiredBits = expectedBits(options.asert, previousHeight, previous, header.timestamp);
194
+ if (header.bits !== requiredBits)
195
+ return result("invalid", `Unexpected difficulty target at height ${height}`);
196
+ assertProofOfWork(header, powLimit);
197
+ if (height === proof.blockHeight)
198
+ inclusionHeader = header;
199
+ timestamps = [...timestamps, header.timestamp].slice(-11);
200
+ previous = header;
201
+ previousHeight = height;
202
+ }
203
+ const tipHeight = checkpoint.height + proof.headers.length;
204
+ if (!inclusionHeader)
205
+ return result("invalid", "Inclusion block is not present in the supplied header chain", { txid: transaction.txid, tipHeight });
206
+ if (!verifyMerkleBranch(transaction.txid, proof.transactionIndex, proof.merkleBranch, inclusionHeader.merkleRoot))
207
+ return result("invalid", "Transaction Merkle proof does not match the inclusion block", {
208
+ txid: transaction.txid,
209
+ blockHeight: proof.blockHeight,
210
+ tipHeight,
211
+ });
212
+ const confirmations = tipHeight - proof.blockHeight + 1;
213
+ return result(confirmations >= minConfirmations ? "confirmed" : "pending", undefined, {
214
+ txid: transaction.txid,
215
+ blockHeight: proof.blockHeight,
216
+ tipHeight,
217
+ confirmations,
218
+ });
219
+ }
220
+ catch (error) {
221
+ return result("invalid", error instanceof Error ? error.message : String(error));
222
+ }
223
+ }
224
+ export const BCH_MAINNET_ASERT_PARAMETERS = {
225
+ anchorHeight: 661647,
226
+ anchorBits: 0x1804dafe,
227
+ anchorPreviousBlockTime: 1605447844,
228
+ powLimitBits: 0x1d00ffff,
229
+ };
230
+ // Fixed trust root, cross-checked against two independent BCH mainnet Electrum
231
+ // servers. Refresh in a reviewed release as it ages; never fetch trust roots
232
+ // from the same untrusted endpoint that supplies a proof.
233
+ export const BCH_MAINNET_CHECKPOINT = {
234
+ height: 975752,
235
+ hash: "0000000000000000010a200fe779aa3c313c7fc1b355b13560ef105709d153fd",
236
+ headerHex: "000000248509713afafd1db2ec77ef48baf6522757a8b2cd8cb315000000000000000000d70ca4e92d1bc9485441fb6626766cc8a350e59cc44b6a15ddc36dae072f9b60eaa7e96a680f02181a62ef3c",
237
+ recentTimestamps: [
238
+ 1793688001,
239
+ 1793688081,
240
+ 1793688330,
241
+ 1793692665,
242
+ 1793693308,
243
+ 1793694029,
244
+ 1793694650,
245
+ 1793696313,
246
+ 1793696954,
247
+ 1793697100,
248
+ 1793697770,
249
+ ],
250
+ };
package/dist/binding.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type BchMessageProof } from "./signing.js";
2
2
  export declare const PROTOCOL_ID = "cashlet-did";
3
- export declare const PROTOCOL_VERSION = "2.0.0";
3
+ export declare const PROTOCOL_VERSION = "3.0.0";
4
4
  export declare const BINDING_KIND = 30078;
5
+ export declare const ROTATION_AUTH_KIND = 30080;
5
6
  export type Network = "mainnet" | "testnet" | "chipnet";
6
7
  export interface CanonicalBinding {
7
8
  protocol: typeof PROTOCOL_ID;
@@ -17,7 +18,7 @@ export interface CanonicalBinding {
17
18
  };
18
19
  createdAt: number;
19
20
  nonce: string;
20
- purpose: "identity-binding-v2";
21
+ purpose: "identity-binding-v3";
21
22
  previousBindingId?: string;
22
23
  anchor?: {
23
24
  txid: string;
@@ -30,6 +31,7 @@ export interface IdentityBinding {
30
31
  nostrProof: {
31
32
  event: NostrEvent;
32
33
  };
34
+ rotationAuthorization?: NostrEvent;
33
35
  publishedAt?: number;
34
36
  }
35
37
  export interface NostrEvent {
@@ -45,7 +47,14 @@ export declare function canonicalize(v: unknown): string;
45
47
  export declare function sha256hex(s: string): Promise<string>;
46
48
  export declare function buildBinding(bchAddress: string, network: Network, nostrPubkey: string, paymentCode?: string, previousBindingId?: string): Promise<CanonicalBinding>;
47
49
  export declare function bchSigningPayload(binding: CanonicalBinding): string;
48
- export declare function nostrEventTemplate(binding: CanonicalBinding, bchProof?: BchMessageProof): {
50
+ export declare function rotationAuthorizationTemplate(previous: CanonicalBinding, successor: CanonicalBinding): {
51
+ kind: number;
52
+ created_at: number;
53
+ tags: string[][];
54
+ content: string;
55
+ };
56
+ export declare function verifyRotationAuthorization(authorization: NostrEvent, previous: CanonicalBinding, successor: CanonicalBinding): boolean;
57
+ export declare function nostrEventTemplate(binding: CanonicalBinding, bchProof?: BchMessageProof, rotationAuthorization?: NostrEvent): {
49
58
  kind: number;
50
59
  created_at: number;
51
60
  tags: string[][];
package/dist/binding.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { BCH_PROOF_FORMAT, normalizeBchMessageSignature, } from "./signing.js";
2
+ import { verifyEvent } from "nostr-tools";
2
3
  export const PROTOCOL_ID = "cashlet-did";
3
- export const PROTOCOL_VERSION = "2.0.0";
4
+ export const PROTOCOL_VERSION = "3.0.0";
4
5
  export const BINDING_KIND = 30078;
6
+ export const ROTATION_AUTH_KIND = 30080;
5
7
  export function canonicalize(v) {
6
8
  if (v === null || typeof v !== "object")
7
9
  return JSON.stringify(v);
@@ -38,7 +40,7 @@ export async function buildBinding(bchAddress, network, nostrPubkey, paymentCode
38
40
  nostr: { pubkey: nostrPubkey },
39
41
  createdAt: Math.floor(Date.now() / 1000),
40
42
  nonce: randomHex(32),
41
- purpose: "identity-binding-v2",
43
+ purpose: "identity-binding-v3",
42
44
  ...(previousBindingId ? { previousBindingId } : {}),
43
45
  };
44
46
  const bindingId = await sha256hex(canonicalize(partial));
@@ -55,12 +57,56 @@ export function bchSigningPayload(binding) {
55
57
  createdAt: binding.createdAt,
56
58
  });
57
59
  }
58
- export function nostrEventTemplate(binding, bchProof) {
60
+ export function rotationAuthorizationTemplate(previous, successor) {
61
+ if (successor.previousBindingId !== previous.bindingId)
62
+ throw new Error("Successor must reference the previous binding");
63
+ if (successor.nostr.pubkey === previous.nostr.pubkey)
64
+ throw new Error("Rotation authorization is only required when the Nostr key changes");
65
+ return {
66
+ kind: ROTATION_AUTH_KIND,
67
+ created_at: successor.createdAt,
68
+ tags: [
69
+ ["d", successor.bindingId],
70
+ ["protocol", PROTOCOL_ID, PROTOCOL_VERSION],
71
+ ["t", `${PROTOCOL_ID}-rotation`],
72
+ ],
73
+ content: canonicalize({
74
+ protocol: PROTOCOL_ID,
75
+ version: PROTOCOL_VERSION,
76
+ purpose: "key-rotation-authorization-v3",
77
+ previousBindingId: previous.bindingId,
78
+ previousPubkey: previous.nostr.pubkey,
79
+ successorBindingId: successor.bindingId,
80
+ successorPubkey: successor.nostr.pubkey,
81
+ successorBch: successor.bch,
82
+ createdAt: successor.createdAt,
83
+ }),
84
+ };
85
+ }
86
+ export function verifyRotationAuthorization(authorization, previous, successor) {
87
+ if (!authorization ||
88
+ authorization.kind !== ROTATION_AUTH_KIND ||
89
+ authorization.pubkey !== previous.nostr.pubkey ||
90
+ authorization.created_at !== successor.createdAt ||
91
+ !Array.isArray(authorization.tags) ||
92
+ !verifyEvent(authorization))
93
+ return false;
94
+ const expected = rotationAuthorizationTemplate(previous, successor);
95
+ return authorization.content === expected.content &&
96
+ JSON.stringify(authorization.tags) === JSON.stringify(expected.tags);
97
+ }
98
+ export function nostrEventTemplate(binding, bchProof, rotationAuthorization) {
59
99
  if (bchProof) {
60
100
  if (bchProof.message !== bchSigningPayload(binding))
61
101
  throw new Error("BCH proof message does not match the binding");
62
102
  normalizeBchMessageSignature(bchProof.signatureBase64);
63
103
  }
104
+ if (!binding.previousBindingId && rotationAuthorization)
105
+ throw new Error("Unexpected rotation authorization without a predecessor");
106
+ if (rotationAuthorization && (rotationAuthorization.kind !== ROTATION_AUTH_KIND ||
107
+ !Array.isArray(rotationAuthorization.tags) ||
108
+ rotationAuthorization.tags.find(tag => tag[0] === "d")?.[1] !== binding.bindingId))
109
+ throw new Error("Rotation authorization does not match the successor binding");
64
110
  return {
65
111
  kind: BINDING_KIND,
66
112
  created_at: binding.createdAt,
@@ -71,6 +117,7 @@ export function nostrEventTemplate(binding, bchProof) {
71
117
  ["protocol", PROTOCOL_ID, PROTOCOL_VERSION],
72
118
  ["t", PROTOCOL_ID],
73
119
  ...(binding.previousBindingId ? [["supersedes", binding.previousBindingId]] : []),
120
+ ...(rotationAuthorization ? [["rotation-proof", rotationAuthorization.id, rotationAuthorization.pubkey]] : []),
74
121
  ...(binding.anchor ? [["bch-anchor", binding.anchor.txid, ...(binding.anchor.blockHeight ? [String(binding.anchor.blockHeight)] : [])]] : []),
75
122
  ...(bchProof ? [["bch-proof", BCH_PROOF_FORMAT, bchProof.message, bchProof.signatureBase64]] : []),
76
123
  ],
@@ -82,6 +129,7 @@ export function nostrEventTemplate(binding, bchProof) {
82
129
  nonce: binding.nonce,
83
130
  createdAt: binding.createdAt,
84
131
  ...(binding.previousBindingId ? { previousBindingId: binding.previousBindingId } : {}),
132
+ ...(rotationAuthorization ? { rotationAuthorization } : {}),
85
133
  }),
86
134
  };
87
135
  }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  export { buildDisclosure, verifyDisclosure, type Disclosure, type DisclosureReveal, } from "./disclosure.js";
2
2
  export { buildAnchorTx, ANCHOR_PREFIX, type AnchorUtxo, type AnchorResult, } from "./anchor.js";
3
+ export { verifyBchAnchorProof, BCH_MAINNET_ASERT_PARAMETERS, BCH_MAINNET_CHECKPOINT, type BchSpvCheckpoint, type BchAsertParameters, type BchAnchorProof, type BchAnchorVerificationOptions, type BchAnchorVerificationResult, } from "./anchor-verification.js";
3
4
  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";
5
+ 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
6
  export { BCH_PROOF_FORMAT, normalizeBchMessageSignature, signBchMessage, type BchMessageProof, type BchMessageSigner, type BchProofTag, } from "./signing.js";
6
7
  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";
8
+ export { resolveIdentity, resolveAddress, resolveByNip05, verifyIdentity, bchChallengePayload, nostrChallengeTemplate, verifyDualChallengeProof, NOSTR_CHALLENGE_KIND, type DualChallengeProof, verifyChallengeProof, resolveIdentityChain, type VerifyResult, type VerifyStatus, type ResolvedIdentity, type BCHAnchorClaim, } from "./verify.js";
8
9
  export { CashletDidError, type CashletDidErrorCode } from "./errors.js";
9
10
  export { deriveNostrKeypair, type NostrKeypair, } from "./keygen.js";
10
11
  export { buildHttpAuthEventAsync, verifyHttpAuth, type HttpAuthEventTemplate, type HttpAuthResult, } from "./nip98.js";
package/dist/index.js CHANGED
@@ -1,18 +1,19 @@
1
1
  export { buildDisclosure, verifyDisclosure, } from "./disclosure.js";
2
2
  export { buildAnchorTx, ANCHOR_PREFIX, } from "./anchor.js";
3
+ export { verifyBchAnchorProof, BCH_MAINNET_ASERT_PARAMETERS, BCH_MAINNET_CHECKPOINT, } from "./anchor-verification.js";
3
4
  export { issueCredential, issueRating, revokeCredential, resolveCredentials, scoreReputation, CREDENTIAL_KIND, } from "./credentials.js";
4
5
  export {
5
6
  // Protocol constants
6
- PROTOCOL_ID, PROTOCOL_VERSION, BINDING_KIND,
7
+ PROTOCOL_ID, PROTOCOL_VERSION, BINDING_KIND, ROTATION_AUTH_KIND,
7
8
  // Binding construction
8
- buildBinding, bchSigningPayload, nostrEventTemplate, canonicalize, sha256hex, } from "./binding.js";
9
+ buildBinding, bchSigningPayload, nostrEventTemplate, rotationAuthorizationTemplate, verifyRotationAuthorization, canonicalize, sha256hex, } from "./binding.js";
9
10
  export { BCH_PROOF_FORMAT, normalizeBchMessageSignature, signBchMessage, } from "./signing.js";
10
11
  export {
11
12
  // Relay communication
12
13
  publishToRelay, publishToRelays, queryRelay, queryRelays, DEFAULT_RELAYS, } from "./relay.js";
13
14
  export {
14
15
  // Verification
15
- resolveIdentity, resolveAddress, resolveByNip05, verifyIdentity, bchChallengePayload, verifyChallengeProof, resolveIdentityChain, } from "./verify.js";
16
+ resolveIdentity, resolveAddress, resolveByNip05, verifyIdentity, bchChallengePayload, nostrChallengeTemplate, verifyDualChallengeProof, NOSTR_CHALLENGE_KIND, verifyChallengeProof, resolveIdentityChain, } from "./verify.js";
16
17
  export { CashletDidError } from "./errors.js";
17
18
  export { deriveNostrKeypair, } from "./keygen.js";
18
19
  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,6 +27,7 @@ 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
  }
@@ -29,6 +37,20 @@ export declare function resolveIdentity(npub: string, relays?: string[]): Promis
29
37
  export declare function verifyIdentity(resolved: ResolvedIdentity): Promise<VerifyResult>;
30
38
  /** Build the exact versioned challenge message that a BCH wallet must sign. */
31
39
  export declare function bchChallengePayload(nonce: string, audience?: string): string;
40
+ export declare function nostrChallengeTemplate(nonce: string, audience: string, endpoint: string, issuedAt: number): {
41
+ kind: number;
42
+ created_at: number;
43
+ tags: string[][];
44
+ content: string;
45
+ };
46
+ export declare function verifyDualChallengeProof(proof: DualChallengeProof, expected: {
47
+ nonce: string;
48
+ audience: string;
49
+ endpoint: string;
50
+ issuedAt: number;
51
+ address: string;
52
+ pubkey: string;
53
+ }): boolean;
32
54
  /** Verify a challenge-response proof.
33
55
  * The wallet signs the exact text produced by `bchChallengePayload`.
34
56
  * @param issuedAt - unix timestamp when the challenge was issued; if provided, rejects proofs older than 5 minutes.
package/dist/verify.js CHANGED
@@ -1,10 +1,11 @@
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
10
  function varint(n) {
10
11
  if (n < 0xfd)
@@ -164,10 +165,12 @@ export async function verifyIdentity(resolved) {
164
165
  const proofTag = exactlyOne("bch-proof");
165
166
  const paymentCodeTag = optionalTag("bch-payment-code");
166
167
  const supersedesTag = optionalTag("supersedes");
168
+ const rotationTag = optionalTag("rotation-proof");
167
169
  const anchorTag = optionalTag("bch-anchor");
170
+ const rotationAuthorization = content.rotationAuthorization;
168
171
  const previousBindingId = content.previousBindingId;
169
172
  const contentKeys = Object.keys(content).sort().join(",");
170
- const allowedContentKeys = ["bch", "bindingId", "createdAt", "nonce", "previousBindingId", "protocol", "version"]
173
+ const allowedContentKeys = ["bch", "bindingId", "createdAt", "nonce", "previousBindingId", "protocol", "rotationAuthorization", "version"]
171
174
  .filter(key => content[key] !== undefined).sort().join(",");
172
175
  const bchKeys = bch && typeof bch === "object" && !Array.isArray(bch)
173
176
  ? Object.keys(bch).sort().join(",")
@@ -197,7 +200,16 @@ export async function verifyIdentity(resolved) {
197
200
  (bch.paymentCode === undefined ? paymentCodeTag !== undefined : (!paymentCodeTag || paymentCodeTag.length !== 2 || paymentCodeTag[1] !== bch.paymentCode)) ||
198
201
  getTags("bch-payment-code").length > 1 ||
199
202
  (previousBindingId === undefined ? supersedesTag !== undefined : (!supersedesTag || supersedesTag.length !== 2 || supersedesTag[1] !== previousBindingId)) ||
203
+ (rotationAuthorization === undefined ? rotationTag !== undefined : (!rotationAuthorization ||
204
+ typeof rotationAuthorization !== "object" ||
205
+ !rotationTag ||
206
+ rotationTag.length !== 3 ||
207
+ rotationTag[1] !== rotationAuthorization.id ||
208
+ rotationTag[2] !== rotationAuthorization.pubkey ||
209
+ !verifyEvent(rotationAuthorization))) ||
210
+ (rotationAuthorization !== undefined && previousBindingId === undefined) ||
200
211
  getTags("supersedes").length > 1 ||
212
+ getTags("rotation-proof").length > 1 ||
201
213
  getTags("bch-anchor").length > 1 ||
202
214
  (anchorTag !== undefined && ((anchorTag.length !== 2 && anchorTag.length !== 3) ||
203
215
  !/^[0-9a-f]{64}$/.test(anchorTag[1]) ||
@@ -232,7 +244,7 @@ export async function verifyIdentity(resolved) {
232
244
  nostr: { pubkey: rawEvent.pubkey },
233
245
  createdAt: content.createdAt,
234
246
  nonce: content.nonce,
235
- purpose: "identity-binding-v2",
247
+ purpose: "identity-binding-v3",
236
248
  ...(previousBindingId !== undefined ? { previousBindingId } : {}),
237
249
  };
238
250
  const expectedBindingId = await sha256hex(canonicalize(partial));
@@ -263,6 +275,61 @@ export function bchChallengePayload(nonce, audience) {
263
275
  throw new TypeError("Challenge audience must be a non-empty single-line string");
264
276
  return `${PROTOCOL_ID}/challenge/v${PROTOCOL_VERSION}\n${nonce}${audience === undefined ? "" : `\n${audience}`}`;
265
277
  }
278
+ export function nostrChallengeTemplate(nonce, audience, endpoint, issuedAt) {
279
+ const payload = bchChallengePayload(nonce, audience);
280
+ if (!Number.isSafeInteger(issuedAt) || issuedAt <= 0)
281
+ throw new TypeError("Challenge issue time must be a positive Unix timestamp");
282
+ if (typeof endpoint !== "string" || endpoint.length === 0 || /[\r\n]/.test(endpoint))
283
+ throw new TypeError("Challenge endpoint must be a non-empty single-line URL");
284
+ return {
285
+ kind: NOSTR_CHALLENGE_KIND,
286
+ created_at: issuedAt,
287
+ tags: [
288
+ ["u", endpoint],
289
+ ["method", "POST"],
290
+ ["audience", audience],
291
+ ["challenge", nonce],
292
+ ["expiration", String(issuedAt + CHALLENGE_TTL_SECS)],
293
+ ],
294
+ content: payload,
295
+ };
296
+ }
297
+ export function verifyDualChallengeProof(proof, expected) {
298
+ if (!proof ||
299
+ proof.address !== expected.address ||
300
+ !verifyChallengeProof(proof, expected.nonce, expected.audience, expected.issuedAt))
301
+ return false;
302
+ const event = proof.nostrEvent;
303
+ if (!event ||
304
+ event.kind !== NOSTR_CHALLENGE_KIND ||
305
+ event.pubkey !== expected.pubkey ||
306
+ event.created_at !== expected.issuedAt ||
307
+ event.content !== bchChallengePayload(expected.nonce, expected.audience) ||
308
+ !Array.isArray(event.tags))
309
+ return false;
310
+ const uniqueTag = (name) => {
311
+ const matches = event.tags.filter(tag => Array.isArray(tag) && tag[0] === name);
312
+ return matches.length === 1 ? matches[0] : undefined;
313
+ };
314
+ const endpointTag = uniqueTag("u");
315
+ const methodTag = uniqueTag("method");
316
+ const audienceTag = uniqueTag("audience");
317
+ const challengeTag = uniqueTag("challenge");
318
+ const expirationTag = uniqueTag("expiration");
319
+ const expectedTags = nostrChallengeTemplate(expected.nonce, expected.audience, expected.endpoint, expected.issuedAt).tags;
320
+ try {
321
+ return endpointTag?.[1] === expected.endpoint &&
322
+ methodTag?.[1] === "POST" &&
323
+ audienceTag?.[1] === expected.audience &&
324
+ challengeTag?.[1] === expected.nonce &&
325
+ expirationTag?.[1] === String(expected.issuedAt + CHALLENGE_TTL_SECS) &&
326
+ event.tags.length === expectedTags.length &&
327
+ verifyEvent(event);
328
+ }
329
+ catch {
330
+ return false;
331
+ }
332
+ }
266
333
  /** Verify a challenge-response proof.
267
334
  * The wallet signs the exact text produced by `bchChallengePayload`.
268
335
  * @param issuedAt - unix timestamp when the challenge was issued; if provided, rejects proofs older than 5 minutes.
@@ -275,7 +342,9 @@ export function verifyChallengeProof(proof, nonce, audience, issuedAt) {
275
342
  typeof nonce !== "string")
276
343
  return false;
277
344
  if (issuedAt !== undefined &&
278
- (!Number.isSafeInteger(issuedAt) || Math.floor(Date.now() / 1000) - issuedAt > CHALLENGE_TTL_SECS))
345
+ (!Number.isSafeInteger(issuedAt) ||
346
+ issuedAt > Math.floor(Date.now() / 1000) + 60 ||
347
+ Math.floor(Date.now() / 1000) - issuedAt > CHALLENGE_TTL_SECS))
279
348
  return false;
280
349
  let expectedPayload;
281
350
  try {
@@ -365,10 +434,53 @@ export async function resolveIdentityChain(npub, relays = DEFAULT_RELAYS) {
365
434
  }
366
435
  if (!predecessor)
367
436
  return [];
437
+ const predecessorBinding = canonicalBindingFromResolved(predecessor);
438
+ const successorBinding = canonicalBindingFromResolved(current);
439
+ if (!predecessorBinding || !successorBinding)
440
+ return [];
441
+ if (predecessor.pubkey !== current.pubkey) {
442
+ if (!current.rotationAuthorization ||
443
+ !verifyRotationAuthorization(current.rotationAuthorization, predecessorBinding, successorBinding))
444
+ return [];
445
+ }
446
+ else if (current.rotationAuthorization &&
447
+ !verifyRotationAuthorization(current.rotationAuthorization, predecessorBinding, successorBinding)) {
448
+ return [];
449
+ }
368
450
  current = predecessor;
369
451
  }
370
452
  return chain;
371
453
  }
454
+ function canonicalBindingFromResolved(identity) {
455
+ try {
456
+ const content = JSON.parse(identity.rawEvent.content);
457
+ const bch = content.bch;
458
+ if (typeof content.nonce !== "string" ||
459
+ typeof content.createdAt !== "number" ||
460
+ typeof bch.address !== "string" ||
461
+ typeof bch.network !== "string" ||
462
+ !["mainnet", "testnet", "chipnet"].includes(bch.network))
463
+ return null;
464
+ return {
465
+ protocol: PROTOCOL_ID,
466
+ version: PROTOCOL_VERSION,
467
+ bindingId: identity.bindingId,
468
+ bch: {
469
+ address: bch.address,
470
+ network: bch.network,
471
+ ...(typeof bch.paymentCode === "string" ? { paymentCode: bch.paymentCode } : {}),
472
+ },
473
+ nostr: { pubkey: identity.pubkey },
474
+ createdAt: content.createdAt,
475
+ nonce: content.nonce,
476
+ purpose: "identity-binding-v3",
477
+ ...(typeof content.previousBindingId === "string" ? { previousBindingId: content.previousBindingId } : {}),
478
+ };
479
+ }
480
+ catch {
481
+ return null;
482
+ }
483
+ }
372
484
  function resolvedIdentityFromEvent(event) {
373
485
  if (event.kind !== BINDING_KIND ||
374
486
  !/^[0-9a-f]{64}$/.test(event.pubkey) ||
@@ -408,6 +520,9 @@ function resolvedIdentityFromEvent(event) {
408
520
  createdAt,
409
521
  bchProofTag: bchProofTag,
410
522
  rawEvent: event,
523
+ ...(content.rotationAuthorization && typeof content.rotationAuthorization === "object"
524
+ ? { rotationAuthorization: content.rotationAuthorization }
525
+ : {}),
411
526
  ...(anchorTag?.[1] ? {
412
527
  anchorClaim: {
413
528
  txid: anchorTag[1],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "did-beta-bch",
3
- "version": "2.0.0",
3
+ "version": "3.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",