Skip to content

Commit fa011bb

Browse files
Fix: Harden hex-string handling (TOB-21) and update dependent tests
1 parent 3649036 commit fa011bb

File tree

9 files changed

+155
-95
lines changed

9 files changed

+155
-95
lines changed

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: 8 additions & 1 deletion
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,6 +246,13 @@ 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,

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

Lines changed: 12 additions & 8 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 = {
@@ -356,29 +357,32 @@ describe('MasterCertificate', () => {
356357
}
357358
})
358359
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
360360
const subjectWallet = new CompletedProtoWallet(subjectKey2)
361361

362-
// Some sample fields
363362
const selfSignedFields = {
364363
owner: 'Bob',
365364
organization: 'SelfCo'
366365
}
367366

368-
// Issue the certificate for "self"
367+
// ✅ FIX: resolve the subject's identity key as proper hex
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)

src/primitives/Hash.ts

Lines changed: 42 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -170,51 +170,53 @@ abstract class BaseHash {
170170
* @returns Returns an array denoting the padding.
171171
*/
172172
private _pad (): number[] {
173-
//
174-
let len = this.pendingTotal
175-
const bytes = this._delta8
176-
const k = bytes - ((len + this.padLength) % bytes)
177-
const res = new Array(k + this.padLength)
178-
res[0] = 0x80
179-
let i
180-
for (i = 1; i < k; i++) {
181-
res[i] = 0
182-
}
173+
const len = this.pendingTotal
183174

184-
// Append length
185-
len <<= 3
186-
let t
187-
if (this.endian === 'big') {
188-
for (t = 8; t < this.padLength; t++) {
189-
res[i++] = 0
190-
}
175+
// 🔐 New: guarantee len is a sane byte count
176+
if (!Number.isSafeInteger(len) || len < 0) {
177+
// Anything outside the safe integer range (or negative)
178+
// must be treated as "too long" by definition.
179+
throw new Error('Message too long for this hash function')
180+
}
191181

192-
res[i++] = 0
193-
res[i++] = 0
194-
res[i++] = 0
195-
res[i++] = 0
196-
res[i++] = (len >>> 24) & 0xff
197-
res[i++] = (len >>> 16) & 0xff
198-
res[i++] = (len >>> 8) & 0xff
199-
res[i++] = len & 0xff
200-
} else {
201-
res[i++] = len & 0xff
202-
res[i++] = (len >>> 8) & 0xff
203-
res[i++] = (len >>> 16) & 0xff
204-
res[i++] = (len >>> 24) & 0xff
205-
res[i++] = 0
206-
res[i++] = 0
207-
res[i++] = 0
208-
res[i++] = 0
209-
210-
for (t = 8; t < this.padLength; t++) {
211-
res[i++] = 0
212-
}
182+
const bytes = this._delta8
183+
const k = bytes - ((len + this.padLength) % bytes)
184+
const res = new Array(k + this.padLength)
185+
res[0] = 0x80
186+
let i: number
187+
for (i = 1; i < k; i++) {
188+
res[i] = 0
189+
}
190+
191+
// Append length
192+
const lengthBytes = this.padLength
193+
const maxBits = 1n << BigInt(lengthBytes * 8)
194+
let totalBits = BigInt(len) * 8n
195+
196+
if (totalBits >= maxBits) {
197+
throw new Error('Message too long for this hash function')
198+
}
199+
200+
if (this.endian === 'big') {
201+
const lenArray = new Array<number>(lengthBytes)
202+
203+
for (let b = lengthBytes - 1; b >= 0; b--) {
204+
lenArray[b] = Number(totalBits & 0xffn)
205+
totalBits >>= 8n
213206
}
214207

215-
return res
208+
for (let b = 0; b < lengthBytes; b++) {
209+
res[i++] = lenArray[b]
210+
}
211+
} else {
212+
for (let b = 0; b < lengthBytes; b++) {
213+
res[i++] = Number(totalBits & 0xffn)
214+
totalBits >>= 8n
215+
}
216216
}
217-
}
217+
218+
return res
219+
}}
218220

