Skip to content

Commit be01587

Browse files
aberohamclaude
andcommitted
wip: upgrade legacy password hashes on login (#30)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1bcf896 commit be01587

3 files changed

Lines changed: 149 additions & 6 deletions

File tree

lib/user/mysql.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ class UserRepoMySQL extends UserBase {
3737

3838
for (const u of await Mysql.execute(query, [username, groupName])) {
3939
if (await this.validPassword(authTry.password, u.password, authTry.username, u.pass_salt)) {
40+
if (this._needsUpgrade) {
41+
const salt = this.generateSalt()
42+
const hash = await this.hashAuthPbkdf2(authTry.password, salt)
43+
await Mysql.execute(
44+
...Mysql.update('nt_user', `nt_user_id=${u.id}`, {
45+
password: hash,
46+
pass_salt: salt,
47+
}),
48+
)
49+
}
4050
for (const f of ['password', 'pass_salt']) {
4151
delete u[f] // SECURITY: no longer needed
4252
}

lib/user/test.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import assert from 'node:assert/strict'
2+
import crypto from 'node:crypto'
23
import { describe, it, after, before } from 'node:test'
34

45
import User from './index.js'
56
import Group from '../group/index.js'
7+
import Mysql from '../mysql.js'
68

79
import userCase from '../test/user.json' with { type: 'json' }
810
import groupCase from '../test/group.json' with { type: 'json' }
@@ -144,4 +146,117 @@ describe('user', function () {
144146
assert.ok(u)
145147
})
146148
})
149+
150+
describe('password upgrade on login', () => {
151+
const upgradeUserId = 4200
152+
const upgradeUser = {
153+
nt_user_id: upgradeUserId,
154+
nt_group_id: groupCase.id,
155+
username: 'upgrade-test',
156+
email: 'upgrade-test@example.com',
157+
first_name: 'Upgrade',
158+
last_name: 'Test',
159+
}
160+
const testPass = 'UpgradeMe!123'
161+
const authCreds = {
162+
username: `${upgradeUser.username}@${groupCase.name}`,
163+
password: testPass,
164+
}
165+
166+
async function getDbPassword() {
167+
const rows = await Mysql.execute(
168+
'SELECT password, pass_salt FROM nt_user WHERE nt_user_id = ?',
169+
[upgradeUserId],
170+
)
171+
return rows[0]
172+
}
173+
174+
async function insertUser(password, passSalt) {
175+
await Mysql.execute(
176+
'INSERT INTO nt_user (nt_user_id, nt_group_id, username, email, first_name, last_name, password, pass_salt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
177+
[upgradeUserId, upgradeUser.nt_group_id, upgradeUser.username, upgradeUser.email, upgradeUser.first_name, upgradeUser.last_name, password, passSalt],
178+
)
179+
}
180+
181+
async function cleanup() {
182+
await Mysql.execute(
183+
'DELETE FROM nt_user WHERE nt_user_id = ?',
184+
[upgradeUserId],
185+
)
186+
}
187+
188+
before(cleanup)
189+
after(cleanup)
190+
191+
it('upgrades plain text password to PBKDF2 on login', async () => {
192+
await cleanup()
193+
await insertUser(testPass, null)
194+
195+
const result = await User.authenticate(authCreds)
196+
assert.ok(result, 'login should succeed')
197+
198+
const row = await getDbPassword()
199+
assert.ok(row.pass_salt, 'pass_salt should be set after upgrade')
200+
assert.notEqual(row.password, testPass, 'password should be hashed')
201+
202+
// verify round-trip: can still log in with the upgraded hash
203+
const again = await User.authenticate(authCreds)
204+
assert.ok(again, 'login should succeed after upgrade')
205+
await cleanup()
206+
})
207+
208+
it('upgrades SHA1 password to PBKDF2 on login', async () => {
209+
// authenticate() passes the full authTry.username (including @group) to
210+
// validPassword(), so the HMAC key must match that full string
211+
const sha1Hash = crypto
212+
.createHmac('sha1', authCreds.username.toLowerCase())
213+
.update(testPass)
214+
.digest('hex')
215+
await cleanup()
216+
await insertUser(sha1Hash, null)
217+
218+
const result = await User.authenticate(authCreds)
219+
assert.ok(result, 'login should succeed with SHA1 hash')
220+
221+
const row = await getDbPassword()
222+
assert.ok(row.pass_salt, 'pass_salt should be set after upgrade')
223+
assert.notEqual(row.password, sha1Hash, 'password should be re-hashed')
224+
225+
const again = await User.authenticate(authCreds)
226+
assert.ok(again, 'login should succeed after upgrade')
227+
await cleanup()
228+
})
229+
230+
it('upgrades PBKDF2-5000 to current iterations on login', async () => {
231+
const legacySalt = User.generateSalt()
232+
const legacyHash = await User.hashAuthPbkdf2(testPass, legacySalt, 5000)
233+
await cleanup()
234+
await insertUser(legacyHash, legacySalt)
235+
236+
const result = await User.authenticate(authCreds)
237+
assert.ok(result, 'login should succeed with legacy PBKDF2')
238+
239+
const row = await getDbPassword()
240+
assert.notEqual(row.password, legacyHash, 'password should be re-hashed')
241+
assert.notEqual(row.pass_salt, legacySalt, 'salt should be regenerated')
242+
243+
const again = await User.authenticate(authCreds)
244+
assert.ok(again, 'login should succeed after upgrade')
245+
await cleanup()
246+
})
247+
248+
it('does not re-hash password already at current iterations', async () => {
249+
const salt = User.generateSalt()
250+
const hash = await User.hashAuthPbkdf2(testPass, salt)
251+
await cleanup()
252+
await insertUser(hash, salt)
253+
254+
await User.authenticate(authCreds)
255+
256+
const row = await getDbPassword()
257+
assert.equal(row.password, hash, 'password should be unchanged')
258+
assert.equal(row.pass_salt, salt, 'salt should be unchanged')
259+
await cleanup()
260+
})
261+
})
147262
})

