Skip to content

Commit 2285482

Browse files
authored
Merge pull request #407 from bsv-blockchain/TOB-21-Hex
Security: Enforce strict hex validation for Hash.toArray (TOB-21)
2 parents d509815 + 62c7b33 commit 2285482

File tree

15 files changed

+338
-97
lines changed

15 files changed

+338
-97
lines changed

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format
55
## Table of Contents
66

77
- [Unreleased](#unreleased)
8+
- [1.9.21 - 2025-12-04](#1921---2025-12-04)
89
- [1.9.20 - 2025-12-02](#1920---2025-12-02)
910
- [1.9.19 - 2025-12-02](#1919---2025-12-02)
1011
- [1.9.18 - 2025-12-02](#1918---2025-12-02)
@@ -192,6 +193,26 @@ All notable changes to this project will be documented in this file. The format
192193
### Security
193194
---
194195

196+
### [1.9.21] - 2025-12-04
197+
198+
### Security
199+
200+
- Implemented strict validation for hex string parsing across the codebase,
201+
addressing TOB-21.
202+
Non-hex characters now cause an immediate error instead of being silently
203+
discarded, preventing message-forgery scenarios.
204+
205+
### Fixed
206+
207+
- Rewrote the `toArray` and `hexToArray` conversion logic to enforce strict
208+
hexadecimal input handling rather than permissive filtering.
209+
- Corrected UTF-8 decoding behavior to ensure invalid byte sequences produce
210+
a single `U+FFFD` replacement character as specified.
211+
- Updated all internal hash and array conversion utilities to maintain consistent
212+
behavior with strong input validation.
213+
214+
---
215+
195216
### [1.9.20] - 2025-12-02
196217

197218
### Security

benchmarks/transaction-bench.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ async function deepInputChain () {
2121
const path = [
2222
[
2323
{ offset: 0, hash: txid, txid: true, duplicate: false },
24-
{ offset: 1, hash: 'otherHash1', txid: false, duplicate: false }
24+
{ offset: 1, hash: 'aa'.repeat(32), txid: false, duplicate: false }
2525
],
26-
[{ offset: 1, hash: 'mergedHash1', txid: false, duplicate: false }]
26+
[{ offset: 1, hash: 'bb'.repeat(32), txid: false, duplicate: false }]
2727
]
2828
const merklePath = new MerklePath(blockHeight, path)
2929
tx.merklePath = merklePath
@@ -65,9 +65,9 @@ async function wideInputSet () {
6565
const path = [
6666
[
6767
{ offset: 0, hash: txid, txid: true, duplicate: false },
68-
{ offset: 1, hash: 'otherHash1', txid: false, duplicate: false }
68+
{ offset: 1, hash: 'aa'.repeat(32), txid: false, duplicate: false }
6969
],
70-
[{ offset: 1, hash: 'mergedHash1', txid: false, duplicate: false }]
70+
[{ offset: 1, hash: 'bb'.repeat(32), txid: false, duplicate: false }]
7171
]
7272
const merklePath = new MerklePath(blockHeight, path)
7373
sourceTx.merklePath = merklePath
@@ -110,9 +110,9 @@ async function largeInputsOutputs () {
110110
const path = [
111111
[
112112
{ offset: 0, hash: txid, txid: true, duplicate: false },
113-
{ offset: 1, hash: 'otherHash1', txid: false, duplicate: false }
113+
{ offset: 1, hash: 'aa'.repeat(32), txid: false, duplicate: false }
114114
],
115-
[{ offset: 1, hash: 'mergedHash1', txid: false, duplicate: false }]
115+
[{ offset: 1, hash: 'bb'.repeat(32), txid: false, duplicate: false }]
116116
]
117117
const merklePath = new MerklePath(blockHeight, path)
118118
sourceTx.merklePath = merklePath
@@ -158,9 +158,9 @@ async function nestedInputs () {
158158
const path = [
159159
[
160160
{ offset: 0, hash: txid, txid: true, duplicate: false },
161-
{ offset: 1, hash: 'otherHash1', txid: false, duplicate: false }
161+
{ offset: 1, hash: 'aa'.repeat(32), txid: false, duplicate: false }
162162
],
163-
[{ offset: 1, hash: 'mergedHash1', txid: false, duplicate: false }]
163+
[{ offset: 1, hash: 'bb'.repeat(32), txid: false, duplicate: false }]
164164
]
165165
const merklePath = new MerklePath(blockHeight, path)
166166
baseTx.merklePath = merklePath

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bsv/sdk",
3-
"version": "1.9.19",
3+
"version": "1.9.20",
44
"type": "module",
55
"description": "BSV Blockchain Software Development Kit",
66
"main": "dist/cjs/mod.js",

src/auth/__tests/Peer.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { SimplifiedFetchTransport } from '../../auth/transports/SimplifiedFetchT
1212
const certifierPrivKey = new PrivateKey(21)
1313
const alicePrivKey = new PrivateKey(22)
1414
const bobPrivKey = new PrivateKey(23)
15+
const DUMMY_REVOCATION_OUTPOINT_HEX = '00'.repeat(36)
1516

1617
jest.mock('../../auth/utils/getVerifiableCertificates')
1718

@@ -101,7 +102,7 @@ describe('Peer class mutual authentication and certificate exchange', () => {
101102
subjectPubKey,
102103
fields,
103104
certificateType,
104-
async () => 'revocationOutpoint' // or any revocation outpoint logic you want
105+
async () => DUMMY_REVOCATION_OUTPOINT_HEX
105106
)
106107

107108
// For test consistency, you could override the auto-generated serialNumber:

src/auth/certificates/MasterCertificate.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export class MasterCertificate extends Certificate {
232232
certificateType: string,
233233
getRevocationOutpoint = async (_serial: string): Promise<string> => {
234234
void _serial // Explicitly acknowledge unused parameter
235-
return 'Certificate revocation not tracked.'
235+
return '00'.repeat(32)
236236
},
237237
serialNumber?: string
238238
): Promise<MasterCertificate> {
@@ -246,11 +246,18 @@ export class MasterCertificate extends Certificate {
246246
// 3. Obtain a revocation outpoint
247247
const revocationOutpoint = await getRevocationOutpoint(finalSerialNumber)
248248

249+
let subjectIdentityKey: string
250+
if (subject === 'self') {
251+
subjectIdentityKey = (await certifierWallet.getPublicKey({ identityKey: true })).publicKey
252+
} else {
253+
subjectIdentityKey = subject
254+
}
255+
249256
// 4. Create new MasterCertificate instance
250257
const certificate = new MasterCertificate(
251258
certificateType,
252259
finalSerialNumber,
253-
subject,
260+
subjectIdentityKey,
254261
(await certifierWallet.getPublicKey({ identityKey: true })).publicKey,
255262
revocationOutpoint,
256263
certificateFields,

src/auth/certificates/__tests/MasterCertificate.test.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ const verifierKey2 = new PrivateKey(81)
1414

1515
// A mock revocation outpoint for testing
1616
const mockRevocationOutpoint =
17-
'deadbeefdeadbeefdeadbeefdeadbeef00000000000000000000000000000000.1'
17+
'deadbeefdeadbeefdeadbeefdeadbeef00000001'
18+
1819

1920
// Arbitrary certificate data (in plaintext)
2021
const plaintextFields = {
@@ -355,33 +356,69 @@ describe('MasterCertificate', () => {
355356
expect(newCert.fields[fieldName]).toMatch(/^[A-Za-z0-9+/]+=*$/)
356357
}
357358
})
359+
358360
it('should allow issuing a self-signed certificate and decrypt it with the same wallet', async () => {
359-
// In a self-signed scenario, the subject and certifier are the same
360361
const subjectWallet = new CompletedProtoWallet(subjectKey2)
361362

362-
// Some sample fields
363363
const selfSignedFields = {
364364
owner: 'Bob',
365365
organization: 'SelfCo'
366366
}
367367

368-
// Issue the certificate for "self"
368+
const subjectIdentityKey = (
369+
await subjectWallet.getPublicKey({ identityKey: true })
370+
).publicKey
371+
372+
// Issue the certificate: subject = actual identity key (valid hex)
369373
const selfSignedCert = await MasterCertificate.issueCertificateForSubject(
370-
subjectWallet, // act as certifier
371-
'self',
374+
subjectWallet, // acts as certifier
375+
subjectIdentityKey, // <-- was 'self', now real hex
372376
selfSignedFields,
373377
'SELF_SIGNED_TEST'
374378
)
375379

376-
// Now we attempt to decrypt the fields with the same wallet
380+
// Decrypt with the same wallet
377381
const decrypted = await MasterCertificate.decryptFields(
378382
subjectWallet,
379383
selfSignedCert.masterKeyring,
380384
selfSignedCert.fields,
381-
'self'
385+
'self' // still fine here if decryptFields treats 'self' specially
382386
)
383387

384388
expect(decrypted).toEqual(selfSignedFields)
385389
})
390+
391+
it('resolves subject === "self" to the certifier wallet identity key', async () => {
392+
const certifierWallet = new CompletedProtoWallet(new PrivateKey(99))
393+
394+
const certifierIdentityKey = (
395+
await certifierWallet.getPublicKey({ identityKey: true })
396+
).publicKey
397+
398+
const cert = await MasterCertificate.issueCertificateForSubject(
399+
certifierWallet,
400+
'self',
401+
{ name: 'Alice' },
402+
'TEST_CERT'
403+
)
404+
405+
expect(cert.subject).toBe(certifierIdentityKey)
406+
})
407+
408+
it('uses provided subjectIdentityKey when subject is a valid hex string', async () => {
409+
const certifierWallet = new CompletedProtoWallet(new PrivateKey(42))
410+
411+
const validPubkey =
412+
'0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798'
413+
414+
const cert = await MasterCertificate.issueCertificateForSubject(
415+
certifierWallet,
416+
validPubkey,
417+
{ name: 'Alice' },
418+
'TEST_CERT'
419+
)
420+
421+
expect(cert.subject).toBe(validPubkey)
422+
})
386423
})
387-
})
424+
})

src/primitives/Hash.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11

22
// @ts-nocheck
33
/* eslint-disable @typescript-eslint/naming-convention */
4+
import { assertValidHex, normalizeHex } from './hex.js'
5+
46
const assert = (
57
expression: unknown,
68
message: string = 'Hash assertion failed'
@@ -169,6 +171,10 @@ abstract class BaseHash {
169171
*/
170172
private _pad (): number[] {
171173
const len = this.pendingTotal
174+
if (!Number.isSafeInteger(len) || len < 0) {
175+
throw new Error('Message too long for this hash function')
176+
}
177+
172178
const bytes = this._delta8
173179
const k = bytes - ((len + this.padLength) % bytes)
174180
const res = new Array(k + this.padLength)
@@ -177,8 +183,6 @@ abstract class BaseHash {
177183
for (i = 1; i < k; i++) {
178184
res[i] = 0
179185
}
180-
181-
// Append length
182186
const lengthBytes = this.padLength
183187
const maxBits = 1n << BigInt(lengthBytes * 8)
184188
let totalBits = BigInt(len) * 8n
@@ -204,6 +208,7 @@ abstract class BaseHash {
204208
totalBits >>= 8n
205209
}
206210
}
211+
207212
return res
208213
}
209214
}
@@ -262,10 +267,8 @@ export function toArray (
262267
}
263268
}
264269
} else {
265-
msg = msg.replace(/[^a-z0-9]+/gi, '')
266-
if (msg.length % 2 !== 0) {
267-
msg = '0' + msg
268-
}
270+
assertValidHex(msg)
271+
msg = normalizeHex(msg)
269272
for (let i = 0; i < msg.length; i += 2) {
270273
res.push(parseInt(msg[i] + msg[i + 1], 16))
271274
}

src/primitives/__tests/HMAC.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,22 @@ describe('HMAC', function () {
4848
res: 'cf5ad5984f9e43917aa9087380dac46e410ddc8a7731859c84e9d0f31bd43655'
4949
})
5050

51+
function normalizeKey (key: string | number[]): string | number[] {
52+
if (typeof key === 'string') {
53+
// test-only helper: remove whitespace between hex groups
54+
return key.replace(/\s+/g, '')
55+
}
56+
return key
57+
}
58+
5159
function test (opt): void {
5260
it(`should not fail at ${opt.name as string}`, function (): void {
53-
let h = new SHA256HMAC(opt.key)
61+
const key = normalizeKey(opt.key)
62+
63+
let h = new SHA256HMAC(key as any)
5464
expect(h.update(opt.msg, opt.msgEnc).digestHex()).toEqual(opt.res)
55-
h = h = new SHA256HMAC(opt.key)
65+
66+
h = new SHA256HMAC(key as any)
5667
expect(
5768
h
5869
.update(opt.msg.slice(0, 10), opt.msgEnc)

src/primitives/__tests/Hash.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as hash from '../../primitives/Hash'
33
import * as crypto from 'crypto'
44
import PBKDF2Vectors from './PBKDF2.vectors'
55
import { toArray, toHex } from '../../primitives/utils'
6+
import { SHA1 } from '../..//primitives/Hash'
67

78
describe('Hash', function () {
89
function test (Hash, cases): void {
@@ -177,4 +178,27 @@ describe('Hash', function () {
177178
})
178179
}
179180
})
181+
182+
describe('Hash strict length validation (TOB-21)', () => {
183+
184+
it('throws when pendingTotal is not a safe integer', () => {
185+
const h = new SHA1()
186+
187+
h.pendingTotal = Number.MAX_SAFE_INTEGER + 10
188+
189+
expect(() => {
190+
h.digest()
191+
}).toThrow('Message too long for this hash function')
192+
})
193+
194+
it('throws when pendingTotal is negative', () => {
195+
const h = new SHA1()
196+
197+
h.pendingTotal = -5
198+
199+
expect(() => {
200+
h.digest()
201+
}).toThrow('Message too long for this hash function')
202+
})
203+
})
180204
})

0 commit comments

Comments
 (0)