@smartledger/bsv 8.1.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +38 -38
  3. package/bsv-ecies.min.js +1 -1
  4. package/bsv-gdaf.min.js +62 -62
  5. package/bsv-ltp.min.js +48 -48
  6. package/bsv-smartcontract.min.js +1 -1
  7. package/bsv.bundle.js +62 -62
  8. package/bsv.min.js +62 -62
  9. package/docs/AUDIT_SCOPE.md +8 -8
  10. package/docs/BRC220_ENCODING_AMENDMENT.md +100 -0
  11. package/docs/BRC220_PLAN.md +224 -0
  12. package/docs/MODULE_REFERENCE_COMPLETE.md +27 -27
  13. package/docs/advanced/UTXO_MANAGER_GUIDE.md +1 -1
  14. package/docs/getting-started/INSTALLATION.md +23 -23
  15. package/docs/getting-started/QUICK_START.md +7 -7
  16. package/docs/migration/FROM_BSV_1_5_6.md +5 -5
  17. package/index.js +5 -0
  18. package/index.mjs +1 -0
  19. package/lib/gdaf/attestation-signer.js +54 -2
  20. package/lib/gdaf/attestation-verifier.js +41 -25
  21. package/lib/gdaf/zk-prover.js +143 -46
  22. package/lib/notaryhash/certificate.js +282 -0
  23. package/lib/notaryhash/encoding.js +150 -0
  24. package/lib/notaryhash/index.js +334 -0
  25. package/lib/notaryhash/merkle.js +209 -0
  26. package/lib/notaryhash/script.js +261 -0
  27. package/lib/notaryhash/suites.js +156 -0
  28. package/lib/util/jcs.js +75 -0
  29. package/package.json +9 -6
  30. package/test/gdaf/canonicalization.js +106 -0
  31. package/test/gdaf/zk_prover.js +204 -0
  32. package/test/notaryhash/certificate.js +249 -0
  33. package/test/notaryhash/encoding.js +186 -0
  34. package/test/notaryhash/merkle.js +181 -0
  35. package/test/notaryhash/script.js +270 -0
  36. package/test/notaryhash/verify.js +339 -0
  37. package/tools/minimal_reproduction.js +119 -0
  38. package/tools/opcode_map.js +342 -0
  39. package/tools/server.js +97 -0
  40. package/tools/simple_real_tx.js +136 -0
  41. package/tools/sv-sighash-harness.js +123 -0
  42. package/tools/sv-sighash-report.js +71 -0
  43. package/tools/sv-tx-harness.js +172 -0
  44. package/tools/sv-tx-report.js +43 -0
  45. package/tools/sv-vector-harness.js +304 -0
  46. package/tools/sv-vector-report.js +88 -0
  47. package/version.js +1 -1
@@ -159,17 +159,17 @@ const recovered = bsv.reconstructSecret([shares[0], shares[2], shares[4]]);
159
159
  ### **New Modular Options**
160
160
  ```html
161
161
  <!-- Core compatibility (same size as bsv@1.5.6) -->
162
- <script src="https://unpkg.com/@smartledger/bsv@8.1.0/bsv.min.js"></script>
162
+ <script src="https://unpkg.com/@smartledger/bsv@8.3.0/bsv.min.js"></script>
163
163
 
164
164
  <!-- Add smart contracts when ready -->
165
- <script src="https://unpkg.com/@smartledger/bsv@8.1.0/bsv-smartcontract.min.js"></script>
165
+ <script src="https://unpkg.com/@smartledger/bsv@8.3.0/bsv-smartcontract.min.js"></script>
166
166
 
167
167
  <!-- Add advanced features as needed -->
168
- <script src="https://unpkg.com/@smartledger/bsv@8.1.0/bsv-ltp.min.js"></script>
169
- <script src="https://unpkg.com/@smartledger/bsv@8.1.0/bsv-gdaf.min.js"></script>
168
+ <script src="https://unpkg.com/@smartledger/bsv@8.3.0/bsv-ltp.min.js"></script>
169
+ <script src="https://unpkg.com/@smartledger/bsv@8.3.0/bsv-gdaf.min.js"></script>
170
170
 
171
171
  <!-- Everything in one file -->
172
- <script src="https://unpkg.com/@smartledger/bsv@8.1.0/bsv.bundle.js"></script>
172
+ <script src="https://unpkg.com/@smartledger/bsv@8.3.0/bsv.bundle.js"></script>
173
173
  ```
174
174
 
175
175
  ## πŸ” **Testing Your Migration**
package/index.js CHANGED
@@ -115,6 +115,11 @@ bsv.Block = require('./lib/block')
115
115
  bsv.MerkleBlock = require('./lib/block/merkleblock')
