@smartledger/bsv 5.5.3 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -0
- package/README.md +38 -38
- package/bin/cli.js +35 -5
- package/bsv-covenant.min.js +6 -6
- package/bsv-gdaf.min.js +2 -2
- package/bsv-ltp.min.js +2 -2
- package/bsv-shamir.min.js +1 -1
- package/bsv-smartcontract.min.js +6 -6
- package/bsv-statuslist.min.js +5 -5
- package/bsv-vcjwt.min.js +1 -1
- package/bsv.bundle.js +2 -2
- package/bsv.d.ts +289 -10
- package/bsv.min.js +2 -2
- 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 +4 -0
- package/lib/crypto/ecdsa.js +13 -0
- package/lib/crypto/shamir.js +16 -4
- package/lib/gdaf/did-resolver.js +3 -1
- package/lib/gdaf/smartledger-anchor.js +154 -12
- package/lib/ltp/anchor.js +16 -3
- package/lib/ltp/obligation.js +56 -8
- package/lib/ltp/proof.js +4 -2
- package/lib/ltp/right.js +86 -11
- package/lib/ordinals/README.md +170 -0
- package/lib/ordinals/bsv20.js +185 -0
- package/lib/ordinals/index.js +49 -0
- package/lib/ordinals/inscription.js +159 -0
- package/lib/ordinals/ordlock.js +560 -0
- package/lib/smart_contract/builder.js +27 -3
- package/lib/smart_contract/covenant.js +37 -10
- package/lib/smart_contract/covenant_builder.js +7 -5
- package/lib/smart_contract/index.js +28 -15
- package/lib/smart_contract/preimage.js +7 -7
- package/lib/smart_contract/pushtx.js +47 -7
- package/lib/smart_contract/token.js +69 -1
- package/lib/spv/headerchain.js +77 -0
- package/lib/spv/index.js +16 -0
- package/lib/spv/merkleproof.js +108 -0
- package/lib/statuslist/index.js +65 -24
- package/lib/vcjwt/index.js +6 -0
- package/package.json +1 -1
- package/test/credentials-test.js +22 -19
- package/test/crypto/shamir.js +9 -1
- package/test/crypto/shamir_rngcap.js +29 -0
- package/test/gdaf/anchor_spv.js +163 -0
- package/test/ltp/verify_failclosed.js +134 -0
- package/test/ordinals/bsv20.js +126 -0
- package/test/ordinals/inscription.js +71 -0
- package/test/ordinals/ordlock.js +552 -0
- package/test/smart_contract/extract_field.js +61 -0
- package/test/smart_contract/nonenforcing_guard.js +44 -0
- package/test/smart_contract/ordinal_transfer.js +83 -0
- package/test/smart_contract/sighash_marketplace.js +110 -0
- package/test/spv/headerchain.js +86 -0
- package/test/spv/merkleproof.js +133 -0
- package/test/statuslist/failclosed.js +78 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* global describe, it, before, after */
|
|
4
|
+
|
|
5
|
+
// Ordinal-safe transfer (Phase 1). A 1-sat ordinal held in a covenant must be
|
|
6
|
+
// transferred WITHOUT burning the inscribed satoshi. The single-output
|
|
7
|
+
// ownershipToken sets output = input - fee, so it destroys a 1-sat UTXO; these
|
|
8
|
+
// tests prove (a) the single-output path refuses a 1-sat UTXO, and (b)
|
|
9
|
+
// transferOrdinal recreates the token at exactly 1 sat among funding outputs and
|
|
10
|
+
// the covenant verifies.
|
|
11
|
+
|
|
12
|
+
require('chai').should()
|
|
13
|
+
var bsv = require('../..')
|
|
14
|
+
var SC = bsv.SmartContract
|
|
15
|
+
var PrivateKey = bsv.PrivateKey
|
|
16
|
+
var Transaction = bsv.Transaction
|
|
17
|
+
|
|
18
|
+
var Token = SC.Token
|
|
19
|
+
var help = SC.CovenantHelpers
|
|
20
|
+
var fundAndSpend = help.fundAndSpend
|
|
21
|
+
var p2pkhOutput = help.p2pkhOutput
|
|
22
|
+
var verify = help.verify
|
|
23
|
+
|
|
24
|
+
describe('Ordinal-safe transfer (1-sat, SIGHASH_ALL)', function () {
|
|
25
|
+
this.timeout(20000)
|
|
26
|
+
var I = bsv.Script.Interpreter
|
|
27
|
+
var saved
|
|
28
|
+
var alice = PrivateKey.fromRandom()
|
|
29
|
+
var bob = PrivateKey.fromRandom()
|
|
30
|
+
|
|
31
|
+
before(function () {
|
|
32
|
+
saved = { el: I.MAX_SCRIPT_ELEMENT_SIZE, num: I.MAXIMUM_ELEMENT_SIZE, ops: I.MAX_OPS_PER_SCRIPT }
|
|
33
|
+
SC.enableGenesis()
|
|
34
|
+
})
|
|
35
|
+
after(function () {
|
|
36
|
+
I.MAX_SCRIPT_ELEMENT_SIZE = saved.el
|
|
37
|
+
I.MAXIMUM_ELEMENT_SIZE = saved.num
|
|
38
|
+
I.MAX_OPS_PER_SCRIPT = saved.ops
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('transferOrdinal recreates the 1-sat token among funding outputs and verifies', function () {
|
|
42
|
+
var lock = Token.ordinalLock(Token.ownerId(alice))
|
|
43
|
+
var bobHash = Token.ownerId(bob)
|
|
44
|
+
var nextToken = Token.ordinalLock(bobHash)
|
|
45
|
+
|
|
46
|
+
// Ordinal output stays exactly 1 sat; a change output carries the funding.
|
|
47
|
+
var tokenOutput = new Transaction.Output({ script: nextToken, satoshis: 1 })
|
|
48
|
+
var change = p2pkhOutput(PrivateKey.fromRandom(), 5000)
|
|
49
|
+
|
|
50
|
+
// token output at index 0 (ordinal FIFO), change after.
|
|
51
|
+
var spend = fundAndSpend(lock, 1, { outputs: [tokenOutput, change] }).spend
|
|
52
|
+
spend.inputs[0].setScript(Token.transferOrdinal(alice, bobHash, spend, 1, lock, { tokenIndex: 0 }))
|
|
53
|
+
|
|
54
|
+
verify(spend.inputs[0].script, lock, { tx: spend, satoshis: 1 }).ok.should.equal(true)
|
|
55
|
+
// The recreated token output is still exactly 1 sat.
|
|
56
|
+
spend.outputs[0].satoshis.should.equal(1)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('guardrail: single-output unlockTransfer refuses a 1-sat UTXO', function () {
|
|
60
|
+
var lock = Token.ordinalLock(Token.ownerId(alice))
|
|
61
|
+
var bobHash = Token.ownerId(bob)
|
|
62
|
+
var next = Token.ownershipToken(500, bobHash)
|
|
63
|
+
var spend = fundAndSpend(lock, 1, {
|
|
64
|
+
outputs: [new Transaction.Output({ script: next, satoshis: 1 })]
|
|
65
|
+
}).spend
|
|
66
|
+
;(function () {
|
|
67
|
+
Token.unlockTransfer(alice, bobHash, spend, 1, lock)
|
|
68
|
+
}).should.throw(/burns|ordinal/)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('anti-burn: transferOrdinal rejects a token output not equal to the ordinal value', function () {
|
|
72
|
+
var lock = Token.ordinalLock(Token.ownerId(alice))
|
|
73
|
+
var bobHash = Token.ownerId(bob)
|
|
74
|
+
var nextToken = Token.ordinalLock(bobHash)
|
|
75
|
+
// Wrong: token output is 2 sat, not the ordinal's 1 sat.
|
|
76
|
+
var tokenOutput = new Transaction.Output({ script: nextToken, satoshis: 2 })
|
|
77
|
+
var change = p2pkhOutput(PrivateKey.fromRandom(), 5000)
|
|
78
|
+
var spend = fundAndSpend(lock, 1, { outputs: [tokenOutput, change] }).spend
|
|
79
|
+
;(function () {
|
|
80
|
+
Token.transferOrdinal(alice, bobHash, spend, 1, lock, { tokenIndex: 0 })
|
|
81
|
+
}).should.throw(/equal the ordinal|burn/)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* global describe, it, before, after */
|
|
4
|
+
|
|
5
|
+
// Configurable SIGHASH core (Phase 2). The OP_PUSH_TX core was hard-wired to
|
|
6
|
+
// SIGHASH_ALL|FORKID (0x41). It can now be built under any flag; these tests prove
|
|
7
|
+
// the marketplace / partially-signed pattern with SIGHASH_SINGLE|ANYONECANPAY|FORKID
|
|
8
|
+
// (0xc3): a seller commits only to its own input and its own output, and a buyer may
|
|
9
|
+
// add funding inputs and change outputs without invalidating the covenant — while
|
|
10
|
+
// the seller's OWN output stays bound.
|
|
11
|
+
|
|
12
|
+
require('chai').should()
|
|
13
|
+
var bsv = require('../..')
|
|
14
|
+
var SC = bsv.SmartContract
|
|
15
|
+
var PrivateKey = bsv.PrivateKey
|
|
16
|
+
var Script = bsv.Script
|
|
17
|
+
var Opcode = bsv.Opcode
|
|
18
|
+
var Transaction = bsv.Transaction
|
|
19
|
+
|
|
20
|
+
var P = SC.PushTx
|
|
21
|
+
var help = SC.CovenantHelpers
|
|
22
|
+
var fundAndSpend = help.fundAndSpend
|
|
23
|
+
var p2pkhOutput = help.p2pkhOutput
|
|
24
|
+
var verify = help.verify
|
|
25
|
+
|
|
26
|
+
var SATS = 100000
|
|
27
|
+
|
|
28
|
+
describe('Configurable SIGHASH — SINGLE|ANYONECANPAY marketplace covenant', function () {
|
|
29
|
+
this.timeout(20000)
|
|
30
|
+
var I = bsv.Script.Interpreter
|
|
31
|
+
var saved
|
|
32
|
+
var flag = P.SIGHASH_SINGLE_ANYONECANPAY_FORKID // 0xc3
|
|
33
|
+
|
|
34
|
+
before(function () {
|
|
35
|
+
saved = { el: I.MAX_SCRIPT_ELEMENT_SIZE, num: I.MAXIMUM_ELEMENT_SIZE, ops: I.MAX_OPS_PER_SCRIPT }
|
|
36
|
+
SC.enableGenesis()
|
|
37
|
+
})
|
|
38
|
+
after(function () {
|
|
39
|
+
I.MAX_SCRIPT_ELEMENT_SIZE = saved.el
|
|
40
|
+
I.MAXIMUM_ELEMENT_SIZE = saved.num
|
|
41
|
+
I.MAX_OPS_PER_SCRIPT = saved.ops
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Lock: authenticate the pushed preimage under `flag`, assert the flag, succeed.
|
|
45
|
+
function makeLock () {
|
|
46
|
+
var lock = new Script()
|
|
47
|
+
lock.add(Opcode.OP_DUP)
|
|
48
|
+
P.pushTxCore(lock, { sighashType: flag })
|
|
49
|
+
lock.add(Opcode.OP_VERIFY)
|
|
50
|
+
P.assertSighashType(lock, flag)
|
|
51
|
+
lock.add(Opcode.OP_DROP)
|
|
52
|
+
lock.add(Opcode.OP_TRUE)
|
|
53
|
+
return lock
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function grindUnlock (spend, lock) {
|
|
57
|
+
var g = P.grind(spend, 0, lock, SATS, { sighashType: flag })
|
|
58
|
+
spend.inputs[0].setScript(new Script().add(g.preimage))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it('verifies a SINGLE|ANYONECANPAY covenant spend', function () {
|
|
62
|
+
var lock = makeLock()
|
|
63
|
+
var sellerOut = p2pkhOutput(PrivateKey.fromRandom(), SATS - 500) // committed output (index 0)
|
|
64
|
+
var spend = fundAndSpend(lock, SATS, { outputs: [sellerOut] }).spend
|
|
65
|
+
grindUnlock(spend, lock)
|
|
66
|
+
verify(spend.inputs[0].script, lock, { tx: spend, satoshis: SATS }).ok.should.equal(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('stays valid when a buyer adds a funding input and a change output', function () {
|
|
70
|
+
var lock = makeLock()
|
|
71
|
+
var sellerOut = p2pkhOutput(PrivateKey.fromRandom(), SATS - 500)
|
|
72
|
+
var spend = fundAndSpend(lock, SATS, { outputs: [sellerOut] }).spend
|
|
73
|
+
grindUnlock(spend, lock) // seller signs input0 + output0 only
|
|
74
|
+
|
|
75
|
+
// Buyer adds a funding input and their own change output AFTER the seller signed.
|
|
76
|
+
spend.addInput(new Transaction.Input({
|
|
77
|
+
prevTxId: '11'.repeat(32), outputIndex: 0, script: Script.empty()
|
|
78
|
+
}), p2pkhOutput(PrivateKey.fromRandom(), 50000).script, 50000)
|
|
79
|
+
spend.addOutput(p2pkhOutput(PrivateKey.fromRandom(), 49000)) // buyer change at index 1
|
|
80
|
+
|
|
81
|
+
// The seller's covenant on input 0 is unaffected (ANYONECANPAY ignores the new
|
|
82
|
+
// input; SINGLE ignores the new output).
|
|
83
|
+
verify(spend.inputs[0].script, lock, { tx: spend, satoshis: SATS }).ok.should.equal(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('still binds the seller\'s OWN output (SINGLE commits output at the input index)', function () {
|
|
87
|
+
var lock = makeLock()
|
|
88
|
+
var sellerOut = p2pkhOutput(PrivateKey.fromRandom(), SATS - 500)
|
|
89
|
+
var spend = fundAndSpend(lock, SATS, { outputs: [sellerOut] }).spend
|
|
90
|
+
grindUnlock(spend, lock)
|
|
91
|
+
|
|
92
|
+
// Tamper the committed output (index 0) after signing → must fail.
|
|
93
|
+
spend.outputs[0] = p2pkhOutput(PrivateKey.fromRandom(), 1)
|
|
94
|
+
verify(spend.inputs[0].script, lock, { tx: spend, satoshis: SATS }).ok.should.equal(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('back-compat: default pushTxCore is still SIGHASH_ALL|FORKID', function () {
|
|
98
|
+
var lock = new Script()
|
|
99
|
+
lock.add(Opcode.OP_DUP)
|
|
100
|
+
P.pushTxCore(lock) // no opts → 0x41
|
|
101
|
+
lock.add(Opcode.OP_VERIFY)
|
|
102
|
+
P.assertSighashAll(lock)
|
|
103
|
+
lock.add(Opcode.OP_DROP).add(Opcode.OP_TRUE)
|
|
104
|
+
var out = p2pkhOutput(PrivateKey.fromRandom(), SATS - 500)
|
|
105
|
+
var spend = fundAndSpend(lock, SATS, { outputs: [out] }).spend
|
|
106
|
+
var g = P.grind(spend, 0, lock, SATS) // default sighash
|
|
107
|
+
spend.inputs[0].setScript(new Script().add(g.preimage))
|
|
108
|
+
verify(spend.inputs[0].script, lock, { tx: spend, satoshis: SATS }).ok.should.equal(true)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* global describe, it */
|
|
4
|
+
|
|
5
|
+
// SPV header-chain verification: linkage + per-header proof-of-work + optional
|
|
6
|
+
// trusted-hash anchoring. Uses the regtest max target (0x207fffff) so a header's
|
|
7
|
+
// PoW passes after trivial nonce grinding (no real mining needed).
|
|
8
|
+
|
|
9
|
+
require('chai').should()
|
|
10
|
+
var bsv = require('../..')
|
|
11
|
+
var SPV = bsv.SPV
|
|
12
|
+
|
|
13
|
+
function rand32 () { return bsv.crypto.Random.getRandomBuffer(32) }
|
|
14
|
+
|
|
15
|
+
// "Mine" a header at regtest difficulty: grind nonce until PoW passes (~1-2 tries).
|
|
16
|
+
function minedHeader (prevHashInternal, merkleInternal) {
|
|
17
|
+
for (var nonce = 0; nonce < 100000; nonce++) {
|
|
18
|
+
var h = new bsv.BlockHeader({
|
|
19
|
+
version: 1,
|
|
20
|
+
prevHash: prevHashInternal,
|
|
21
|
+
merkleRoot: merkleInternal,
|
|
22
|
+
time: 1231006505 + nonce,
|
|
23
|
+
bits: 0x207fffff,
|
|
24
|
+
nonce: nonce
|
|
25
|
+
})
|
|
26
|
+
if (h.validProofOfWork()) return h
|
|
27
|
+
}
|
|
28
|
+
throw new Error('could not mine a regtest header')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function chain (n) {
|
|
32
|
+
var hs = []
|
|
33
|
+
var prev = Buffer.alloc(32)
|
|
34
|
+
for (var i = 0; i < n; i++) {
|
|
35
|
+
var h = minedHeader(prev, rand32())
|
|
36
|
+
hs.push(h)
|
|
37
|
+
prev = h._getHash()
|
|
38
|
+
}
|
|
39
|
+
return hs
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('SPV.verifyHeaderChain', function () {
|
|
43
|
+
this.timeout(10000)
|
|
44
|
+
|
|
45
|
+
it('accepts a linked, PoW-valid chain', function () {
|
|
46
|
+
var hs = chain(4)
|
|
47
|
+
var res = SPV.verifyHeaderChain(hs)
|
|
48
|
+
res.valid.should.equal(true)
|
|
49
|
+
res.count.should.equal(4)
|
|
50
|
+
res.anchorHash.should.equal(hs[0].id)
|
|
51
|
+
res.tipHash.should.equal(hs[3].id)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('rejects a broken link', function () {
|
|
55
|
+
var hs = chain(3)
|
|
56
|
+
var res = SPV.verifyHeaderChain([hs[0], hs[2]]) // hs[2] links to hs[1], not hs[0]
|
|
57
|
+
res.valid.should.equal(false)
|
|
58
|
+
res.reason.should.match(/broken link/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('rejects a header that fails proof-of-work', function () {
|
|
62
|
+
// Mainnet-difficulty bits with nonce 0 — the hash will not meet the tiny target.
|
|
63
|
+
var bad = new bsv.BlockHeader({
|
|
64
|
+
version: 1, prevHash: Buffer.alloc(32), merkleRoot: rand32(),
|
|
65
|
+
time: 1231006505, bits: 0x1d00ffff, nonce: 0
|
|
66
|
+
})
|
|
67
|
+
SPV.verifyHeaderChain([bad]).valid.should.equal(false)
|
|
68
|
+
// ...but skipping PoW, a single header is trivially "valid".
|
|
69
|
+
SPV.verifyHeaderChain([bad], { requirePow: false }).valid.should.equal(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('honours a trusted-hash anchor (tip or anchor)', function () {
|
|
73
|
+
var hs = chain(3)
|
|
74
|
+
SPV.verifyHeaderChain(hs, { trustedHash: hs[2].id }).valid.should.equal(true)
|
|
75
|
+
SPV.verifyHeaderChain(hs, { trustedHash: hs[0].id }).valid.should.equal(true)
|
|
76
|
+
var res = SPV.verifyHeaderChain(hs, { trustedHash: 'ff'.repeat(32) })
|
|
77
|
+
res.valid.should.equal(false)
|
|
78
|
+
res.reason.should.match(/trusted hash/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('accepts hex / buffer headers', function () {
|
|
82
|
+
var hs = chain(2)
|
|
83
|
+
var asHex = hs.map(function (h) { return h.toBuffer().toString('hex') })
|
|
84
|
+
SPV.verifyHeaderChain(asHex).valid.should.equal(true)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* global describe, it */
|
|
4
|
+
|
|
5
|
+
// SPV Merkle inclusion proofs, cross-checked against the codebase's own Merkle
|
|
6
|
+
// tree algorithm (lib/block/block.js getMerkleTree). For random trees of many
|
|
7
|
+
// shapes (even, odd, duplicate-last), extract the branch for each leaf and assert
|
|
8
|
+
// the SPV verifier recomputes the exact same root — then assert tampering fails.
|
|
9
|
+
|
|
10
|
+
require('chai').should()
|
|
11
|
+
var bsv = require('../..')
|
|
12
|
+
var SPV = bsv.SPV
|
|
13
|
+
var Hash = bsv.crypto.Hash
|
|
14
|
+
|
|
15
|
+
function rev (b) { return Buffer.from(b).reverse() }
|
|
16
|
+
|
|
17
|
+
// Build the flat merkle tree exactly like Block.getMerkleTree (leaves = internal-LE).
|
|
18
|
+
function buildTree (leaves) {
|
|
19
|
+
var tree = leaves.slice()
|
|
20
|
+
var j = 0
|
|
21
|
+
for (var size = leaves.length; size > 1; size = Math.floor((size + 1) / 2)) {
|
|
22
|
+
for (var i = 0; i < size; i += 2) {
|
|
23
|
+
var i2 = Math.min(i + 1, size - 1)
|
|
24
|
+
tree.push(Hash.sha256sha256(Buffer.concat([tree[j + i], tree[j + i2]])))
|
|
25
|
+
}
|
|
26
|
+
j += size
|
|
27
|
+
}
|
|
28
|
+
return tree
|
|
29
|
+
}
|
|
30
|
+
function rootOf (leaves) { var t = buildTree(leaves); return t[t.length - 1] }
|
|
31
|
+
|
|
32
|
+
// Extract the sibling branch (display-order hex) for a leaf, mirroring the tree layout.
|
|
33
|
+
function branchFor (leaves, index) {
|
|
34
|
+
var tree = buildTree(leaves)
|
|
35
|
+
var branch = []
|
|
36
|
+
var j = 0
|
|
37
|
+
var idx = index
|
|
38
|
+
for (var size = leaves.length; size > 1; size = Math.floor((size + 1) / 2)) {
|
|
39
|
+
var sib = idx ^ 1
|
|
40
|
+
if (sib >= size) sib = idx // odd end -> duplicate self
|
|
41
|
+
branch.push(rev(tree[j + sib]).toString('hex'))
|
|
42
|
+
j += size
|
|
43
|
+
idx = Math.floor(idx / 2)
|
|
44
|
+
}
|
|
45
|
+
return branch
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function randLeaves (n) {
|
|
49
|
+
var a = []
|
|
50
|
+
for (var i = 0; i < n; i++) a.push(bsv.crypto.Random.getRandomBuffer(32))
|
|
51
|
+
return a
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('SPV.verifyMerkleProof (cross-checked vs Block.getMerkleTree)', function () {
|
|
55
|
+
var sizes = [1, 2, 3, 4, 5, 7, 8, 9, 16, 21]
|
|
56
|
+
|
|
57
|
+
it('recomputes the root for every leaf across many tree shapes', function () {
|
|
58
|
+
sizes.forEach(function (n) {
|
|
59
|
+
var leaves = randLeaves(n)
|
|
60
|
+
var root = rev(rootOf(leaves)).toString('hex') // display order
|
|
61
|
+
for (var idx = 0; idx < n; idx++) {
|
|
62
|
+
var txid = rev(leaves[idx]).toString('hex')
|
|
63
|
+
var nodes = branchFor(leaves, idx)
|
|
64
|
+
SPV.merkleRootFromBranch(txid, idx, nodes).should.equal(root,
|
|
65
|
+
'size ' + n + ' index ' + idx)
|
|
66
|
+
SPV.verifyMerkleProof({ txid: txid, index: idx, nodes: nodes, merkleRoot: root })
|
|
67
|
+
.should.equal(true)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('rejects a tampered branch node', function () {
|
|
73
|
+
var leaves = randLeaves(6)
|
|
74
|
+
var root = rev(rootOf(leaves)).toString('hex')
|
|
75
|
+
var nodes = branchFor(leaves, 2)
|
|
76
|
+
nodes[0] = rev(bsv.crypto.Random.getRandomBuffer(32)).toString('hex') // wrong sibling
|
|
77
|
+
SPV.verifyMerkleProof({ txid: rev(leaves[2]).toString('hex'), index: 2, nodes: nodes, merkleRoot: root })
|
|
78
|
+
.should.equal(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('rejects the right proof against the wrong txid', function () {
|
|
82
|
+
var leaves = randLeaves(8)
|
|
83
|
+
var root = rev(rootOf(leaves)).toString('hex')
|
|
84
|
+
var nodes = branchFor(leaves, 3)
|
|
85
|
+
var wrongTxid = rev(bsv.crypto.Random.getRandomBuffer(32)).toString('hex')
|
|
86
|
+
SPV.verifyMerkleProof({ txid: wrongTxid, index: 3, nodes: nodes, merkleRoot: root })
|
|
87
|
+
.should.equal(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("accepts '*' as the odd-node duplicate marker", function () {
|
|
91
|
+
// 3 leaves: index 2 is the odd one; its level-0 sibling is itself.
|
|
92
|
+
var leaves = randLeaves(3)
|
|
93
|
+
var root = rev(rootOf(leaves)).toString('hex')
|
|
94
|
+
var nodes = branchFor(leaves, 2)
|
|
95
|
+
nodes[0] = '*' // replace the explicit self-hash with the duplicate marker
|
|
96
|
+
SPV.verifyMerkleProof({ txid: rev(leaves[2]).toString('hex'), index: 2, nodes: nodes, merkleRoot: root })
|
|
97
|
+
.should.equal(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('single-tx block: empty branch, root == txid', function () {
|
|
101
|
+
var leaves = randLeaves(1)
|
|
102
|
+
var txid = rev(leaves[0]).toString('hex')
|
|
103
|
+
SPV.merkleRootFromBranch(txid, 0, []).should.equal(txid)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('verifyTxInclusion binds the branch to a header merkle root', function () {
|
|
107
|
+
var leaves = randLeaves(5)
|
|
108
|
+
var rootInternal = rootOf(leaves)
|
|
109
|
+
var header = new bsv.BlockHeader({
|
|
110
|
+
version: 1,
|
|
111
|
+
prevHash: Buffer.alloc(32),
|
|
112
|
+
merkleRoot: rootInternal, // internal-LE buffer
|
|
113
|
+
time: 1231006505,
|
|
114
|
+
bits: 0x1d00ffff,
|
|
115
|
+
nonce: 0
|
|
116
|
+
})
|
|
117
|
+
var res = SPV.verifyTxInclusion({
|
|
118
|
+
txid: rev(leaves[3]).toString('hex'),
|
|
119
|
+
index: 3,
|
|
120
|
+
nodes: branchFor(leaves, 3),
|
|
121
|
+
header: header,
|
|
122
|
+
requirePow: false // synthetic header has no real PoW
|
|
123
|
+
})
|
|
124
|
+
res.rootMatches.should.equal(true)
|
|
125
|
+
res.valid.should.equal(true)
|
|
126
|
+
|
|
127
|
+
// Wrong index -> root mismatch.
|
|
128
|
+
SPV.verifyTxInclusion({
|
|
129
|
+
txid: rev(leaves[3]).toString('hex'), index: 1,
|
|
130
|
+
nodes: branchFor(leaves, 3), header: header, requirePow: false
|
|
131
|
+
}).valid.should.equal(false)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* global describe, it */
|
|
4
|
+
|
|
5
|
+
// Regression guard for the StatusList2021 revocation-bypass bug. Before the fix,
|
|
6
|
+
// getCredentialStatusEntry / updateStatusList read the revocation bitstring from
|
|
7
|
+
// the JWT payload WITHOUT verifying the signature — so anyone who could substitute
|
|
8
|
+
// the list JWT could flip a revoked credential back to "valid". These tests assert
|
|
9
|
+
// the status-list reads now fail closed.
|
|
10
|
+
|
|
11
|
+
var expect = require('chai').expect
|
|
12
|
+
var didweb = require('../../lib/didweb')
|
|
13
|
+
var statuslist = require('../../lib/statuslist')
|
|
14
|
+
var vcjwt = require('../../lib/vcjwt')
|
|
15
|
+
|
|
16
|
+
function tamperEncodedList (listVcJwt, newEncodedList) {
|
|
17
|
+
var parts = listVcJwt.split('.')
|
|
18
|
+
var payload = JSON.parse(vcjwt.base64UrlDecode(parts[1]).toString())
|
|
19
|
+
payload.vc.credentialSubject.encodedList = newEncodedList
|
|
20
|
+
var tamperedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
21
|
+
return parts[0] + '.' + tamperedPayload + '.' + parts[2] // keep original signature
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('StatusList2021 fails closed (revocation-bypass regression)', function () {
|
|
25
|
+
var keys, docs, resolver, verifyOpts, listJwt
|
|
26
|
+
|
|
27
|
+
before(async function () {
|
|
28
|
+
keys = await didweb.generateIssuerKeys({ alg: 'ES256' })
|
|
29
|
+
docs = didweb.buildDidWebDocuments({
|
|
30
|
+
domain: 'example.com',
|
|
31
|
+
p256: { jwk: keys.publicJwk, kid: keys.kid }
|
|
32
|
+
})
|
|
33
|
+
resolver = async function () { return { jwks: docs.jwks } }
|
|
34
|
+
verifyOpts = { expectedIssuerDid: docs.did, didResolver: resolver }
|
|
35
|
+
var created = await statuslist.createStatusList({ issuerDid: docs.did, privateJwk: keys.privateJwk })
|
|
36
|
+
listJwt = created.listVcJwt
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('reads a genuine, verified status list', async function () {
|
|
40
|
+
var status = await statuslist.getCredentialStatusEntry(Object.assign({ listVcJwt: listJwt, index: 7 }, verifyOpts))
|
|
41
|
+
expect(status).to.equal('valid')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('rejects a list whose bitstring was tampered without re-signing (bypass closed)', async function () {
|
|
45
|
+
// Build a bitstring that marks index 7 as revoked, then splice it in WITHOUT
|
|
46
|
+
// the issuer's signature — exactly the substitution attack the bug allowed.
|
|
47
|
+
var zlib = require('zlib')
|
|
48
|
+
var buf = Buffer.alloc(Math.ceil(100000 / 8), 0)
|
|
49
|
+
buf[0] = 0xff
|
|
50
|
+
var forgedEncoded = zlib.gzipSync(buf).toString('base64')
|
|
51
|
+
var forgedJwt = tamperEncodedList(listJwt, forgedEncoded)
|
|
52
|
+
|
|
53
|
+
var threw = false
|
|
54
|
+
try {
|
|
55
|
+
await statuslist.getCredentialStatusEntry(Object.assign({ listVcJwt: forgedJwt, index: 7 }, verifyOpts))
|
|
56
|
+
} catch (e) { threw = true }
|
|
57
|
+
expect(threw, 'tampered list must be rejected').to.equal(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('refuses to read without a pinned expectedIssuerDid', async function () {
|
|
61
|
+
var threw = false
|
|
62
|
+
try {
|
|
63
|
+
await statuslist.getCredentialStatusEntry({ listVcJwt: listJwt, index: 7, didResolver: resolver })
|
|
64
|
+
} catch (e) { threw = true }
|
|
65
|
+
expect(threw).to.equal(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('rejects a list signed by a different (attacker) issuer under the victim DID', async function () {
|
|
69
|
+
var attacker = await didweb.generateIssuerKeys({ alg: 'ES256' })
|
|
70
|
+
var attackerList = await statuslist.createStatusList({ issuerDid: docs.did, privateJwk: attacker.privateJwk })
|
|
71
|
+
// Resolver returns the VICTIM's key for docs.did; attacker's signature must fail.
|
|
72
|
+
var threw = false
|
|
73
|
+
try {
|
|
74
|
+
await statuslist.getCredentialStatusEntry(Object.assign({ listVcJwt: attackerList.listVcJwt, index: 7 }, verifyOpts))
|
|
75
|
+
} catch (e) { threw = true }
|
|
76
|
+
expect(threw).to.equal(true)
|
|
77
|
+
})
|
|
78
|
+
})
|