@smartledger/bsv 8.0.1 → 8.2.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.
@@ -8,18 +8,26 @@ var $ = require('../util/preconditions')
8
8
 
9
9
  /**
10
10
  * ZKProver
11
- *
12
- * Zero-Knowledge Proof system for selective disclosure of credential fields.
13
- * Implements Merkle tree-based proofs and commitment schemes for privacy-preserving
14
- * credential verification.
15
- *
16
- * Features:
17
- * - Selective field disclosure
18
- * - Merkle inclusion proofs
19
- * - Commitment schemes with salt
20
- * - Range proofs for numerical values
21
- * - Proof of age without revealing birthdate
22
- * - Hash-based privacy preservation
11
+ *
12
+ * Selective disclosure of credential fields, built on a salted Merkle tree and hash
13
+ * commitments.
14
+ *
15
+ * NOT ZERO-KNOWLEDGE, despite the name this module has carried. There is no
16
+ * zero-knowledge machinery here — no Bulletproofs, no pairing, no circuit. What it
17
+ * provides is:
18
+ *
19
+ * - Selective field disclosure that genuinely withholds the undisclosed fields. Each
20
+ * leaf carries its own salt and only the disclosed leaves' salts travel in the proof,
21
+ * so an undisclosed leaf hash is a commitment under a secret the verifier never sees.
22
+ * - Merkle inclusion proofs binding disclosed fields to a credential root.
23
+ * - Hash commitments for ranges and ages. These can only be verified by OPENING them,
24
+ * which reveals the committed value to the verifier. A real age proof would not; this
25
+ * cannot do that, and callers who need it need a different primitive.
26
+ *
27
+ * The range and age verifiers previously returned a boolean the prover wrote about
28
+ * itself, so any object of the right shape verified. They now require the opening and
29
+ * check the commitment. Keep that distinction in mind before describing anything built
30
+ * on this module as zero-knowledge to a reviewer.
23
31
  */
24
32
 
25
33
  /**
@@ -43,29 +51,46 @@ function ZKProver(options) {
43
51
  * @returns {Object} Merkle tree data
44
52
  */
