Skip to content

Commit 7eb6edd

Browse files
aberohamclaude
andcommitted
fix: address review feedback on password hashing
- Return { valid, needsUpgrade } from validPassword() instead of setting mutable _needsUpgrade state on the instance — eliminates coupling between validPassword() and authenticate() via shared state. - Validate PBKDF2_ITERATIONS env var at module load: warn and fall back to 220000 if the value is not a valid positive integer. - Use strict regex (/^\d+\$[0-9a-f]{64}$/) to validate self-describing hash format before parsing — rejects malformed stored hashes like "220000$abc$def" instead of silently misinterpreting them. - Add comment on raw SQL test helper explaining why User.create() is bypassed (need to insert specific legacy password formats). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1d023e3 commit 7eb6edd

3 files changed

Lines changed: 34 additions & 26 deletions

File tree

lib/user/mysql.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ class UserRepoMySQL extends UserBase {
3636
AND g.name = ?`
3737

3838
for (const u of await Mysql.execute(query, [username, groupName])) {
39-
if (await this.validPassword(authTry.password, u.password, authTry.username, u.pass_salt)) {
40-
if (this._needsUpgrade) {
39+
const { valid, needsUpgrade } = await this.validPassword(authTry.password, u.password, authTry.username, u.pass_salt)
40+
if (valid) {
41+
if (needsUpgrade) {
4142
const salt = this.generateSalt()
4243
const hash = await this.hashForStorage(authTry.password, salt)
4344
await Mysql.execute(

lib/user/test.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,35 +79,35 @@ describe('user', function () {
7979
describe('validPassword', function () {
8080
it('auths user with plain text password', async () => {
8181
const r = await User.validPassword('test', 'test', 'demo', '')
82-
assert.equal(r, true)
82+
assert.deepEqual(r, { valid: true, needsUpgrade: true })
8383
})
8484

8585
it('auths valid self-describing PBKDF2 password', async () => {
8686
const salt = '(ICzAm2.QfCa6.MN'
8787
const hash = await User.hashForStorage('YouGuessedIt!', salt)
8888
const r = await User.validPassword('YouGuessedIt!', hash, 'unit-test', salt)
89-
assert.equal(r, true)
89+
assert.deepEqual(r, { valid: true, needsUpgrade: false })
9090
})
9191

9292
it('rejects invalid self-describing PBKDF2 password', async () => {
9393
const salt = '(ICzAm2.QfCa6.MN'
9494
const hash = await User.hashForStorage('YouGuessedIt!', salt)
9595
const r = await User.validPassword('YouMissedIt!', hash, 'unit-test', salt)
96-
assert.equal(r, false)
96+
assert.deepEqual(r, { valid: false, needsUpgrade: false })
9797
})
9898

9999
it('auths valid legacy PBKDF2-5000 password', async () => {
100100
const salt = '(ICzAm2.QfCa6.MN'
101101
const hash = await User.hashAuthPbkdf2('YouGuessedIt!', salt, 5000)
102102
const r = await User.validPassword('YouGuessedIt!', hash, 'unit-test', salt)
103-
assert.equal(r, true)
103+
assert.deepEqual(r, { valid: true, needsUpgrade: true })
104104
})
105105

106106
it('rejects invalid legacy PBKDF2-5000 password', async () => {
107107
const salt = '(ICzAm2.QfCa6.MN'
108108
const hash = await User.hashAuthPbkdf2('YouGuessedIt!', salt, 5000)
109109
const r = await User.validPassword('YouMissedIt!', hash, 'unit-test', salt)
110-
assert.equal(r, false)
110+
assert.deepEqual(r, { valid: false, needsUpgrade: false })
111111
})
112112

113113
it('auths valid SHA1 password', async () => {
@@ -116,7 +116,7 @@ describe('user', function () {
116116
'083007777a5241d01abba70c938c60d80be60027',
117117
'unit-test',
118118
)
119-
assert.equal(r, true)
119+
assert.deepEqual(r, { valid: true, needsUpgrade: true })
120120
})
121121

122122
it('rejects invalid SHA1 password', async () => {
@@ -125,7 +125,7 @@ describe('user', function () {
125125
'083007777a5241d01abba7Oc938c60d80be60027',
126126
'unit-test',
127127
)
128-
assert.equal(r, false)
128+
assert.deepEqual(r, { valid: false, needsUpgrade: false })
129129
})
130130
})
131131

@@ -179,6 +179,8 @@ describe('user', function () {
179179
return rows[0]
180180
}
181181

182+
// Raw SQL so we can insert specific legacy password formats (plain text,
183+
// SHA-1, PBKDF2-5000) that User.create() would hash on the way in.
182184
async function insertUser(password, passSalt) {
183185
await Mysql.execute(
184186
'INSERT INTO nt_user (nt_user_id, nt_group_id, username, email, first_name, last_name, password, pass_salt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',

lib/user/userBase.js

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import crypto from 'node:crypto'
22

3-
const PBKDF2_ITERATIONS = parseInt(process.env.PBKDF2_ITERATIONS) || 220000
3+
const PBKDF2_ITERATIONS = (() => {
4+
const raw = process.env.PBKDF2_ITERATIONS
5+
if (raw === undefined) return 220000
6+
const parsed = parseInt(raw, 10)
7+
if (Number.isNaN(parsed) || parsed < 1) {
8+
console.warn(`PBKDF2_ITERATIONS="${raw}" is not a valid positive integer, using 220000`)
9+
return 220000
10+
}
11+
return parsed
12+
})()
413
const LEGACY_ITERATIONS = 5000
14+
const SELF_DESCRIBING_RE = /^(\d+)\$([0-9a-f]{64})$/
515

616
/**
717
* User domain class – pure attributes and password business logic.
@@ -78,48 +88,43 @@ class UserBase {
7888
}
7989

8090
async validPassword(passTry, passDb, username, salt) {
81-
this._needsUpgrade = false
82-
8391
if (!salt && passTry === passDb) {
84-
this._needsUpgrade = true
85-
return true
92+
return { valid: true, needsUpgrade: true }
8693
}
8794

8895
if (salt) {
8996
// Self-describing format: "iterations$hexHash" — single hash, no fallback
90-
if (passDb.includes('$')) {
91-
const [storedItersStr, storedHashHex] = passDb.split('$')
92-
const storedIters = parseInt(storedItersStr, 10)
97+
const m = SELF_DESCRIBING_RE.exec(passDb)
98+
if (m) {
99+
const storedIters = parseInt(m[1], 10)
100+
const storedHashHex = m[2]
93101
const hashed = await this.hashAuthPbkdf2(passTry, salt, storedIters)
94102
if (this.debug) console.log(`self-describing: (${hashed === storedHashHex}) ${hashed}`)
95103
if (hashed === storedHashHex) {
96-
if (storedIters < PBKDF2_ITERATIONS) this._needsUpgrade = true
97-
return true
104+
return { valid: true, needsUpgrade: storedIters < PBKDF2_ITERATIONS }
98105
}
99-
return false
106+
return { valid: false, needsUpgrade: false }
100107
}
101108

102109
// Raw hex (legacy NicTool 2 format, implicitly 5000 iterations)
103110
const legacy = await this.hashAuthPbkdf2(passTry, salt, LEGACY_ITERATIONS)
104111
if (this.debug) console.log(`legacy: (${legacy === passDb}) ${legacy}`)
105112
if (legacy === passDb) {
106-
this._needsUpgrade = true
107-
return true
113+
return { valid: true, needsUpgrade: true }
108114
}
109-
return false
115+
return { valid: false, needsUpgrade: false }
110116
}
111117

112118
// Check for HMAC SHA-1 password
113119
if (/^[0-9a-f]{40}$/.test(passDb)) {
114120
const digest = crypto.createHmac('sha1', username.toLowerCase()).update(passTry).digest('hex')
115121
if (this.debug) console.log(`digest: (${digest === passDb}) ${digest}`)
116122
if (digest === passDb) {
117-
this._needsUpgrade = true
118-
return true
123+
return { valid: true, needsUpgrade: true }
119124
}
120125
}
121126

122-
return false
127+
return { valid: false, needsUpgrade: false }
123128
}
124129
}
125130

0 commit comments

Comments
 (0)