116
116
  bsv.BlockHeader = require('./lib/block/blockheader')
117
117
  bsv.SPV = require('./lib/spv')
118
+
119
+ // BRC-220 NotaryHash. Exposed only now that verification exists: build/parse alone would
120
+ // let a caller construct a record whose proofHash means nothing, with nothing to stop
121
+ // them. See docs/BRC220_PLAN.md.
122
+ bsv.NotaryHash = require('./lib/notaryhash')
118
123
  bsv.HDPrivateKey = require('./lib/hdprivatekey.js')
119
124
  bsv.HDPublicKey = require('./lib/hdpublickey.js')
120
125
  bsv.Networks = require('./lib/networks')
package/index.mjs CHANGED
@@ -23,6 +23,7 @@ export const {
23
23
  Message,
24
24
  Mnemonic,
25
25
  Networks,
26
+ NotaryHash,
26
27
  Opcode,
27
28
  Ordinals,
28
29
  Output,
@@ -7,6 +7,7 @@ var Hash = require('../crypto/hash')
7
7
  var ECDSA = require('../crypto/ecdsa')
8
8
  var Signature = require('../crypto/signature')
9
9
  var $ = require('../util/preconditions')
10
+ var JCS = require('../util/jcs')
10
11
  var _ = require('../util/_')
11
12
 
12
13
  /**
@@ -94,16 +95,67 @@ AttestationSigner._canonicalizeJSON = function(obj) {
94
95
  return JSON.stringify(AttestationSigner._sortValue(obj))
95
96
  }
96
97
 
98
+ /**
99
+ * RFC 8785 (JCS) canonical JSON.
100
+ *
101
+ * `_canonicalizeJSON` above sorts keys and then REBUILDS an object, which loses the
102
+ * sort: V8 orders integer-like own properties numerically ahead of string keys whatever
103
+ * order they were inserted in. So `{ '10': …, '2': … }` serialized as
104
+ *
105
+ * ours {"2":"two","10":"ten"}
106
+ * JCS {"10":"ten","2":"two"} (UTF-16 code-unit order)
107
+ *
108
+ * Within this library that is merely deterministic-but-nonstandard: signing and
109
+ * verification agree, and no forgery follows from it. Across implementations it is a
110
+ * verification failure β€” GDAF credentials exist to be checked by other parties, and a
111
+ * JCS-conformant verifier in any language computes a different hash and rejects a valid
112
+ * signature. The previous implementation acknowledged this with "do not feed integer-like
113
+ * keys here", which is a constraint the data cannot be relied upon to honour.
114
+ *
115
+ * This serializes directly instead of round-tripping through an object, so the order
116
+ * actually survives. Array.prototype.sort already compares strings by UTF-16 code unit,
117
+ * which is exactly what JCS specifies; the bug was never the sort.
118
+ *
119
+ * JSON.stringify is used for the leaf types on purpose: for finite numbers it produces
120
+ * ECMAScript Number::toString, which is what JCS mandates, and since ES2019 it emits
121
+ * well-formed output for lone surrogates. Both are hard to reproduce by hand and easy to
122
+ * get subtly wrong.
123
+ *
124
+ * @param {*} value - Value to serialize
125
+ * @returns {String} RFC 8785 canonical JSON
126
+ */
127
+ AttestationSigner._canonicalizeJCS = function (value) {
128
+ return JCS.stringify(value)
129
+ }
130
+
97
131
  /**
98
132
  * Create hash of credential for signing
99
133
  * @param {Object} credential - Credential object
100
134
  * @returns {Buffer} SHA256 hash
101
135
  */
102
- AttestationSigner._hashCredential = function(credential) {
103
- var canonical = AttestationSigner._canonicalizeJSON(credential)
136
+ AttestationSigner._hashCredential = function (credential, canonicalization) {
137
+ // RFC 8785 by default. `'legacy'` selects the pre-JCS sorted-key form, which is kept
138
+ // ONLY so credentials signed before the change still verify β€” see
139
+ // AttestationSigner.CANONICALIZATION. New signatures must never use it.
140
+ var canonical = canonicalization === AttestationSigner.CANONICALIZATION.LEGACY
141
+ ? AttestationSigner._canonicalizeJSON(credential)
142
+ : AttestationSigner._canonicalizeJCS(credential)
104
143
  return Hash.sha256(Buffer.from(canonical, 'utf8'))
105
144
  }
106
145
 
146
+ /**
147
+ * Canonicalization forms understood when hashing a credential.
148
+ *
149
+ * JCS is RFC 8785 and is what everything signs with now. LEGACY is the sorted-key form
150
+ * this library used before, retained so existing credentials keep verifying: it is
151
+ * deterministic but not interoperable, because rebuilding the object let V8 reorder
152
+ * integer-like keys ahead of the sort.
153
+ */
154
+ AttestationSigner.CANONICALIZATION = {
155
+ JCS: 'jcs',
156
+ LEGACY: 'legacy'
157
+ }
158
+
107
159
  /**
108
160
  * Create base credential structure
109
161
  * @param {Object} credentialSubject - Subject data
@@ -306,18 +306,23 @@ AttestationVerifier._verifySignature = async function(credential) {
306
306
  delete credentialCopy.proof
307
307
  delete credentialCopy.rootHash
308
308
 
309
- // Create hash
310
- var credentialHash = AttestationSigner._hashCredential(credentialCopy)
311
-
312
- // Verify signature
309
+ // Signatures are made over RFC 8785 canonical JSON. Credentials signed before that
310
+ // change used the older sorted-key form, so both are tried β€” JCS first, so a
311
+ // current credential never pays for the legacy attempt. This is a compatibility
312
+ // path, not a weakening: each form is a complete, deterministic serialization and
313
+ // the signature must verify against one of them exactly.
313
314
  var signature = AttestationVerifier._parseJWSSignature(proof.jws)
314
-
315
- var ecdsa = new ECDSA()
316
- ecdsa.hashbuf = credentialHash
317
- ecdsa.pubkey = publicKey
318
- ecdsa.sig = signature
319
315
 
320
- var valid = ecdsa.verify()
316
+ var valid = [
317
+ AttestationSigner.CANONICALIZATION.JCS,
318
+ AttestationSigner.CANONICALIZATION.LEGACY
319
+ ].some(function (form) {
320
+ var ecdsa = new ECDSA()
321
+ ecdsa.hashbuf = AttestationSigner._hashCredential(credentialCopy, form)
322
+ ecdsa.pubkey = publicKey
323
+ ecdsa.sig = signature
324
+ return ecdsa.verify() === true
325
+ })
321
326
 
322
327
  if (valid) {
323
328
  return {
@@ -369,22 +374,24 @@ AttestationVerifier._verifyPresentationSignature = async function(presentation)
369
374
  var presentationCopy = JSON.parse(JSON.stringify(presentation))
370
375
  delete presentationCopy.proof
371
376
 
372
- // Create hash
373
- var presentationHash = AttestationSigner._hashCredential(presentationCopy)
374
-
375
- // Verify signature
377
+ // Both canonicalizations, for the reason given on the credential path above:
378
+ // presentations signed before the move to RFC 8785 must keep verifying.
376
379
  var signature = AttestationVerifier._parseJWSSignature(proof.jws)
377
-
378
- var ecdsa = new ECDSA()
379
- ecdsa.hashbuf = presentationHash
380
- ecdsa.pubkey = publicKey
381
- ecdsa.sig = signature
382
380
 
383
- var valid = ecdsa.verify()
381
+ var presentationValid = [
382
+ AttestationSigner.CANONICALIZATION.JCS,
383
+ AttestationSigner.CANONICALIZATION.LEGACY
384
+ ].some(function (form) {
385
+ var e = new ECDSA()
386
+ e.hashbuf = AttestationSigner._hashCredential(presentationCopy, form)
387
+ e.pubkey = publicKey
388
+ e.sig = signature
389
+ return e.verify() === true
390
+ })
384
391
 
385
392
  return {
386
- valid: valid,
387
- errors: valid ? [] : ['Presentation signature verification failed']
393
+ valid: presentationValid,
394
+ errors: presentationValid ? [] : ['Presentation signature verification failed']
388
395
  }
389
396
 
390
397
  } catch (error) {
@@ -541,10 +548,19 @@ AttestationVerifier.verifyCredentialHash = function(credential) {
541
548
  delete credentialCopy.proof
542
549
  delete credentialCopy.rootHash
543
550
 
544
- var computedHash = AttestationSigner._hashCredential(credentialCopy)
551
+ // A rootHash written before the move to RFC 8785 was computed over the legacy
552
+ // form, so both are accepted. Buffer.compare on each is still an exact match β€”
553
+ // this widens which serialization is recognised, not what counts as equal.
545
554
  var storedHash = Buffer.from(credential.rootHash, 'hex')
546
-
547
- return Buffer.compare(computedHash, storedHash) === 0
555
+
556
+ return [
557
+ AttestationSigner.CANONICALIZATION.JCS,
558
+ AttestationSigner.CANONICALIZATION.LEGACY
559
+ ].some(function (form) {
560
+ return Buffer.compare(
561
+ AttestationSigner._hashCredential(credentialCopy, form), storedHash
562
+ ) === 0
563
+ })
548
564
  } catch (error) {
549
565
  return false
550
566
  }
@@ -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
  }