@smartledger/bsv 8.2.0 → 8.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/README.md +38 -38
- package/bsv-ecies.min.js +1 -1
- package/bsv-gdaf.min.js +62 -62
- package/bsv-ltp.min.js +48 -48
- package/bsv-smartcontract.min.js +1 -1
- package/bsv.bundle.js +62 -62
- package/bsv.min.js +62 -62
- package/docs/AUDIT_SCOPE.md +8 -8
- package/docs/BRC220_ENCODING_AMENDMENT.md +100 -0
- package/docs/BRC220_PLAN.md +224 -0
- package/docs/MODULE_REFERENCE_COMPLETE.md +27 -27
- package/docs/advanced/UTXO_MANAGER_GUIDE.md +1 -1
- package/docs/getting-started/INSTALLATION.md +23 -23
- package/docs/getting-started/QUICK_START.md +7 -7
- package/docs/migration/FROM_BSV_1_5_6.md +5 -5
- package/index.js +5 -0
- package/index.mjs +1 -0
- package/lib/gdaf/attestation-signer.js +2 -38
- package/lib/notaryhash/certificate.js +282 -0
- package/lib/notaryhash/encoding.js +150 -0
- package/lib/notaryhash/index.js +334 -0
- package/lib/notaryhash/merkle.js +209 -0
- package/lib/notaryhash/script.js +261 -0
- package/lib/notaryhash/suites.js +156 -0
- package/lib/util/jcs.js +75 -0
- package/package.json +7 -5
- package/test/notaryhash/certificate.js +249 -0
- package/test/notaryhash/encoding.js +186 -0
- package/test/notaryhash/merkle.js +181 -0
- package/test/notaryhash/script.js +270 -0
- package/test/notaryhash/verify.js +339 -0
- package/version.js +1 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* global describe, it */
|
|
4
|
+
|
|
5
|
+
// BRC-220 on-chain record. As with the encoding module, the property that matters is that
|
|
6
|
+
// another implementation reading the spec produces and accepts the same bytes — so the
|
|
7
|
+
// tests assert the push layout the spec dictates rather than only round-tripping our own
|
|
8
|
+
// output, which would pass even if every field were in the wrong place.
|
|
9
|
+
|
|
10
|
+
require('chai').should()
|
|
11
|
+
var bsv = require('../..')
|
|
12
|
+
var NS = require('../../lib/notaryhash/script')
|
|
13
|
+
var Hash = require('../../lib/crypto/hash')
|
|
14
|
+
var Opcode = require('../../lib/opcode')
|
|
15
|
+
|
|
16
|
+
var FULL = {
|
|
17
|
+
mode: NS.MODE.FULL,
|
|
18
|
+
algorithm: 'ECDSA-secp256k1',
|
|
19
|
+
hashAlgorithm: 'SHA-256',
|
|
20
|
+
payloadHash: Buffer.alloc(32, 0x11),
|
|
21
|
+
proofHash: Buffer.alloc(32, 0x22),
|
|
22
|
+
publicKey: Buffer.alloc(33, 0x02),
|
|
23
|
+
signature: Buffer.alloc(64, 0x30)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The spec counts PUSH indices, which start after OP_FALSE OP_RETURN. Script chunks
|
|
27
|
+
// include those two opcodes, so push N is chunk N+2.
|
|
28
|
+
function pushes (script) {
|
|
29
|
+
return script.chunks.slice(2)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('BRC-220 on-chain record', function () {
|
|
33
|
+
describe('push layout', function () {
|
|
34
|
+
it('is OP_FALSE OP_RETURN followed by the pushes', function () {
|
|
35
|
+
var s = NS.build(FULL)
|
|
36
|
+
s.chunks[0].opcodenum.should.equal(Opcode.OP_FALSE)
|
|
37
|
+
s.chunks[1].opcodenum.should.equal(Opcode.OP_RETURN)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('puts the prefix at push index 0, as 10 ASCII bytes', function () {
|
|
41
|
+
var p = pushes(NS.build(FULL))
|
|
42
|
+
p[0].buf.toString('ascii').should.equal('NOTARYHASH')
|
|
43
|
+
p[0].buf.length.should.equal(10)
|
|
44
|
+
p[0].buf.toString('hex').should.equal('4e4f5441525948415348')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// The spec discriminates on "the mode/kind byte at push index 2". If this drifts,
|
|
48
|
+
// every other implementation reads the wrong field.
|
|
49
|
+
it('puts the version at push index 1 and the mode at push index 2', function () {
|
|
50
|
+
var p = pushes(NS.build(FULL))
|
|
51
|
+
p[1].buf.length.should.equal(1)
|
|
52
|
+
p[1].buf[0].should.equal(1)
|
|
53
|
+
p[2].buf.length.should.equal(1)
|
|
54
|
+
p[2].buf[0].should.equal(NS.MODE.FULL)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('orders full-mode fields as the spec lists them', function () {
|
|
58
|
+
var p = pushes(NS.build(FULL))
|
|
59
|
+
p.length.should.equal(9)
|
|
60
|
+
p[3].buf.toString('utf8').should.equal('ECDSA-secp256k1')
|
|
61
|
+
p[4].buf.toString('utf8').should.equal('SHA-256')
|
|
62
|
+
p[5].buf.toString('hex').should.equal(FULL.payloadHash.toString('hex'))
|
|
63
|
+
p[6].buf.toString('hex').should.equal(FULL.proofHash.toString('hex'))
|
|
64
|
+
p[7].buf.toString('hex').should.equal(FULL.publicKey.toString('hex'))
|
|
65
|
+
p[8].buf.toString('hex').should.equal(FULL.signature.toString('hex'))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('lays batch mode out as root then u32be count', function () {
|
|
69
|
+
var p = pushes(NS.build({ mode: NS.MODE.BATCH, merkleRoot: Buffer.alloc(32, 0xaa), leafCount: 12345 }))
|
|
70
|
+
p.length.should.equal(5)
|
|
71
|
+
p[2].buf[0].should.equal(NS.MODE.BATCH)
|
|
72
|
+
p[3].buf.length.should.equal(32)
|
|
73
|
+
p[4].buf.length.should.equal(4)
|
|
74
|
+
p[4].buf.readUInt32BE(0).should.equal(12345)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// The canonical proof bytes use lp() because they are one flat string; here the push
|
|
78
|
+
// opcode already carries the length. Applying lp() as well would double-encode.
|
|
79
|
+
it('does not length-prefix inside the pushes', function () {
|
|
80
|
+
var p = pushes(NS.build(FULL))
|
|
81
|
+
// A doubly-encoded payloadHash would be 4 + 32 bytes.
|
|
82
|
+
p[5].buf.length.should.equal(32)
|
|
83
|
+
p[3].buf.length.should.equal('ECDSA-secp256k1'.length)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('hybrid mode', function () {
|
|
88
|
+
it('puts SHA-256 of the key and signature on chain', function () {
|
|
89
|
+
var p = pushes(NS.build(Object.assign({}, FULL, { mode: NS.MODE.HYBRID })))
|
|
90
|
+
p[7].buf.toString('hex').should.equal(Hash.sha256(FULL.publicKey).toString('hex'))
|
|
91
|
+
p[8].buf.toString('hex').should.equal(Hash.sha256(FULL.signature).toString('hex'))
|
|
92
|
+
p[7].buf.length.should.equal(32)
|
|
93
|
+
p[8].buf.length.should.equal(32)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// The caller always supplies the FULL key and signature; hashing is this module's job.
|
|
97
|
+
// Accepting a pre-hashed blob would produce a record that looks right and cannot be
|
|
98
|
+
// reconciled with the certificate, and nothing downstream would notice.
|
|
99
|
+
it('hashes internally rather than trusting a pre-hashed input', function () {
|
|
100
|
+
var preHashed = Object.assign({}, FULL, {
|
|
101
|
+
mode: NS.MODE.HYBRID,
|
|
102
|
+
publicKey: Hash.sha256(FULL.publicKey)
|
|
103
|
+
})
|
|
104
|
+
var p = pushes(NS.build(preHashed))
|
|
105
|
+
// Hashed again, so it does NOT equal the value that was passed in.
|
|
106
|
+
p[7].buf.toString('hex').should.not.equal(Hash.sha256(FULL.publicKey).toString('hex'))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('parses to hashes, never to raw blobs', function () {
|
|
110
|
+
var parsed = NS.parse(NS.build(Object.assign({}, FULL, { mode: NS.MODE.HYBRID })))
|
|
111
|
+
parsed.publicKeyHash.toString('hex').should.equal(Hash.sha256(FULL.publicKey).toString('hex'))
|
|
112
|
+
;(parsed.publicKey === undefined).should.equal(true)
|
|
113
|
+
;(parsed.signature === undefined).should.equal(true)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('round trip', function () {
|
|
118
|
+
it('preserves every full-mode field', function () {
|
|
119
|
+
var p = NS.parse(NS.build(FULL))
|
|
120
|
+
p.mode.should.equal(NS.MODE.FULL)
|
|
121
|
+
p.version.should.equal(1)
|
|
122
|
+
p.algorithm.should.equal(FULL.algorithm)
|
|
123
|
+
p.hashAlgorithm.should.equal(FULL.hashAlgorithm)
|
|
124
|
+
p.payloadHash.toString('hex').should.equal(FULL.payloadHash.toString('hex'))
|
|
125
|
+
p.proofHash.toString('hex').should.equal(FULL.proofHash.toString('hex'))
|
|
126
|
+
p.publicKey.toString('hex').should.equal(FULL.publicKey.toString('hex'))
|
|
127
|
+
p.signature.toString('hex').should.equal(FULL.signature.toString('hex'))
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('preserves batch fields', function () {
|
|
131
|
+
var p = NS.parse(NS.build({ mode: NS.MODE.BATCH, merkleRoot: Buffer.alloc(32, 0xbb), leafCount: 1 }))
|
|
132
|
+
p.merkleRoot.toString('hex').should.equal(Buffer.alloc(32, 0xbb).toString('hex'))
|
|
133
|
+
p.leafCount.should.equal(1)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('round-trips through hex', function () {
|
|
137
|
+
var hex = NS.build(FULL).toHex()
|
|
138
|
+
NS.parse(hex).algorithm.should.equal(FULL.algorithm)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('carries a post-quantum-sized signature in full mode', function () {
|
|
142
|
+
var big = Object.assign({}, FULL, { signature: Buffer.alloc(7856, 0x5a) })
|
|
143
|
+
NS.parse(NS.build(big)).signature.length.should.equal(7856)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('parser rejects', function () {
|
|
148
|
+
function corrupt (fn) {
|
|
149
|
+
var s = NS.build(FULL)
|
|
150
|
+
fn(s)
|
|
151
|
+
return s
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Long enough to pass the length check, so the OP_FALSE check is what rejects it.
|
|
155
|
+
it('a record that does not open with OP_FALSE', function () {
|
|
156
|
+
var s = corrupt(function (s) { s.chunks[0] = { opcodenum: Opcode.OP_1 } })
|
|
157
|
+
;(function () { NS.parse(s) }).should.throw(/OP_FALSE/)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('a record with no OP_RETURN', function () {
|
|
161
|
+
var s = corrupt(function (s) { s.chunks[1] = { opcodenum: Opcode.OP_1 } })
|
|
162
|
+
;(function () { NS.parse(s) }).should.throw(/OP_RETURN/)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('a foreign protocol prefix', function () {
|
|
166
|
+
var s = corrupt(function (s) { s.chunks[2].buf = Buffer.from('SOMETHINGELSE', 'ascii') })
|
|
167
|
+
;(function () { NS.parse(s) }).should.throw(/prefix/)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('an unsupported version', function () {
|
|
171
|
+
var s = corrupt(function (s) { s.chunks[3].buf = Buffer.from([9]) })
|
|
172
|
+
;(function () { NS.parse(s) }).should.throw(/version: 9/)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('an unknown mode', function () {
|
|
176
|
+
var s = corrupt(function (s) { s.chunks[4].buf = Buffer.from([7]) })
|
|
177
|
+
;(function () { NS.parse(s) }).should.throw(/unknown NotaryHash mode: 7/)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('the wrong number of pushes', function () {
|
|
181
|
+
var s = corrupt(function (s) { s.chunks.pop() })
|
|
182
|
+
;(function () { NS.parse(s) }).should.throw(/exactly 11 pushes/)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('a payloadHash that is not 32 bytes', function () {
|
|
186
|
+
var s = corrupt(function (s) { s.chunks[7].buf = Buffer.alloc(31, 1) })
|
|
187
|
+
;(function () { NS.parse(s) }).should.throw(/payloadHash must be exactly 32/)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('a leafCount that is not 4 bytes', function () {
|
|
191
|
+
var s = NS.build({ mode: NS.MODE.BATCH, merkleRoot: Buffer.alloc(32, 1), leafCount: 5 })
|
|
192
|
+
s.chunks[6].buf = Buffer.alloc(3, 0)
|
|
193
|
+
;(function () { NS.parse(s) }).should.throw(/leafCount must be 4 bytes/)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('too few pushes to be a record at all', function () {
|
|
197
|
+
;(function () { NS.parse(new bsv.Script().add(Opcode.OP_FALSE).add(Opcode.OP_RETURN)) })
|
|
198
|
+
.should.throw(/too few pushes/)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('builder rejects', function () {
|
|
203
|
+
it('an unknown mode', function () {
|
|
204
|
+
;(function () { NS.build(Object.assign({}, FULL, { mode: 5 })) }).should.throw(/mode must be/)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('hex where raw bytes belong', function () {
|
|
208
|
+
;(function () {
|
|
209
|
+
NS.build(Object.assign({}, FULL, { payloadHash: FULL.payloadHash.toString('hex') }))
|
|
210
|
+
}).should.throw(/must be a Buffer/)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('a payloadHash of the wrong length', function () {
|
|
214
|
+
;(function () {
|
|
215
|
+
NS.build(Object.assign({}, FULL, { payloadHash: Buffer.alloc(31) }))
|
|
216
|
+
}).should.throw(/exactly 32 bytes/)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('a non-integer leafCount', function () {
|
|
220
|
+
;(function () {
|
|
221
|
+
NS.build({ mode: NS.MODE.BATCH, merkleRoot: Buffer.alloc(32), leafCount: 1.5 })
|
|
222
|
+
}).should.throw(/leafCount/)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// A builder that minimally encodes its pushes represents u8(1) as OP_1. Those records
|
|
227
|
+
// are conformant, and rejecting them would be an interop failure rather than a safety
|
|
228
|
+
// measure — the mode byte is a routing hint, and every field that matters is checked
|
|
229
|
+
// on its own terms.
|
|
230
|
+
describe('interop with minimally-encoded pushes', function () {
|
|
231
|
+
it('reads OP_1 / OP_0 as the version and mode bytes', function () {
|
|
232
|
+
var s = NS.build(FULL)
|
|
233
|
+
s.chunks[3] = { opcodenum: Opcode.OP_1 }
|
|
234
|
+
s.chunks[4] = { opcodenum: Opcode.OP_0 }
|
|
235
|
+
var p = NS.parse(s)
|
|
236
|
+
p.version.should.equal(1)
|
|
237
|
+
p.mode.should.equal(NS.MODE.FULL)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('reads OP_2 as batch mode', function () {
|
|
241
|
+
var s = NS.build({ mode: NS.MODE.BATCH, merkleRoot: Buffer.alloc(32, 3), leafCount: 2 })
|
|
242
|
+
s.chunks[4] = { opcodenum: Opcode.OP_2 }
|
|
243
|
+
NS.parse(s).mode.should.equal(NS.MODE.BATCH)
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('isNotaryHash', function () {
|
|
248
|
+
it('accepts a record of each mode', function () {
|
|
249
|
+
NS.isNotaryHash(NS.build(FULL)).should.equal(true)
|
|
250
|
+
NS.isNotaryHash(NS.build(Object.assign({}, FULL, { mode: NS.MODE.HYBRID }))).should.equal(true)
|
|
251
|
+
NS.isNotaryHash(NS.build({ mode: NS.MODE.BATCH, merkleRoot: Buffer.alloc(32), leafCount: 0 }))
|
|
252
|
+
.should.equal(true)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('rejects an unrelated output without throwing', function () {
|
|
256
|
+
NS.isNotaryHash(bsv.Script.buildPublicKeyHashOut(bsv.PrivateKey.fromRandom().toAddress()))
|
|
257
|
+
.should.equal(false)
|
|
258
|
+
NS.isNotaryHash('not-hex-at-all').should.equal(false)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// It is a scanning filter, not a validator. Saying so in a test stops it being used
|
|
262
|
+
// as one.
|
|
263
|
+
it('is only a filter — a truncated record still passes it but fails parse()', function () {
|
|
264
|
+
var s = NS.build(FULL)
|
|
265
|
+
s.chunks = s.chunks.slice(0, 6)
|
|
266
|
+
NS.isNotaryHash(s).should.equal(true)
|
|
267
|
+
;(function () { NS.parse(s) }).should.throw()
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
})
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* global describe, it, before */
|
|
4
|
+
|
|
5
|
+
// BRC-220 verification: the three checks the spec requires, wired together.
|
|
6
|
+
//
|
|
7
|
+
// The plan's stance is that each check must have a test that defeats it IN ISOLATION —
|
|
8
|
+
// a suite where only the conjunction is tested cannot tell you that check 3 was never
|
|
9
|
+
// wired up, because checks 1 and 2 carry the verdict. Each of the three below is broken
|
|
10
|
+
// on its own with the others left intact.
|
|
11
|
+
|
|
12
|
+
require('chai').should()
|
|
13
|
+
var bsv = require('../..')
|
|
14
|
+
var NH = require('../../lib/notaryhash')
|
|
15
|
+
var Encoding = require('../../lib/notaryhash/encoding')
|
|
16
|
+
var Hash = require('../../lib/crypto/hash')
|
|
17
|
+
var BN = require('../../lib/crypto/bn')
|
|
18
|
+
|
|
19
|
+
// The Bitcoin genesis coinbase — one of the reference vectors the spec names, and the
|
|
20
|
+
// only check here that is external to this implementation.
|
|
21
|
+
var GENESIS_COINBASE_RAWTX =
|
|
22
|
+
'01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff' +
|
|
23
|
+
'4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72' +
|
|
24
|
+
'206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff' +
|
|
25
|
+
'0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f' +
|
|
26
|
+
'61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000'
|
|
27
|
+
var GENESIS_COINBASE_TXID = '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b'
|
|
28
|
+
|
|
29
|
+
function rawSig (sig) {
|
|
30
|
+
return Buffer.concat([
|
|
31
|
+
sig.r.toArrayLike(Buffer, 'be', 32),
|
|
32
|
+
sig.s.toArrayLike(Buffer, 'be', 32)
|
|
33
|
+
])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('BRC-220 verification', function () {
|
|
37
|
+
var key, publicKey, payloadHash, signature, certificate, header, rawTx
|
|
38
|
+
|
|
39
|
+
before(function () {
|
|
40
|
+
key = bsv.PrivateKey.fromRandom()
|
|
41
|
+
publicKey = key.toPublicKey().toBuffer()
|
|
42
|
+
payloadHash = Hash.sha256(Buffer.from('the document nobody sees'))
|
|
43
|
+
|
|
44
|
+
// The signer signs the 32-byte payloadHash DIRECTLY — spec §Algorithms.
|
|
45
|
+
var ecdsa = bsv.crypto.ECDSA().set({ hashbuf: payloadHash, endian: 'little', privkey: key })
|
|
46
|
+
ecdsa.sign()
|
|
47
|
+
signature = rawSig(ecdsa.sig)
|
|
48
|
+
|
|
49
|
+
var createdAt = '2026-08-16T00:00:00.000Z'
|
|
50
|
+
var proofHash = Encoding.proofHash({
|
|
51
|
+
algorithm: 'ECDSA-secp256k1',
|
|
52
|
+
hashAlgorithm: 'SHA-256',
|
|
53
|
+
payloadHash: payloadHash,
|
|
54
|
+
publicKey: publicKey,
|
|
55
|
+
signature: signature,
|
|
56
|
+
createdAtUnix: Encoding.toUnixSeconds(createdAt)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// A real transaction carrying the record, so the anchor check has something to read.
|
|
60
|
+
var tx = new bsv.Transaction()
|
|
61
|
+
tx.addOutput(new bsv.Transaction.Output({
|
|
62
|
+
script: NH.Script.build({
|
|
63
|
+
mode: NH.MODE.FULL,
|
|
64
|
+
algorithm: 'ECDSA-secp256k1',
|
|
65
|
+
hashAlgorithm: 'SHA-256',
|
|
66
|
+
payloadHash: payloadHash,
|
|
67
|
+
proofHash: proofHash,
|
|
68
|
+
publicKey: publicKey,
|
|
69
|
+
signature: signature
|
|
70
|
+
}),
|
|
71
|
+
satoshis: 0
|
|
72
|
+
}))
|
|
73
|
+
rawTx = tx.toString()
|
|
74
|
+
var txid = NH.txidFromRawTx(rawTx)
|
|
75
|
+
|
|
76
|
+
// A single-transaction block: the Merkle root is the txid itself, so an empty path
|
|
77
|
+
// folds to it. Proof of work is not required for these fixtures.
|
|
78
|
+
header = new bsv.BlockHeader({
|
|
79
|
+
version: 1,
|
|
80
|
+
prevHash: Buffer.alloc(32),
|
|
81
|
+
merkleRoot: Buffer.from(txid, 'hex').reverse(),
|
|
82
|
+
time: 1786838400,
|
|
83
|
+
bits: 0x1d00ffff,
|
|
84
|
+
nonce: 0
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
certificate = NH.Certificate.attachSPV(
|
|
88
|
+
NH.Certificate.build({
|
|
89
|
+
mode: NH.MODE.FULL,
|
|
90
|
+
algorithm: 'ECDSA-secp256k1',
|
|
91
|
+
hashAlgorithm: 'SHA-256',
|
|
92
|
+
payloadHash: payloadHash,
|
|
93
|
+
publicKey: publicKey,
|
|
94
|
+
signature: signature,
|
|
95
|
+
createdAt: createdAt,
|
|
96
|
+
anchor: { txid: txid, blockHeight: 800000 }
|
|
97
|
+
}),
|
|
98
|
+
{ rawTx: rawTx, blockHash: header.id, blockHeight: 800000, merkleProof: { index: 0, nodes: [] } }
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
var OPTS = function () { return { header: header, requirePow: false } }
|
|
103
|
+
|
|
104
|
+
describe('the happy path', function () {
|
|
105
|
+
it('passes all three checks', function () {
|
|
106
|
+
var report = NH.verify(certificate, OPTS())
|
|
107
|
+
report.signature.should.equal(true)
|
|
108
|
+
report.proofIntegrity.should.equal(true)
|
|
109
|
+
report.anchor.should.equal(true, JSON.stringify(report.errors))
|
|
110
|
+
report.valid.should.equal(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('isValid returns a strict boolean', function () {
|
|
114
|
+
NH.isValid(certificate, OPTS()).should.equal(true)
|
|
115
|
+
NH.isValid(certificate, OPTS()).should.be.a('boolean')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Each check defeated on its own, with the other two left working.
|
|
120
|
+
describe('check 1 — signature, defeated in isolation', function () {
|
|
121
|
+
it('fails when the signature is forged, while proof integrity still passes', function () {
|
|
122
|
+
var forged = Object.assign({}, certificate, { signature: 'aa'.repeat(64) })
|
|
123
|
+
var report = NH.verify(forged, OPTS())
|
|
124
|
+
report.signature.should.equal(false)
|
|
125
|
+
report.valid.should.equal(false)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('fails when the certificate names a key that did not sign', function () {
|
|
129
|
+
var other = bsv.PrivateKey.fromRandom().toPublicKey().toBuffer()
|
|
130
|
+
var swapped = Object.assign({}, certificate, { publicKey: other.toString('hex') })
|
|
131
|
+
NH.verify(swapped, OPTS()).signature.should.equal(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// An unregistered algorithm must FAIL, never fall through to a default. Falling
|
|
135
|
+
// through to ECDSA for an ML-DSA certificate would be catastrophic.
|
|
136
|
+
it('fails for an algorithm no suite is registered for', function () {
|
|
137
|
+
var pq = Object.assign({}, certificate, { algorithm: 'ML-DSA-65' })
|
|
138
|
+
NH.verifySignature(pq).should.equal(false)
|
|
139
|
+
NH.Suites.verify('ML-DSA-65', Buffer.alloc(32), Buffer.alloc(64), Buffer.alloc(33))
|
|
140
|
+
.should.equal(false)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('check 2 — proof integrity, defeated in isolation', function () {
|
|
145
|
+
// The signature still verifies here: payloadHash, signature and publicKey are
|
|
146
|
+
// untouched. Only proofHash is a lie, and only check 2 can see it.
|
|
147
|
+
it('fails when proofHash is replaced, while the signature still verifies', function () {
|
|
148
|
+
var lying = Object.assign({}, certificate, { proofHash: '00'.repeat(32) })
|
|
149
|
+
var report = NH.verify(lying, OPTS())
|
|
150
|
+
report.signature.should.equal(true)
|
|
151
|
+
report.proofIntegrity.should.equal(false)
|
|
152
|
+
report.valid.should.equal(false)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('fails when createdAt is moved after issuance', function () {
|
|
156
|
+
var moved = Object.assign({}, certificate, { createdAt: '2026-08-17T00:00:00.000Z' })
|
|
157
|
+
NH.verify(moved, OPTS()).proofIntegrity.should.equal(false)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('check 3 — anchor, defeated in isolation', function () {
|
|
162
|
+
// Signature and proof integrity both still pass; only the anchor is wrong.
|
|
163
|
+
it('fails when rawTx does not hash to anchor.txid', function () {
|
|
164
|
+
var wrongTxid = Object.assign({}, certificate, {
|
|
165
|
+
anchor: { txid: 'ff'.repeat(32), blockHeight: 800000 }
|
|
166
|
+
})
|
|
167
|
+
var report = NH.verify(wrongTxid, OPTS())
|
|
168
|
+
report.signature.should.equal(true)
|
|
169
|
+
report.proofIntegrity.should.equal(true)
|
|
170
|
+
report.anchor.should.equal(false)
|
|
171
|
+
report.errors.should.include('rawTx does not hash to anchor.txid')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('fails when the merkle proof does not fold to the header root', function () {
|
|
175
|
+
var otherHeader = new bsv.BlockHeader({
|
|
176
|
+
version: 1,
|
|
177
|
+
prevHash: Buffer.alloc(32),
|
|
178
|
+
merkleRoot: Buffer.alloc(32, 0x77),
|
|
179
|
+
time: 1786838400,
|
|
180
|
+
bits: 0x1d00ffff,
|
|
181
|
+
nonce: 0
|
|
182
|
+
})
|
|
183
|
+
NH.verifyAnchorSPV(certificate, { header: otherHeader, requirePow: false })
|
|
184
|
+
.valid.should.equal(false)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('fails when the transaction carries no NotaryHash record', function () {
|
|
188
|
+
var empty = new bsv.Transaction()
|
|
189
|
+
empty.addOutput(new bsv.Transaction.Output({
|
|
190
|
+
script: bsv.Script.buildDataOut('nothing to see'), satoshis: 0
|
|
191
|
+
}))
|
|
192
|
+
var swapped = Object.assign({}, certificate, {
|
|
193
|
+
spv: Object.assign({}, certificate.spv, { rawTx: empty.toString() })
|
|
194
|
+
})
|
|
195
|
+
NH.verifyAnchorSPV(swapped, OPTS()).errors.join(' ').should.match(/no NotaryHash record|does not hash/)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// THE anchor check. The certificate is internally perfect and the transaction is
|
|
199
|
+
// real; it simply is not the transaction this certificate describes.
|
|
200
|
+
it('fails when the on-chain record describes a different payload', function () {
|
|
201
|
+
var otherPayload = Hash.sha256(Buffer.from('a different document'))
|
|
202
|
+
var tx = new bsv.Transaction()
|
|
203
|
+
tx.addOutput(new bsv.Transaction.Output({
|
|
204
|
+
script: NH.Script.build({
|
|
205
|
+
mode: NH.MODE.FULL,
|
|
206
|
+
algorithm: 'ECDSA-secp256k1',
|
|
207
|
+
hashAlgorithm: 'SHA-256',
|
|
208
|
+
payloadHash: otherPayload,
|
|
209
|
+
proofHash: Buffer.alloc(32, 0x01),
|
|
210
|
+
publicKey: Buffer.from(certificate.publicKey, 'hex'),
|
|
211
|
+
signature: Buffer.from(certificate.signature, 'hex')
|
|
212
|
+
}),
|
|
213
|
+
satoshis: 0
|
|
214
|
+
}))
|
|
215
|
+
var raw = tx.toString()
|
|
216
|
+
var txid = NH.txidFromRawTx(raw)
|
|
217
|
+
var h = new bsv.BlockHeader({
|
|
218
|
+
version: 1,
|
|
219
|
+
prevHash: Buffer.alloc(32),
|
|
220
|
+
merkleRoot: Buffer.from(txid, 'hex').reverse(),
|
|
221
|
+
time: 1786838400,
|
|
222
|
+
bits: 0x1d00ffff,
|
|
223
|
+
nonce: 0
|
|
224
|
+
})
|
|
225
|
+
var mismatched = Object.assign({}, certificate, {
|
|
226
|
+
anchor: { txid: txid, blockHeight: 800000 },
|
|
227
|
+
spv: Object.assign({}, certificate.spv, { rawTx: raw })
|
|
228
|
+
})
|
|
229
|
+
var report = NH.verifyAnchorSPV(mismatched, { header: h, requirePow: false })
|
|
230
|
+
report.valid.should.equal(false)
|
|
231
|
+
report.errors.should.include('on-chain record does not match the certificate')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// Not supplying a header is not a pass. This is the trust the spec exists to remove.
|
|
235
|
+
it('refuses to pass without a block header', function () {
|
|
236
|
+
var report = NH.verifyAnchorSPV(certificate, {})
|
|
237
|
+
report.valid.should.equal(false)
|
|
238
|
+
report.errors.join(' ').should.match(/block header is required/)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('reports a missing SPV envelope rather than ignoring the anchor', function () {
|
|
242
|
+
var noSpv = Object.assign({}, certificate)
|
|
243
|
+
delete noSpv.spv
|
|
244
|
+
NH.verifyAnchorSPV(noSpv, OPTS()).errors.should.include('certificate has no SPV envelope')
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe('reporting', function () {
|
|
249
|
+
it('names which check failed rather than saying only "invalid"', function () {
|
|
250
|
+
var forged = Object.assign({}, certificate, { signature: 'aa'.repeat(64) })
|
|
251
|
+
NH.verify(forged, OPTS()).errors.should.include('signature does not verify')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('reports shape problems before attempting the checks', function () {
|
|
255
|
+
var report = NH.verify({ protocol: 'NotaryHash' }, OPTS())
|
|
256
|
+
report.shape.length.should.be.above(0)
|
|
257
|
+
report.signature.should.equal(false)
|
|
258
|
+
report.valid.should.equal(false)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// skipAnchor is for offline triage and must never report a valid certificate.
|
|
262
|
+
it('never reports valid when the anchor was skipped', function () {
|
|
263
|
+
var report = NH.verify(certificate, { skipAnchor: true })
|
|
264
|
+
report.signature.should.equal(true)
|
|
265
|
+
report.proofIntegrity.should.equal(true)
|
|
266
|
+
report.valid.should.equal(false)
|
|
267
|
+
report.errors.join(' ').should.match(/NOT verified/)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('txidFromRawTx', function () {
|
|
272
|
+
// External check: the spec names this reference vector, and it is the one assertion
|
|
273
|
+
// here that does not depend on our own construction being right.
|
|
274
|
+
it('matches the Bitcoin genesis coinbase transaction', function () {
|
|
275
|
+
NH.txidFromRawTx(GENESIS_COINBASE_RAWTX).should.equal(GENESIS_COINBASE_TXID)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('accepts a Buffer as well as hex', function () {
|
|
279
|
+
NH.txidFromRawTx(Buffer.from(GENESIS_COINBASE_RAWTX, 'hex')).should.equal(GENESIS_COINBASE_TXID)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe('suite registry', function () {
|
|
284
|
+
it('registers a caller-supplied suite and uses it', function () {
|
|
285
|
+
var called = false
|
|
286
|
+
NH.registerSuite('TEST-SUITE', {
|
|
287
|
+
verify: function () { called = true; return true }
|
|
288
|
+
})
|
|
289
|
+
NH.Suites.verify('TEST-SUITE', Buffer.alloc(32), Buffer.alloc(8), Buffer.alloc(8))
|
|
290
|
+
.should.equal(true)
|
|
291
|
+
called.should.equal(true)
|
|
292
|
+
NH.Suites.unregister('TEST-SUITE')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// A suite returning a truthy object must not smuggle a pass through — that is the
|
|
296
|
+
// defect class this codebase has fixed most often.
|
|
297
|
+
it('coerces a truthy non-boolean suite result to false', function () {
|
|
298
|
+
NH.registerSuite('TRUTHY-SUITE', { verify: function () { return { ok: true } } })
|
|
299
|
+
NH.Suites.verify('TRUTHY-SUITE', Buffer.alloc(32), Buffer.alloc(8), Buffer.alloc(8))
|
|
300
|
+
.should.equal(false)
|
|
301
|
+
NH.Suites.unregister('TRUTHY-SUITE')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('survives a throwing suite without reporting a pass', function () {
|
|
305
|
+
NH.registerSuite('THROWS', { verify: function () { throw new Error('boom') } })
|
|
306
|
+
NH.Suites.verify('THROWS', Buffer.alloc(32), Buffer.alloc(8), Buffer.alloc(8))
|
|
307
|
+
.should.equal(false)
|
|
308
|
+
NH.Suites.unregister('THROWS')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('ships only ECDSA-secp256k1 by default', function () {
|
|
312
|
+
NH.Suites.list().should.deep.equal(['ECDSA-secp256k1'])
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe('ECDSA suite specifics', function () {
|
|
317
|
+
it('requires low-S, rejecting the malleated form', function () {
|
|
318
|
+
var sig = bsv.crypto.Signature.fromDER(
|
|
319
|
+
new bsv.crypto.Signature(
|
|
320
|
+
BN.fromBuffer(Buffer.from(certificate.signature, 'hex').slice(0, 32)),
|
|
321
|
+
BN.fromBuffer(Buffer.from(certificate.signature, 'hex').slice(32))
|
|
322
|
+
).toDER()
|
|
323
|
+
)
|
|
324
|
+
var high = Buffer.concat([
|
|
325
|
+
sig.r.toArrayLike(Buffer, 'be', 32),
|
|
326
|
+
bsv.crypto.Point.getN().sub(sig.s).toArrayLike(Buffer, 'be', 32)
|
|
327
|
+
])
|
|
328
|
+
NH.Suites.verify('ECDSA-secp256k1', payloadHash, high, publicKey).should.equal(false)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('accepts DER for the legacy encoding', function () {
|
|
332
|
+
var raw = Buffer.from(certificate.signature, 'hex')
|
|
333
|
+
var der = new bsv.crypto.Signature(
|
|
334
|
+
BN.fromBuffer(raw.slice(0, 32)), BN.fromBuffer(raw.slice(32))
|
|
335
|
+
).toDER()
|
|
336
|
+
NH.Suites.verify('ECDSA-secp256k1', payloadHash, der, publicKey).should.equal(true)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
})
|
package/version.js
CHANGED