did-beta-bch 2.0.1 → 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 +115 -153
- package/README.md +56 -29
- package/dist/anchor-verification.d.ts +44 -0
- package/dist/anchor-verification.js +250 -0
- package/dist/binding.d.ts +12 -3
- package/dist/binding.js +51 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -3
- package/dist/verify.d.ts +23 -1
- package/dist/verify.js +119 -4
- package/package.json +2 -2
package/PROTOCOL.md
CHANGED
|
@@ -1,75 +1,55 @@
|
|
|
1
1
|
# Cashlet DID Protocol Specification
|
|
2
2
|
|
|
3
|
-
Version: `
|
|
3
|
+
Version: `3.0.0`
|
|
4
4
|
Status: Draft
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
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 | `
|
|
18
|
-
| Binding event kind | `30078`
|
|
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.
|
|
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
|
-
|
|
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-
|
|
41
|
-
"version": "
|
|
34
|
+
"purpose": "identity-binding-v3",
|
|
35
|
+
"version": "3.0.0"
|
|
42
36
|
}
|
|
43
37
|
```
|
|
44
38
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
`testnet
|
|
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
|
|
44
|
+
The exact UTF-8 message supplied to the BCH wallet is:
|
|
52
45
|
|
|
53
46
|
```text
|
|
54
|
-
cashlet-did/bch-proof/
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
81
|
-
`
|
|
82
|
-
|
|
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
|
-
|
|
65
|
+
## 3. Binding Nostr event
|
|
85
66
|
|
|
86
|
-
|
|
87
|
-
|
|
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", "
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
96
|
+
tags. Filter revoked records before selecting the newest binding.
|
|
144
97
|
|
|
145
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
163
|
-
fallbacks.
|
|
110
|
+
V1 and v2 bindings MUST NOT be accepted as fallbacks.
|
|
164
111
|
|
|
165
|
-
##
|
|
112
|
+
## 5. Cross-key rotation
|
|
166
113
|
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
128
|
+
## 6. Dual-key challenge-response authentication
|
|
129
|
+
|
|
130
|
+
The BCH-signed payload is either:
|
|
174
131
|
|
|
175
132
|
```text
|
|
176
|
-
cashlet-did/challenge/
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
##
|
|
150
|
+
## 7. NIP-05 and NIP-98
|
|
197
151
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
##
|
|
163
|
+
## 8. BCH transaction anchor
|
|
210
164
|
|
|
211
|
-
|
|
165
|
+
The OP_RETURN output commits to:
|
|
212
166
|
|
|
213
167
|
```text
|
|
214
168
|
OP_RETURN <"cashlet-did:" + bindingId>
|
|
215
169
|
```
|
|
216
170
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
extract `pubkey` → resolve the v2 binding via §5.
|
|
186
|
+
## 9. Other helpers
|
|
226
187
|
|
|
227
|
-
|
|
188
|
+
Selective disclosure commits each attribute using an independent random
|
|
189
|
+
32-byte salt:
|
|
228
190
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
241
|
-
reject the credential once that Unix timestamp has passed. A
|
|
242
|
-
effective only when a valid kind `5` event from the
|
|
243
|
-
references that credential event
|
|
244
|
-
|
|
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
|
|
8
|
-
do not accept v1 bindings; existing identities must be rebound and
|
|
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.
|
|
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** —
|
|
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
|
|
|
@@ -110,35 +111,53 @@ const social = deriveNostrKeypair(mnemonic, 1)
|
|
|
110
111
|
### Challenge-response authentication
|
|
111
112
|
|
|
112
113
|
```ts
|
|
113
|
-
import {
|
|
114
|
+
import {
|
|
115
|
+
bchChallengePayload, nostrChallengeTemplate, verifyDualChallengeProof,
|
|
116
|
+
} from 'did-beta-bch'
|
|
114
117
|
|
|
115
118
|
const payload = bchChallengePayload(nonce, audience)
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
},
|
|
123
133
|
)
|
|
124
134
|
```
|
|
125
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
|
+
|
|
126
139
|
### Key rotation
|
|
127
140
|
|
|
128
141
|
```ts
|
|
129
|
-
import {
|
|
142
|
+
import {
|
|
143
|
+
buildBinding, rotationAuthorizationTemplate, nostrEventTemplate,
|
|
144
|
+
resolveIdentityChain,
|
|
145
|
+
} from 'did-beta-bch'
|
|
130
146
|
|
|
131
|
-
//
|
|
147
|
+
// For a changed Nostr key, have the old key sign this authorization event.
|
|
132
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)
|
|
133
153
|
|
|
134
154
|
// Resolve linked, individually verified binding events, newest first
|
|
135
155
|
const chain = await resolveIdentityChain('npub1...')
|
|
136
156
|
```
|
|
137
157
|
|
|
138
|
-
|
|
139
|
-
present, unrevoked, and valid.
|
|
140
|
-
|
|
141
|
-
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.
|
|
142
161
|
|
|
143
162
|
### Verifiable credentials + reputation
|
|
144
163
|
|
|
@@ -178,16 +197,26 @@ commitments or attach them to a binding event automatically.
|
|
|
178
197
|
### BCH-anchored identity commitment
|
|
179
198
|
|
|
180
199
|
```ts
|
|
181
|
-
import {
|
|
200
|
+
import {
|
|
201
|
+
buildAnchorTx, verifyBchAnchorProof,
|
|
202
|
+
BCH_MAINNET_CHECKPOINT, BCH_MAINNET_ASERT_PARAMETERS,
|
|
203
|
+
} from 'did-beta-bch'
|
|
182
204
|
|
|
183
205
|
// Embed bindingId in an OP_RETURN transaction for on-chain commitment
|
|
184
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
|
+
})
|
|
185
213
|
```
|
|
186
214
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
191
220
|
|
|
192
221
|
### NIP-05 handle resolution
|
|
193
222
|
|
|
@@ -246,17 +275,15 @@ Bindings are published as Nostr events of kind `30078` (NIP-78 addressable data)
|
|
|
246
275
|
| `supersedes` | previous binding ID (key rotation) |
|
|
247
276
|
| `t` | `cashlet-did` |
|
|
248
277
|
|
|
249
|
-
The wallet receives the exact UTF-8
|
|
278
|
+
The wallet receives the exact UTF-8 v3 message text and applies the Bitcoin
|
|
250
279
|
Signed Message prefix, CompactSize byte length, and double-SHA256. Its response
|
|
251
280
|
is standard padded Base64 for a 65-byte compact signature: header 31–34
|
|
252
|
-
(compressed key plus recovery ID 0–3), followed by 64-byte `r || s`.
|
|
253
|
-
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
|
|
254
283
|
NIP-09 kind `5` deletion events.
|
|
255
284
|
|
|
256
285
|
The local demo previews and verifies a binding but does **not** publish or query
|
|
257
|
-
Nostr relays
|
|
258
|
-
verification, and replay rejection locally; `/resolve` needs a published
|
|
259
|
-
binding and relay access.
|
|
286
|
+
Nostr relays; `/resolve` needs a published binding and relay access.
|
|
260
287
|
|
|
261
288
|
## Relays
|
|
262
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 = "
|
|
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-
|
|
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
|
|
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 = "
|
|
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-
|
|
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
|
|
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-
|
|
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) ||
|
|
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": "
|
|
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",
|
|
@@ -49,4 +49,4 @@
|
|
|
49
49
|
"esbuild": "^0.28.2",
|
|
50
50
|
"typescript": "^5.4.0"
|
|
51
51
|
}
|
|
52
|
-
}
|
|
52
|
+
}
|