45
53
  ZKProver.createMerkleTree = function(credential, salt) {
46
- salt = salt || Random.getRandomBuffer(32).toString('hex')
47
-
48
54
  $.checkArgument(credential && typeof credential === 'object', 'Invalid credential')
49
-
55
+
50
56
  // Extract all fields from credential
51
57
  var fields = ZKProver._extractFields(credential)
52
-
53
- // Create leaf hashes
54
- var leaves = fields.map(function(field) {
55
- var fieldData = field.path + ':' + JSON.stringify(field.value) + ':' + salt
58
+
59
+ // PER-LEAF salts. A single salt shared by every leaf defeats selective disclosure
60
+ // entirely: the proof has to carry the salt so the verifier can recompute the
61
+ // disclosed leaves, and with that one value an attacker can hash candidate
62
+ // (path, value) pairs against the sibling hashes on the Merkle path and read back
63
+ // exactly the fields the holder withheld. Confirmed against a synthetic credential —
64
+ // disclosing only `credentialSubject.name` leaked `id`, `partyAffiliation` and
65
+ // `eligible`, the last two by brute-forcing their shared parent node.
66
+ //
67
+ // With a salt per leaf, only the disclosed leaves' salts travel in the proof, so an
68
+ // undisclosed leaf hash is a commitment under a secret the verifier never sees.
69
+ //
70
+ // `salt` is accepted only to derive DETERMINISTIC per-leaf salts, so a caller that
71
+ // needs reproducible trees still gets them without sharing one value across leaves.
72
+ var master = salt || Random.getRandomBuffer(32).toString('hex')
73
+
74
+ var leaves = fields.map(function(field, index) {
75
+ var leafSalt = Hash.sha256(
76
+ Buffer.from(master + ':' + index + ':' + field.path, 'utf8')
77
+ ).toString('hex')
78
+ var fieldData = field.path + ':' + JSON.stringify(field.value) + ':' + leafSalt
56
79
  return {
57
80
  path: field.path,
58
81
  value: field.value,
59
82
  hash: Hash.sha256(Buffer.from(fieldData, 'utf8')).toString('hex'),
60
- salt: salt
83
+ salt: leafSalt
61
84
  }
62
85
  })
63
-
86
+
64
87
  // Build Merkle tree
65
88
  var tree = ZKProver._buildMerkleTree(leaves.map(l => l.hash))
66
-
89
+
67
90
  return {
68
- salt: salt,
91
+ // The MASTER salt. Never put this in a proof — it re-derives every leaf salt and
92
+ // reopens the whole credential. generateSelectiveProof() ships per-leaf salts.
93
+ salt: master,
69
94
  leaves: leaves,
70
95
  tree: tree,
71
96
  root: tree[tree.length - 1][0]
@@ -122,11 +147,16 @@ ZKProver.generateSelectiveProof = function(credential, disclosePaths, salt) {
122
147
  return {
123
148
  path: leaf.path,
124
149
  value: leaf.value,
125
- hash: leaf.hash
150
+ hash: leaf.hash,
151
+ // The salt for THIS leaf only. Shipping one salt for the whole credential let
152
+ // a verifier — or anyone the proof is shown to — brute-force the withheld
153
+ // fields off the Merkle path.
154
+ salt: leaf.salt
126
155
  }
127
156
  }),
128
- merkleProofs: merkleProofs,
129
- salt: salt
157
+ merkleProofs: merkleProofs
158
+ // NOTE: the master salt is deliberately absent. It re-derives every leaf salt,
159
+ // including the undisclosed ones, which would reopen the whole credential.
130
160
  }
131
161
  }
132
162
 
@@ -157,8 +187,13 @@ ZKProver.verifySelectiveProof = function(proof, expectedRoot) {
157
187
  continue
158
188
  }
159
189
 
160
- // Verify field hash
161
- var fieldData = field.path + ':' + JSON.stringify(field.value) + ':' + proof.salt
190
+ // Verify field hash, using the salt carried with THIS field. Reading a
191
+ // credential-wide `proof.salt` is what the leak fix removed.
192
+ if (typeof field.salt !== 'string' || !field.salt) {
193
+ result.errors.push('Missing per-field salt for: ' + field.path)
194
+ continue
195
+ }
196
+ var fieldData = field.path + ':' + JSON.stringify(field.value) + ':' + field.salt
162
197
  var computedHash = Hash.sha256(Buffer.from(fieldData, 'utf8')).toString('hex')
163
198
 
164
199
  if (computedHash !== field.hash) {
@@ -235,7 +270,10 @@ ZKProver.generateAgeProof = function(birthDate, minimumAge, salt) {
235
270
  meetsRequirement: true,
236
271
  birthDateCommitment: commitment,
237
272
  ageProofHash: ageProofHash,
238
- challengeResponse: ZKProver._generateAgeChallenge(birthDate, minimumAge, salt)
273
+ challengeResponse: ZKProver._generateAgeChallenge(birthDate, minimumAge, salt),
274
+ // As with the range proof: the commitment can only be checked by opening it, and
275
+ // opening it reveals the birth date. Returned alongside the proof, never inside it.
276
+ opening: { birthDate: birthDateString, salt: salt }
239
277
  }
240
278
  }
241
279
 
@@ -245,7 +283,7 @@ ZKProver.generateAgeProof = function(birthDate, minimumAge, salt) {
245
283
  * @param {Number} requiredAge - Required minimum age
246
284
  * @returns {Boolean} True if proof is valid
247
285
  */
248
- ZKProver.verifyAgeProof = function(proof, requiredAge) {
286
+ ZKProver.verifyAgeProof = function(proof, requiredAge, opening) {
249
287
  try {
250
288
  $.checkArgument(proof && typeof proof === 'object', 'Invalid proof')
251
289
  $.checkArgument(typeof requiredAge === 'number', 'Required age must be number')
@@ -258,15 +296,40 @@ ZKProver.verifyAgeProof = function(proof, requiredAge) {
258
296
  if (proof.minimumAge !== requiredAge) {
259
297
  return false
260
298
  }
261
-
262
- if (!proof.meetsRequirement) {
299
+
300
+ // Same defect as verifyRangeProof, and the same fix. This used to accept
301
+ // `proof.meetsRequirement` — the prover's own claim — and then check only that
302
+ // `challengeResponse` was a non-empty string, which any forged proof satisfies.
303
+ // `birthDateCommitment` was never opened.
304
+ //
305
+ // The commitment is over the birth date, so verifying it requires the birth date.
306
+ // That reveals it, which is precisely what an age proof is supposed to avoid — the
307
+ // honest reading is that this construction cannot do what its name promises, and a
308
+ // caller who needs real age proofs needs a different primitive.
309
+ if (!opening || typeof opening !== 'object') {
263
310
  return false
264
311
  }
265
-
266
- // Verify challenge response (simplified)
267
- // In production, this would use more sophisticated ZK techniques
268
- return proof.challengeResponse && proof.challengeResponse.length > 0
269
-
312
+ var birthDate = opening.birthDate
313
+ if (typeof birthDate === 'string') birthDate = new Date(birthDate)
314
+ if (!(birthDate instanceof Date) || isNaN(birthDate.getTime())) {
315
+ return false
316
+ }
317
+ if (typeof opening.salt !== 'string') {
318
+ return false
319
+ }
320
+
321
+ var birthDateString = birthDate.toISOString().split('T')[0]
322
+ var commitment = Hash.sha256(
323
+ Buffer.from(birthDateString + ':' + opening.salt, 'utf8')
324
+ ).toString('hex')
325
+ if (commitment !== proof.birthDateCommitment) {
326
+ return false
327
+ }
328
+
329
+ // Recompute the age rather than trusting `meetsRequirement`.
330
+ var ageInYears = Math.floor((Date.now() - birthDate.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
331
+ return ageInYears >= requiredAge
332
+
270
333
  } catch (error) {
271
334
  return false
272
335
  }
@@ -291,7 +354,8 @@ ZKProver.generateRangeProof = function(value, min, max, salt) {
291
354
  // Create commitment to value
292
355
  var commitment = Hash.sha256(Buffer.from(value.toString() + ':' + salt, 'utf8')).toString('hex')
293
356
 
294
- // Generate proof components (simplified Bulletproof-style)
357
+ // Generate proof components. NOT a Bulletproof — this is a hash commitment plus a
358
+ // hash over the parameters. It says nothing without the opening returned below.
295
359
  var proofData = {
296
360
  min: min,
297
361
  max: max,
@@ -309,7 +373,13 @@ ZKProver.generateRangeProof = function(value, min, max, salt) {
309
373
  range: { min: min, max: max },
310
374
  valueCommitment: commitment,
311
375
  proofHash: proofHash,
312
- inRange: true
376
+ inRange: true,
377
+ // The OPENING. verifyRangeProof() cannot check the commitment without it, so the
378
+ // holder must pass it to the verifier out of band. It is returned here rather than
379
+ // embedded in the proof precisely because handing it over reveals the value — that
380
+ // disclosure is the cost of a commitment scheme, and hiding it inside the proof
381
+ // would make every proof self-opening.
382
+ opening: { value: value, salt: salt }
313
383
  }
314
384
  }
315
385
 
@@ -320,20 +390,47 @@ ZKProver.generateRangeProof = function(value, min, max, salt) {
320
390
  * @param {Number} max - Expected maximum
321
391
  * @returns {Boolean} True if proof is valid
322
392
  */
323
- ZKProver.verifyRangeProof = function(proof, min, max) {
393
+ ZKProver.verifyRangeProof = function(proof, min, max, opening) {
324
394
  try {
325
395
  $.checkArgument(proof && typeof proof === 'object', 'Invalid proof')
326
-
396
+
327
397
  if (proof.type !== 'RangeProof') {
328
398
  return false
329
399
  }
330
-
331
- if (proof.range.min !== min || proof.range.max !== max) {
400
+
401
+ if (!proof.range || proof.range.min !== min || proof.range.max !== max) {
332
402
  return false
333
403
  }
334
-
335
- return proof.inRange === true
336
-
404
+
405
+ // WITHOUT AN OPENING, NOTHING HAS BEEN PROVEN. This used to end at
406
+ // `return proof.inRange === true` — a boolean the prover writes about itself, never
407
+ // checked against `valueCommitment`. Any object of the right shape verified:
408
+ //
409
+ // { type: 'RangeProof', range: { min: 18, max: 120 },
410
+ // valueCommitment: '00…', proofHash: 'de…', inRange: true } -> true
411
+ //
412
+ // These are hash commitments, not zero-knowledge proofs: the commitment can only be
413
+ // checked by opening it, so the holder must supply { value, salt } out of band. That
414
+ // reveals the value to the verifier, which is the honest cost of this construction
415
+ // and the reason it must not be described as zero-knowledge.
416
+ if (!opening || typeof opening !== 'object') {
417
+ return false
418
+ }
419
+ if (typeof opening.value !== 'number' || typeof opening.salt !== 'string') {
420
+ return false
421
+ }
422
+
423
+ // The commitment must actually open to the claimed value...
424
+ var commitment = Hash.sha256(
425
+ Buffer.from(opening.value.toString() + ':' + opening.salt, 'utf8')
426
+ ).toString('hex')
427
+ if (commitment !== proof.valueCommitment) {
428
+ return false
429
+ }
430
+
431
+ // ...and that value must genuinely lie in the range, rather than the prover saying so.
432
+ return opening.value >= min && opening.value <= max
433
+
337
434
  } catch (error) {
338
435
  return false
339
436
  }
@@ -322,9 +322,28 @@ Interpreter.LOCKTIME_THRESHOLD_BN = new BN(Interpreter.LOCKTIME_THRESHOLD)
322
322
  Interpreter.useGenesisLimits = function (max) {
323
323
  max = max || 0x7fffffff
324
324
  Interpreter.MAX_SCRIPT_ELEMENT_SIZE = max
325
- Interpreter.MAXIMUM_ELEMENT_SIZE = max
326
325
  Interpreter.MAX_OPS_PER_SCRIPT = max
327
326
  Interpreter.MAX_SCRIPT_SIZE = max
327
+
328
+ // MAXIMUM_ELEMENT_SIZE is deliberately NOT raised. It is CScriptNum's `max_length`,
329
+ // and since the limits became era-derived it serves only as the PRE-Genesis fallback
330
+ // for `maxScriptNumLength()` — post-Genesis is 750,000 and post-Chronicle 32,000,000,
331
+ // both reached through the era flags rather than this static.
332
+ //
333
+ // Raising it therefore could not enable post-Genesis arithmetic; it could only corrupt
334
+ // pre-Genesis validation, and it did. Measured, a 5-byte operand under
335
+ // P2SH,STRICTENC:
336
+ //
337
+ // fresh process: SCRIPT_ERR_SCRIPTNUM_OVERFLOW (what the node says)
338
+ // after useGenesisLimits(): ACCEPTED
339
+ //
340
+ // A false accept, in the direction that can cost money, produced by a process-wide
341
+ // side effect on unrelated code later in the same process. The node's own vectors
342
+ // agree: all 22 SCRIPTNUM_OVERFLOW rows are pre-Genesis, and raising this static turns
343
+ // 15 of them into false accepts.
344
+ //
345
+ // For post-Genesis arithmetic, pass the era — `Interpreter.mainnetFlags()`, or simply
346
+ // omit the flags argument, which now defaults to current mainnet.
328
347
  return Interpreter
329
348
  }
330
349
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartledger/bsv",
3
- "version": "8.0.1",
3
+ "version": "8.2.0",
4
4
  "description": "🚀 Complete Bitcoin SV development framework with legally-recognizable DID:web + W3C VC-JWT toolkit, Legal Token Protocol (LTP), Global Digital Attestation Framework (GDAF), StatusList2021 revocation, and 16 flexible loading options. Standards-based credentials with ES256/ES256K support, on-chain BSV anchoring, and comprehensive Bitcoin SV API. Perfect for legal tokens, verifiable credentials, DeFi, smart contracts, and secure Bitcoin applications.",
5
5
  "author": "SmartLedger Technology <hello@smartledger.technology> (https://smartledger.technology)",
6
6
  "homepage": "https://github.com/codenlighten/smartledger-bsv#readme",
@@ -117,6 +117,7 @@
117
117
  "message/",
118
118
  "mnemonic/",
119
119
  "build/",
120
+ "tools/",
120
121
  "*-entry.js",
121
122
  "bsv.min.js",
122
123
  "bsv.bundle.js",
@@ -187,7 +188,7 @@
187
188
  "preimage",
188
189
  "secret-splitting",
189
190
  "did-resolution",
190
- "zero-knowledge-proofs",
191
+ "selective-disclosure",
191
192
  "blockchain-anchoring",
192
193
  "attestation-framework",
193
194
  "nchain",
@@ -0,0 +1,106 @@
1
+ 'use strict'
2
+
3
+ /* global describe, it */
4
+
5
+ // GDAF signs a hash of canonical JSON, so the canonicalization IS part of the signature
6
+ // scheme. It was sorted-key JSON that rebuilt the object, which loses the sort: V8 orders
7
+ // integer-like own properties numerically, ahead of string keys, whatever order they were
8
+ // inserted in.
9
+ //
10
+ // Within this library that was deterministic — signing and verification agreed, and no
11
+ // forgery followed. Across implementations it was a verification failure, which for
12
+ // credentials that exist to be checked by other parties is the thing that matters.
13
+
14
+ require('chai').should()
15
+ var Signer = require('../../lib/gdaf/attestation-signer')
16
+
17
+ describe('GDAF canonicalization', function () {
18
+ describe('RFC 8785 conformance', function () {
19
+ // The concrete divergence. JCS sorts by UTF-16 code unit, where '10' < '2'.
20
+ it('sorts integer-like keys lexicographically, not numerically', function () {
21
+ var o = {}
22
+ o['10'] = 'ten'
23
+ o['2'] = 'two'
24
+ Signer._canonicalizeJCS(o).should.equal('{"10":"ten","2":"two"}')
25
+ })
26
+
27
+ it('is what the legacy form got wrong, which is why both exist', function () {
28
+ var o = {}
29
+ o['10'] = 'ten'
30
+ o['2'] = 'two'
31
+ Signer._canonicalizeJSON(o).should.equal('{"2":"two","10":"ten"}')
32
+ Signer._canonicalizeJCS(o).should.not.equal(Signer._canonicalizeJSON(o))
33
+ })
34
+
35
+ it('sorts at every depth and leaves arrays in order', function () {
36
+ Signer._canonicalizeJCS({ z: { b: 1, a: 2 }, a: [3, { d: 1, c: 2 }] })
37
+ .should.equal('{"a":[3,{"c":2,"d":1}],"z":{"a":2,"b":1}}')
38
+ })
39
+
40
+ it('is insensitive to key insertion order', function () {
41
+ var x = {}; x.b = 1; x.a = 2
42
+ var y = {}; y.a = 2; y.b = 1
43
+ Signer._canonicalizeJCS(x).should.equal(Signer._canonicalizeJCS(y))
44
+ })
45
+
46
+ // These are the same IEEE-754 double, so collapsing them is correct rather than a
47
+ // weakness — JavaScript has one number type. Pinned so nobody "fixes" it.
48
+ it('treats 1847, 1847.0 and 1.847e3 as the one number they are', function () {
49
+ Signer._canonicalizeJCS({ a: 1847 }).should.equal('{"a":1847}')
50
+ Signer._canonicalizeJCS({ a: 1847.0 }).should.equal('{"a":1847}')
51
+ Signer._canonicalizeJCS({ a: 1.847e3 }).should.equal('{"a":1847}')
52
+ })
53
+
54
+ it('keeps a number distinct from its string form', function () {
55
+ Signer._canonicalizeJCS({ a: 1847 }).should.not.equal(Signer._canonicalizeJCS({ a: '1847' }))
56
+ })
57
+
58
+ // JSON.stringify would emit `null` here, silently signing a different document.
59
+ it('refuses non-finite numbers rather than emitting null', function () {
60
+ ;(function () { Signer._canonicalizeJCS({ a: NaN }) }).should.throw(/non-finite/)
61
+ ;(function () { Signer._canonicalizeJCS({ a: Infinity }) }).should.throw(/non-finite/)
62
+ })
63
+
64
+ it('emits unicode and non-BMP characters directly', function () {
65
+ Signer._canonicalizeJCS({ a: 'é' }).should.equal('{"a":"é"}')
66
+ Signer._canonicalizeJCS({ a: '😀' }).should.equal('{"a":"😀"}')
67
+ })
68
+
69
+ it('drops undefined members, which JSON cannot represent', function () {
70
+ Signer._canonicalizeJCS({ a: 1, b: undefined }).should.equal('{"a":1}')
71
+ })
72
+
73
+ it('nulls undefined array elements, as JSON.stringify does', function () {
74
+ Signer._canonicalizeJCS([1, undefined, 2]).should.equal('[1,null,2]')
75
+ })
76
+ })
77
+
78
+ describe('hashing and migration', function () {
79
+ var CRED = { id: 'urn:x', credentialSubject: { name: 'Alice', age: 41 } }
80
+
81
+ it('hashes with JCS by default', function () {
82
+ Signer._hashCredential(CRED).toString('hex')
83
+ .should.equal(Signer._hashCredential(CRED, Signer.CANONICALIZATION.JCS).toString('hex'))
84
+ })
85
+
86
+ // The migration path. A credential whose keys make the two forms differ must hash
87
+ // differently, or the legacy fallback in the verifier would be pointless.
88
+ it('gives a different hash under the legacy form when the forms diverge', function () {
89
+ var withIntKeys = { '10': 'ten', '2': 'two' }
90
+ Signer._hashCredential(withIntKeys, Signer.CANONICALIZATION.JCS).toString('hex')
91
+ .should.not.equal(
92
+ Signer._hashCredential(withIntKeys, Signer.CANONICALIZATION.LEGACY).toString('hex')
93
+ )
94
+ })
95
+
96
+ it('agrees between the forms when no integer-like keys are present', function () {
97
+ Signer._hashCredential(CRED, Signer.CANONICALIZATION.JCS).toString('hex')
98
+ .should.equal(Signer._hashCredential(CRED, Signer.CANONICALIZATION.LEGACY).toString('hex'))
99
+ })
100
+
101
+ it('exposes both forms by name', function () {
102
+ Signer.CANONICALIZATION.JCS.should.equal('jcs')
103
+ Signer.CANONICALIZATION.LEGACY.should.equal('legacy')
104
+ })
105
+ })
106
+ })
@@ -0,0 +1,204 @@
1
+ 'use strict'
2
+
3
+ /* global describe, it */
4
+
5
+ // This module had ZERO test coverage, and carried three defects that coverage would have
6
+ // caught immediately. Each is pinned below as the attack that worked, not as a paraphrase
7
+ // of the fix, so a regression reproduces the exploit rather than merely failing a check.
8
+ //
9
+ // It is also not zero-knowledge, and the tests say so where it matters: the range and age
10
+ // commitments can only be verified by opening them, which reveals the committed value.
11
+
12
+ require('chai').should()
13
+ var bsv = require('../..')
14
+ var ZK = require('../../lib/gdaf/zk-prover')
15
+ var Hash = require('../../lib/crypto/hash')
16
+
17
+ var CRED = {
18
+ id: 'urn:x',
19
+ credentialSubject: { name: 'Alice', partyAffiliation: 'DEM', eligible: true }
20
+ }
21
+
22
+ describe('ZKProver', function () {
23
+ describe('range proofs', function () {
24
+ // The original read `return proof.inRange === true` — a boolean the prover writes
25
+ // about itself. valueCommitment was never consulted, so this forgery verified.
26
+ it('rejects a forged proof that merely claims inRange', function () {
27
+ var forged = {
28
+ type: 'RangeProof',
29
+ range: { min: 18, max: 120 },
30
+ valueCommitment: '00'.repeat(32),
31
+ proofHash: 'de'.repeat(32),
32
+ inRange: true
33
+ }
34
+ ZK.verifyRangeProof(forged, 18, 120).should.equal(false)
35
+ })
36
+
37
+ it('rejects an honest proof presented without its opening', function () {
38
+ var proof = ZK.generateRangeProof(41, 18, 120)
39
+ ZK.verifyRangeProof(proof, 18, 120).should.equal(false)
40
+ })
41
+
42
+ it('accepts an honest proof with its opening', function () {
43
+ var proof = ZK.generateRangeProof(41, 18, 120)
44
+ ZK.verifyRangeProof(proof, 18, 120, proof.opening).should.equal(true)
45
+ })
46
+
47
+ // The commitment must actually bind the value: a lie about what was committed to
48
+ // has to fail even though the range and structure are right.
49
+ it('rejects an opening that does not match the commitment', function () {
50
+ var proof = ZK.generateRangeProof(41, 18, 120)
51
+ var lie = { value: 12, salt: proof.opening.salt }
52
+ ZK.verifyRangeProof(proof, 18, 120, lie).should.equal(false)
53
+ })
54
+
55
+ it('rejects a value outside the stated range even if the commitment opens', function () {
56
+ var proof = ZK.generateRangeProof(41, 18, 120)
57
+ ZK.verifyRangeProof(proof, 50, 120, proof.opening).should.equal(false)
58
+ })
59
+
60
+ it('returns a strict boolean', function () {
61
+ var proof = ZK.generateRangeProof(41, 18, 120)
62
+ ZK.verifyRangeProof(proof, 18, 120, proof.opening).should.be.a('boolean')
63
+ ZK.verifyRangeProof({}, 18, 120).should.be.a('boolean')
64
+ })
65
+ })
66
+
67
+ describe('age proofs', function () {
68
+ var BIRTH = new Date('1985-01-01T00:00:00Z')
69
+
70
+ it('rejects a forged proof that merely claims meetsRequirement', function () {
71
+ var forged = {
72
+ type: 'AgeProof',
73
+ minimumAge: 18,
74
+ meetsRequirement: true,
75
+ birthDateCommitment: '00'.repeat(32),
76
+ challengeResponse: 'anything-non-empty'
77
+ }
78
+ ZK.verifyAgeProof(forged, 18).should.equal(false)
79
+ })
80
+
81
+ it('rejects an honest proof presented without its opening', function () {
82
+ var proof = ZK.generateAgeProof(BIRTH, 18)
83
+ ZK.verifyAgeProof(proof, 18).should.equal(false)
84
+ })
85
+
86
+ it('accepts an honest proof with its opening', function () {
87
+ var proof = ZK.generateAgeProof(BIRTH, 18)
88
+ ZK.verifyAgeProof(proof, 18, proof.opening).should.equal(true)
89
+ })
90
+
91
+ it('rejects an opening that does not match the commitment', function () {
92
+ var proof = ZK.generateAgeProof(BIRTH, 18)
93
+ ZK.verifyAgeProof(proof, 18, { birthDate: '2015-01-01', salt: proof.opening.salt })
94
+ .should.equal(false)
95
+ })
96
+ })
97
+
98
+ describe('selective disclosure', function () {
99
+ // THE LEAK. One salt covered every leaf and travelled in the proof, so the sibling
100
+ // hashes on the Merkle path could be brute-forced back into the withheld fields.
101
+ // Disclosing only the name recovered id, partyAffiliation and eligible.
102
+ it('does not leak the withheld fields to someone holding the proof', function () {
103
+ var proof = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'], 'aa'.repeat(16))
104
+
105
+ // Everything an attacker gets: the proof's salts and the Merkle path.
106
+ var salts = proof.disclosedFields.map(function (f) { return f.salt })
107
+ var siblings = []
108
+ proof.merkleProofs.forEach(function (mp) {
109
+ mp.proof.forEach(function (n) { siblings.push(n.hash) })
110
+ })
111
+
112
+ function leaf (p, v, s) {
113
+ return Hash.sha256(Buffer.from(p + ':' + JSON.stringify(v) + ':' + s, 'utf8')).toString('hex')
114
+ }
115
+ function node (a, b) {
116
+ return Hash.sha256(Buffer.from(a + b, 'hex')).toString('hex')
117
+ }
118
+
119
+ var paths = ['id', 'credentialSubject.partyAffiliation', 'credentialSubject.eligible']
120
+ var values = ['urn:x', 'DEM', 'REP', 'IND', true, false]
121
+ var recovered = []
122
+
123
+ salts.forEach(function (s) {
124
+ // Direct leaf siblings.
125
+ paths.forEach(function (p) {
126
+ values.forEach(function (v) {
127
+ if (siblings.indexOf(leaf(p, v, s)) !== -1) recovered.push(p)
128
+ })
129
+ })
130
+ // Internal nodes: brute-force the pair beneath them.
131
+ paths.forEach(function (p1) {
132
+ values.forEach(function (v1) {
133
+ paths.forEach(function (p2) {
134
+ values.forEach(function (v2) {
135
+ if (siblings.indexOf(node(leaf(p1, v1, s), leaf(p2, v2, s))) !== -1) {
136
+ recovered.push(p1, p2)
137
+ }
138
+ })
139
+ })
140
+ })
141
+ })
142
+ })
143
+
144
+ recovered.should.deep.equal([], 'recovered withheld fields: ' + recovered.join(', '))
145
+ })
146
+
147
+ it('never ships the master salt, which would reopen every leaf', function () {
148
+ var proof = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'], 'aa'.repeat(16))
149
+ ;(proof.salt === undefined).should.equal(true)
150
+ })
151
+
152
+ // The generated salt used to be dropped on the floor: the proof returned the
153
+ // (undefined) `salt` ARGUMENT rather than the one createMerkleTree produced, so a
154
+ // proof made without an explicit salt could never verify.
155
+ it('verifies a proof generated without an explicit salt', function () {
156
+ var proof = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'])
157
+ var result = ZK.verifySelectiveProof(proof, proof.credentialRoot)
158
+ result.valid.should.equal(true, JSON.stringify(result.errors))
159
+ })
160
+
161
+ it('verifies a proof generated with an explicit salt', function () {
162
+ var proof = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'], 'aa'.repeat(16))
163
+ ZK.verifySelectiveProof(proof, proof.credentialRoot).valid.should.equal(true)
164
+ })
165
+
166
+ it('rejects a tampered disclosed value', function () {
167
+ var proof = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'], 'aa'.repeat(16))
168
+ proof.disclosedFields[0].value = 'Mallory'
169
+ ZK.verifySelectiveProof(proof, proof.credentialRoot).valid.should.equal(false)
170
+ })
171
+
172
+ it('rejects a proof checked against the wrong root', function () {
173
+ var proof = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'], 'aa'.repeat(16))
174
+ ZK.verifySelectiveProof(proof, '00'.repeat(32)).valid.should.equal(false)
175
+ })
176
+
177
+ it('rejects a field whose salt has been stripped', function () {
178
+ var proof = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'], 'aa'.repeat(16))
179
+ delete proof.disclosedFields[0].salt
180
+ ZK.verifySelectiveProof(proof, proof.credentialRoot).valid.should.equal(false)
181
+ })
182
+
183
+ // Deterministic trees are still available for callers that need them, without the
184
+ // single-salt weakness that made them dangerous.
185
+ it('is deterministic given the same master salt', function () {
186
+ var a = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'], 'bb'.repeat(16))
187
+ var b = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'], 'bb'.repeat(16))
188
+ a.credentialRoot.should.equal(b.credentialRoot)
189
+ })
190
+
191
+ it('gives different roots for different master salts', function () {
192
+ var a = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'], 'bb'.repeat(16))
193
+ var b = ZK.generateSelectiveProof(CRED, ['credentialSubject.name'], 'cc'.repeat(16))
194
+ a.credentialRoot.should.not.equal(b.credentialRoot)
195
+ })
196
+ })
197
+
198
+ // The module is reachable from the public GDAF surface, so the fixes above are not
199
+ // internal-only.
200
+ it('is exposed on the GDAF instance', function () {
201
+ var g = new bsv.GDAF()
202
+ ;(typeof g.zkProver.verifyRangeProof).should.equal('function')
203
+ })
204
+ })