lib/user/userBase.js

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import crypto from 'node:crypto'
22

3+
const PBKDF2_ITERATIONS = parseInt(process.env.PBKDF2_ITERATIONS) || 220000
4+
const LEGACY_ITERATIONS = 5000
5+
36
/**
47
* User domain class – pure attributes and password business logic.
58
*
@@ -60,30 +63,45 @@ class UserBase {
6063
return salt
6164
}
6265

63-
async hashAuthPbkdf2(pass, salt) {
66+
async hashAuthPbkdf2(pass, salt, iterations = PBKDF2_ITERATIONS) {
6467
return new Promise((resolve, reject) => {
65-
// match the defaults for NicTool 2.x
66-
crypto.pbkdf2(pass, salt, 5000, 32, 'sha512', (err, derivedKey) => {
68+
crypto.pbkdf2(pass, salt, iterations, 32, 'sha512', (err, derivedKey) => {
6769
if (err) return reject(err)
6870
resolve(derivedKey.toString('hex'))
6971
})
7072
})
7173
}
7274

7375
async validPassword(passTry, passDb, username, salt) {
74-
if (!salt && passTry === passDb) return true // plain pass, TODO, encrypt!
76+
this._needsUpgrade = false
77+
78+
if (!salt && passTry === passDb) {
79+
this._needsUpgrade = true
80+
return true
81+
}
7582

7683
if (salt) {
7784
const hashed = await this.hashAuthPbkdf2(passTry, salt)
7885
if (this.debug) console.log(`hashed: (${hashed === passDb}) ${hashed}`)
79-
return hashed === passDb
86+
if (hashed === passDb) return true
87+
// fall back to NicTool 2 legacy iteration count
88+
const legacy = await this.hashAuthPbkdf2(passTry, salt, LEGACY_ITERATIONS)
89+
if (this.debug) console.log(`legacy: (${legacy === passDb}) ${legacy}`)
90+
if (legacy === passDb) {
91+
this._needsUpgrade = true
92+
return true
93+
}
94+
return false
8095
}
8196

8297
// Check for HMAC SHA-1 password
8398
if (/^[0-9a-f]{40}$/.test(passDb)) {
8499
const digest = crypto.createHmac('sha1', username.toLowerCase()).update(passTry).digest('hex')
85100
if (this.debug) console.log(`digest: (${digest === passDb}) ${digest}`)
86-
return digest === passDb
101+
if (digest === passDb) {
102+
this._needsUpgrade = true
103+
return true
104+
}
87105
}
88106

89107
return false

0 commit comments

Comments
 (0)