Skip to content

Commit a2c6ffb

Browse files
committed
refactor: clean code and improve input sanityzing
1 parent 6fbc56a commit a2c6ffb

11 files changed

Lines changed: 254 additions & 23 deletions

File tree

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ jobs:
2828
registry-url: "https://registry.npmjs.org"
2929

3030
- run: bun install
31-
- run: bun ci
3231
- run: bun run build
32+
- run: bun run ci
3333

3434
- name: "Get Previous tag"
3535
id: previousTag
@@ -56,9 +56,9 @@ jobs:
5656
echo "next_version=$next_version" >> $GITHUB_ENV
5757
5858
# We publish on NPM before releasing on GitHub to avoid releasing a version that is not published
59-
- run: npm publish --access=public
59+
- run: bun publish --access public
6060
env:
61-
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
61+
NPM_CONFIG_TOKEN: ${{secrets.NPM_TOKEN}}
6262

6363
- name: Create tag
6464
uses: actions/github-script@v7

bun.lock

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

src/SRPInt.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BigInteger } from 'jsbn'
22
import { getRandomValues } from './crypto'
3-
import { bufferToHex } from './utils'
3+
import { bufferToHex, sanitizeHex } from './utils'
44

55
const bi = Symbol('big-int')
66

@@ -17,7 +17,20 @@ export class SRPInt {
1717
static ZERO = new SRPInt(new BigInteger('0'), null)
1818

1919
static fromHex(hex: string): SRPInt {
20-
const sanitized = hex.replace(/\s+/g, '').toLowerCase()
20+
const sanitized = sanitizeHex(hex)
21+
22+
if (sanitized.length === 0) {
23+
throw new RangeError(
24+
'Expected string to contain at least one hexadecimal character',
25+
)
26+
}
27+
28+
if (!/^[\da-f]+$/.test(sanitized)) {
29+
throw new RangeError(
30+
'Expected string to only contain hexadecimal characters',
31+
)
32+
}
33+
2134
return new SRPInt(new BigInteger(sanitized, 16), sanitized.length)
2235
}
2336

src/client.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import { getParams } from './params'
33
import { SRPError } from './SRPError'
44
import { SRPInt } from './SRPInt'
55
import type { Ephemeral, HashAlgorithm, PrimeGroup, Session } from './types'
6-
import { bufferToHex, hexToBuffer } from './utils'
6+
import { bufferToHex, constantTimeEqualHex, hexToBuffer } from './utils'
77

88
export const createSRPClient = (
99
hashAlgorithm: HashAlgorithm,
1010
primeGroup: PrimeGroup,
1111
) => {
12-
const { N, g, k, H, PAD, hashBytes } = getParams(hashAlgorithm, primeGroup)
12+
const { N, g, k, H, PAD, hashBytes, ephemeralSecretBytes } = getParams(
13+
hashAlgorithm,
14+
primeGroup,
15+
)
1316

1417
return {
1518
generateSalt: (): string => {
@@ -53,7 +56,7 @@ export const createSRPClient = (
5356
},
5457

5558
generateEphemeral: (): Ephemeral => {
56-
const a = SRPInt.getRandom(hashBytes)
59+
const a = SRPInt.getRandom(ephemeralSecretBytes)
5760

5861
// A = g^a (a = random number)
5962
const A = g.modPow(a, N)
@@ -115,10 +118,9 @@ export const createSRPClient = (
115118
const K = SRPInt.fromHex(clientSession.key) // Shared, strong session key
116119

117120
// H(A, M, K)
118-
const expected = await H(A, M, K)
119-
const actual = SRPInt.fromHex(serverSessionProof)
121+
const expected = (await H(A, M, K)).toHex()
120122

121-
if (!actual.equals(expected)) {
123+
if (!constantTimeEqualHex(serverSessionProof, expected)) {
122124
throw new SRPError('server', 'InvalidSessionProof')
123125
}
124126
},

src/crypto.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ const pbkdf2Iterations: Record<HashAlgorithm, number> = {
2222
}
2323

2424
export const getRandomValues = (array: Uint8Array): void => {
25-
webcrypto?.getRandomValues(array)
25+
if (!webcrypto) {
26+
throw new Error(unavailableErrorMessage)
27+
}
28+
29+
webcrypto.getRandomValues(array)
2630
}
2731

2832
export const digest = (

src/params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export const getParams = (
165165
const g = SRPInt.fromHex(group.g)
166166

167167
const paddedHexLength = N.hexLength ?? 0 // N.hexLength is never null
168+
const ephemeralSecretBytes = Math.max(hashBytes[hashAlgorithm], 32)
168169

169170
const H = (...input: (SRPInt | string)[]) => hash(hashAlgorithm, ...input)
170171
const PAD = (integer: SRPInt) => integer.pad(paddedHexLength)
@@ -177,5 +178,6 @@ export const getParams = (
177178
H, // One-way hash function
178179
PAD, // Pad function to have the same number of bytes as N
179180
hashBytes: hashBytes[hashAlgorithm], // Hash function output length
181+
ephemeralSecretBytes, // Random secret length for ephemeral exponents
180182
}
181183
}

src/server.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,23 @@ import { getParams } from './params'
22
import { SRPError } from './SRPError'
33
import { SRPInt } from './SRPInt'
44
import type { Ephemeral, HashAlgorithm, PrimeGroup, Session } from './types'
5+
import { constantTimeEqualHex } from './utils'
56

67
export const createSRPServer = (
78
hashAlgorithm: HashAlgorithm,
89
primeGroup: PrimeGroup,
910
) => {
10-
const { N, g, k, H, PAD, hashBytes } = getParams(hashAlgorithm, primeGroup)
11+
const { N, g, k, H, PAD, ephemeralSecretBytes } = getParams(
12+
hashAlgorithm,
13+
primeGroup,
14+
)
1115

1216
return {
1317
generateEphemeral: async (verifier: string): Promise<Ephemeral> => {
1418
const v = SRPInt.fromHex(verifier) // Password verifier
1519

1620
// B = kv + g^b (b = random number)
17-
const b = SRPInt.getRandom(hashBytes)
21+
const b = SRPInt.getRandom(ephemeralSecretBytes)
1822
const B = (await k()).multiply(v).add(g.modPow(b, N)).mod(N)
1923

2024
return {
@@ -56,10 +60,9 @@ export const createSRPServer = (
5660
const [K, HN, Hg, HI] = await Promise.all([H(S), H(N), H(g), H(I)])
5761
const M = await H(HN.xor(Hg), HI, s, A, B, K)
5862

59-
const expected = M
60-
const actual = SRPInt.fromHex(clientSessionProof)
63+
const expected = M.toHex()
6164

62-
if (!actual.equals(expected)) {
65+
if (!constantTimeEqualHex(clientSessionProof, expected)) {
6366
throw new SRPError('client', 'InvalidSessionProof')
6467
}
6568

src/utils.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
export const encodeUtf8 = TextEncoder.prototype.encode.bind(new TextEncoder())
2+
const hexPattern = /^[\da-f]+$/i
3+
4+
export const sanitizeHex = (hex: string): string =>
5+
hex.replace(/\s+/g, '').toLowerCase()
26

37
export const bufferToHex = (buffer: ArrayBuffer): string => {
48
const array = new Uint8Array(buffer)
@@ -16,15 +20,52 @@ export const bufferToHex = (buffer: ArrayBuffer): string => {
1620
}
1721

1822
export const hexToBuffer = (hex: string): ArrayBuffer => {
19-
if (hex.length % 2 !== 0) {
23+
const sanitized = sanitizeHex(hex)
24+
25+
if (sanitized.length % 2 !== 0) {
2026
throw new RangeError('Expected string to be an even number of characters')
2127
}
2228

23-
const array = new Uint8Array(hex.length / 2)
29+
if (sanitized.length > 0 && !hexPattern.test(sanitized)) {
30+
throw new RangeError(
31+
'Expected string to only contain hexadecimal characters',
32+
)
33+
}
34+
35+
const array = new Uint8Array(sanitized.length / 2)
2436

25-
for (let i = 0; i < hex.length; i += 2) {
26-
array[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16)
37+
for (let i = 0; i < sanitized.length; i += 2) {
38+
array[i / 2] = Number.parseInt(sanitized.substring(i, i + 2), 16)
2739
}
2840

2941
return array.buffer
3042
}
43+
44+
export const constantTimeEqualHex = (
45+
leftHex: string,
46+
rightHex: string,
47+
): boolean => {
48+
const left = sanitizeHex(leftHex)
49+
const right = sanitizeHex(rightHex)
50+
51+
if (
52+
left.length % 2 !== 0 ||
53+
right.length % 2 !== 0 ||
54+
!hexPattern.test(left) ||
55+
!hexPattern.test(right)
56+
) {
57+
return false
58+
}
59+
60+
const leftBytes = new Uint8Array(hexToBuffer(left))
61+
const rightBytes = new Uint8Array(hexToBuffer(right))
62+
const maxLength = Math.max(leftBytes.length, rightBytes.length)
63+
64+
let mismatch = leftBytes.length ^ rightBytes.length
65+
66+
for (let i = 0; i < maxLength; i++) {
67+
mismatch |= (leftBytes[i] ?? 0) ^ (rightBytes[i] ?? 0)
68+
}
69+
70+
return mismatch === 0
71+
}

tests/SRPInt.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,9 @@ test('SRPInt should keep padding when going back and forth', () => {
1212
expect(SRPInt.fromHex('0000000a').toHex()).toStrictEqual('0000000a')
1313
expect(SRPInt.fromHex('00000000a').toHex()).toStrictEqual('00000000a')
1414
})
15+
16+
test('SRPInt should reject malformed hex input', () => {
17+
expect(() => SRPInt.fromHex('')).toThrow(RangeError)
18+
expect(() => SRPInt.fromHex('gg')).toThrow(RangeError)
19+
expect(() => SRPInt.fromHex('-01')).toThrow(RangeError)
20+
})

0 commit comments

Comments
 (0)