219221
function isSurrogatePair (msg: string, i: number): boolean {
220222
if ((msg.charCodeAt(i) & 0xfc00) !== 0xd800) {

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/hex.test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { assertValidHex, normalizeHex } from '../../primitives/hex'
55
describe('hex utils', () => {
66
describe('assertValidHex', () => {
77
it('should not throw on valid hex strings', () => {
8+
expect(() => assertValidHex('')).not.toThrow() // empty is allowed
89
expect(() => assertValidHex('00')).not.toThrow()
910
expect(() => assertValidHex('abcdef')).not.toThrow()
1011
expect(() => assertValidHex('ABCDEF')).not.toThrow()
@@ -18,13 +19,14 @@ describe('hex utils', () => {
1819
expect(() => assertValidHex('g1')).toThrow('Invalid hex string')
1920
})
2021

21-
it('should throw on empty string', () => {
22-
expect(() => assertValidHex('')).toThrow('Invalid hex string')
23-
})
22+
// ❌ old behavior: empty string was considered invalid
23+
// it('should throw on empty string', () => {
24+
// expect(() => assertValidHex('')).toThrow('Invalid hex string')
25+
// })
2426

2527
it('should throw on undefined or null', () => {
26-
expect(() => assertValidHex(undefined as any)).toThrow()
27-
expect(() => assertValidHex(null as any)).toThrow()
28+
expect(() => assertValidHex(undefined as any)).toThrow('Invalid hex string')
29+
expect(() => assertValidHex(null as any)).toThrow('Invalid hex string')
2830
})
2931
})
3032

@@ -43,6 +45,10 @@ describe('hex utils', () => {
4345
expect(normalizeHex('001122')).toBe('001122')
4446
})
4547

48+
it('should return empty string unchanged', () => {
49+
expect(normalizeHex('')).toBe('')
50+
})
51+
4652
it('should throw on invalid hex', () => {
4753
expect(() => normalizeHex('xyz')).toThrow('Invalid hex string')
4854
expect(() => normalizeHex('12 34')).toThrow('Invalid hex string')

src/primitives/hex.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,31 @@ const PURE_HEX_REGEX = /^[0-9a-fA-F]*$/;
55

66
export function assertValidHex(msg: string): void {
77
if (typeof msg !== 'string') {
8+
console.error("assertValidHex FAIL (non-string):", msg);
89
throw new Error('Invalid hex string');
910
}
1011

11-
// Allow empty strings (valid empty byte arrays)
12-
if (msg.length === 0) return;
12+
// allow empty
13+
if (msg.length === 0) return
1314

1415
if (!PURE_HEX_REGEX.test(msg)) {
15-
throw new Error('Invalid hex string');
16+
console.error("assertValidHex FAIL (bad hex):", msg)
17+
throw new Error('Invalid hex string')
1618
}
1719
}
1820

1921
export function normalizeHex(msg: string): string {
2022
assertValidHex(msg);
2123

2224
// If empty, return empty — never force to "00"
23-
if (msg.length === 0) return '';
25+
if (msg.length === 0) return ''
2426

25-
let normalized = msg.toLowerCase();
27+
let normalized = msg.toLowerCase()
2628

2729
// Pad odd-length hex
2830
if (normalized.length % 2 !== 0) {
29-
normalized = '0' + normalized;
31+
normalized = '0' + normalized
3032
}
3133

32-
return normalized;
34+
return normalized
3335
}

src/primitives/utils.ts

Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -225,54 +225,81 @@ function utf8ToArray (str: string): number[] {
225225
*/
226226
export const toUTF8 = (arr: number[]): string => {
227227
let result = ''
228-
let skip = 0
229-
228+
const replacementChar = '\uFFFD'
230229
for (let i = 0; i < arr.length; i++) {
231-
const byte = arr[i]
232-
233-
// this byte is part of a multi-byte sequence, skip it
234-
// added to avoid modifying i within the loop which is considered unsafe.
235-
if (skip > 0) {
236-
skip--
230+
const byte1 = arr[i]
231+
if (byte1 <= 0x7f) {
232+
result += String.fromCharCode(byte1)
237233
continue
238234
}
239-
240-
// 1-byte sequence (0xxxxxxx)
241-
if (byte <= 0x7f) {
242-
result += String.fromCharCode(byte)
243-
} else if (byte >= 0xc0 && byte <= 0xdf) {
244-
// 2-byte sequence (110xxxxx 10xxxxxx)
235+
const emitReplacement = () => {
236+
result += replacementChar
237+
}
238+
if (byte1 >= 0xc0 && byte1 <= 0xdf) {
239+
if (i + 1 >= arr.length) {
240+
emitReplacement()
241+
continue
242+
}
245243
const byte2 = arr[i + 1]
246-
skip = 1
247-
const codePoint = ((byte & 0x1f) << 6) | (byte2 & 0x3f)
244+
if ((byte2 & 0xc0) !== 0x80) {
245+
emitReplacement()
246+
continue
247+
}
248+
const codePoint = ((byte1 & 0x1f) << 6) | (byte2 & 0x3f)
248249
result += String.fromCharCode(codePoint)
249-
} else if (byte >= 0xe0 && byte <= 0xef) {
250-
// 3-byte sequence (1110xxxx 10xxxxxx 10xxxxxx)
250+
i += 1
251+
continue
252+
}
253+
if (byte1 >= 0xe0 && byte1 <= 0xef) {
254+
if (i + 2 >= arr.length) {
255+
emitReplacement()
256+
continue
257+
}
251258
const byte2 = arr[i + 1]
252259
const byte3 = arr[i + 2]
253-
skip = 2
260+
if ((byte2 & 0xc0) !== 0x80 || (byte3 & 0xc0) !== 0x80) {
261+
emitReplacement()
262+
continue
263+
}
254264
const codePoint =
255-
((byte & 0x0f) << 12) | ((byte2 & 0x3f) << 6) | (byte3 & 0x3f)
265+
((byte1 & 0x0f) << 12) |
266+
((byte2 & 0x3f) << 6) |
267+
(byte3 & 0x3f)
268+
256269
result += String.fromCharCode(codePoint)
257-
} else if (byte >= 0xf0 && byte <= 0xf7) {
258-
// 4-byte sequence (11110xxx 10xxxxxx 10xxxxxx 10xxxxxx)
270+
i += 2
271+
continue
272+
}
273+
if (byte1 >= 0xf0 && byte1 <= 0xf7) {
274+
if (i + 3 >= arr.length) {
275+
emitReplacement()
276+
continue
277+
}
259278
const byte2 = arr[i + 1]
260279
const byte3 = arr[i + 2]
261280
const byte4 = arr[i + 3]
262-
skip = 3
281+
if (
282+
(byte2 & 0xc0) !== 0x80 ||
283+
(byte3 & 0xc0) !== 0x80 ||
284+
(byte4 & 0xc0) !== 0x80
285+
) {
286+
emitReplacement()
287+
continue
288+
}
263289
const codePoint =
264-
((byte & 0x07) << 18) |
290+
((byte1 & 0x07) << 18) |
265291
((byte2 & 0x3f) << 12) |
266292
((byte3 & 0x3f) << 6) |
267293
(byte4 & 0x3f)
268-
269-
// Convert to UTF-16 surrogate pair
270-
const surrogate1 = 0xd800 + ((codePoint - 0x10000) >> 10)
271-
const surrogate2 = 0xdc00 + ((codePoint - 0x10000) & 0x3ff)
272-
result += String.fromCharCode(surrogate1, surrogate2)
294+
const offset = codePoint - 0x10000
295+
const highSurrogate = 0xd800 + (offset >> 10)
296+
const lowSurrogate = 0xdc00 + (offset & 0x3ff)
297+
result += String.fromCharCode(highSurrogate, lowSurrogate)
298+
i += 3
299+
continue
273300
}
301+
emitReplacement()
274302
}
275-
276303
return result
277304
}
278305

0 commit comments

Comments
 (0)