Skip to content

Commit 13d2dae

Browse files
aberohamclaude
andcommitted
feat: self-describing password hashes (iterations$hex)
Store PBKDF2 iteration count alongside the hash to eliminate the linear fallback chain that tried current then legacy iterations on every login attempt. A wrong password now costs exactly one PBKDF2 computation regardless of how many iteration baselines have existed. Future iteration bumps (via PBKDF2_ITERATIONS env var) require zero code changes — users at older iterations silently upgrade on login. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent be01587 commit 13d2dae

5 files changed

Lines changed: 53 additions & 27 deletions

File tree

lib/user/mysql.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class UserRepoMySQL extends UserBase {
3939
if (await this.validPassword(authTry.password, u.password, authTry.username, u.pass_salt)) {
4040
if (this._needsUpgrade) {
4141
const salt = this.generateSalt()
42-
const hash = await this.hashAuthPbkdf2(authTry.password, salt)
42+
const hash = await this.hashForStorage(authTry.password, salt)
4343
await Mysql.execute(
4444
...Mysql.update('nt_user', `nt_user_id=${u.id}`, {
4545
password: hash,
@@ -75,7 +75,7 @@ class UserRepoMySQL extends UserBase {
7575

7676
if (args.password) {
7777
if (!args.pass_salt) args.pass_salt = this.generateSalt()
78-
args.password = await this.hashAuthPbkdf2(args.password, args.pass_salt)
78+
args.password = await this.hashForStorage(args.password, args.pass_salt)
7979
}
8080

8181
const userId = await Mysql.execute(...Mysql.insert(`nt_user`, mapToDbColumn(args, userDbMap)))

lib/user/test.js

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -82,23 +82,31 @@ describe('user', function () {
8282
assert.equal(r, true)
8383
})
8484

85-
it('auths valid pbkdb2 password', async () => {
86-
const r = await User.validPassword(
87-
'YouGuessedIt!',
88-
'050cfa70c3582be0d5bfae25138a8486dc2e6790f39bc0c4e111223ba6034432',
89-
'unit-test',
90-
'(ICzAm2.QfCa6.MN',
91-
)
85+
it('auths valid self-describing PBKDF2 password', async () => {
86+
const salt = '(ICzAm2.QfCa6.MN'
87+
const hash = await User.hashForStorage('YouGuessedIt!', salt)
88+
const r = await User.validPassword('YouGuessedIt!', hash, 'unit-test', salt)
9289
assert.equal(r, true)
9390
})
9491

95-
it('rejects invalid pbkdb2 password', async () => {
96-
const r = await User.validPassword(
97-
'YouMissedIt!',
98-
'050cfa70c3582be0d5bfae25138a8486dc2e6790f39bc0c4e111223ba6034432',
99-
'unit-test',
100-
'(ICzAm2.QfCa6.MN',
101-
)
92+
it('rejects invalid self-describing PBKDF2 password', async () => {
93+
const salt = '(ICzAm2.QfCa6.MN'
94+
const hash = await User.hashForStorage('YouGuessedIt!', salt)
95+
const r = await User.validPassword('YouMissedIt!', hash, 'unit-test', salt)
96+
assert.equal(r, false)
97+
})
98+
99+
it('auths valid legacy PBKDF2-5000 password', async () => {
100+
const salt = '(ICzAm2.QfCa6.MN'
101+
const hash = await User.hashAuthPbkdf2('YouGuessedIt!', salt, 5000)
102+
const r = await User.validPassword('YouGuessedIt!', hash, 'unit-test', salt)
103+
assert.equal(r, true)
104+
})
105+
106+
it('rejects invalid legacy PBKDF2-5000 password', async () => {
107+
const salt = '(ICzAm2.QfCa6.MN'
108+
const hash = await User.hashAuthPbkdf2('YouGuessedIt!', salt, 5000)
109+
const r = await User.validPassword('YouMissedIt!', hash, 'unit-test', salt)
102110
assert.equal(r, false)
103111
})
104112

@@ -188,7 +196,7 @@ describe('user', function () {
188196
before(cleanup)
189197
after(cleanup)
190198

191-
it('upgrades plain text password to PBKDF2 on login', async () => {
199+
it('upgrades plain text password to self-describing PBKDF2 on login', async () => {
192200
await cleanup()
193201
await insertUser(testPass, null)
194202

@@ -198,14 +206,15 @@ describe('user', function () {
198206
const row = await getDbPassword()
199207
assert.ok(row.pass_salt, 'pass_salt should be set after upgrade')
200208
assert.notEqual(row.password, testPass, 'password should be hashed')
209+
assert.ok(row.password.includes('$'), 'password should be in self-describing format')
201210

202211
// verify round-trip: can still log in with the upgraded hash
203212
const again = await User.authenticate(authCreds)
204213
assert.ok(again, 'login should succeed after upgrade')
205214
await cleanup()
206215
})
207216

208-
it('upgrades SHA1 password to PBKDF2 on login', async () => {
217+
it('upgrades SHA1 password to self-describing PBKDF2 on login', async () => {
209218
// authenticate() passes the full authTry.username (including @group) to
210219
// validPassword(), so the HMAC key must match that full string
211220
const sha1Hash = crypto
@@ -221,13 +230,14 @@ describe('user', function () {
221230
const row = await getDbPassword()
222231
assert.ok(row.pass_salt, 'pass_salt should be set after upgrade')
223232
assert.notEqual(row.password, sha1Hash, 'password should be re-hashed')
233+
assert.ok(row.password.includes('$'), 'password should be in self-describing format')
224234

225235
const again = await User.authenticate(authCreds)
226236
assert.ok(again, 'login should succeed after upgrade')
227237
await cleanup()
228238
})
229239

230-
it('upgrades PBKDF2-5000 to current iterations on login', async () => {
240+
it('upgrades PBKDF2-5000 to self-describing format on login', async () => {
231241
const legacySalt = User.generateSalt()
232242
const legacyHash = await User.hashAuthPbkdf2(testPass, legacySalt, 5000)
233243
await cleanup()
@@ -239,15 +249,16 @@ describe('user', function () {
239249
const row = await getDbPassword()
240250
assert.notEqual(row.password, legacyHash, 'password should be re-hashed')
241251
assert.notEqual(row.pass_salt, legacySalt, 'salt should be regenerated')
252+
assert.ok(row.password.includes('$'), 'password should be in self-describing format')
242253

243254
const again = await User.authenticate(authCreds)
244255
assert.ok(again, 'login should succeed after upgrade')
245256
await cleanup()
246257
})
247258

248-
it('does not re-hash password already at current iterations', async () => {
259+
it('does not re-hash password already in self-describing format', async () => {
249260
const salt = User.generateSalt()
250-
const hash = await User.hashAuthPbkdf2(testPass, salt)
261+
const hash = await User.hashForStorage(testPass, salt)
251262
await cleanup()
252263
await insertUser(hash, salt)
253264

lib/user/toml.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ class UserRepoTOML extends UserBase {
8686
args = JSON.parse(JSON.stringify(args))
8787
if (args.password) {
8888
if (!args.pass_salt) args.pass_salt = this.generateSalt()
89-
args.password = await this.hashAuthPbkdf2(args.password, args.pass_salt)
89+
args.password = await this.hashForStorage(args.password, args.pass_salt)
9090
}
9191

9292
const users = await this._load()

lib/user/userBase.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ class UserBase {
7272
})
7373
}
7474

75+
async hashForStorage(pass, salt, iterations = PBKDF2_ITERATIONS) {
76+
const hex = await this.hashAuthPbkdf2(pass, salt, iterations)
77+
return `${iterations}$${hex}`
78+
}
79+
7580
async validPassword(passTry, passDb, username, salt) {
7681
this._needsUpgrade = false
7782

@@ -81,10 +86,20 @@ class UserBase {
8186
}
8287

8388
if (salt) {
84-
const hashed = await this.hashAuthPbkdf2(passTry, salt)
85-
if (this.debug) console.log(`hashed: (${hashed === passDb}) ${hashed}`)
86-
if (hashed === passDb) return true
87-
// fall back to NicTool 2 legacy iteration count
89+
// 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)
93+
const hashed = await this.hashAuthPbkdf2(passTry, salt, storedIters)
94+
if (this.debug) console.log(`self-describing: (${hashed === storedHashHex}) ${hashed}`)
95+
if (hashed === storedHashHex) {
96+
if (storedIters < PBKDF2_ITERATIONS) this._needsUpgrade = true
97+
return true
98+
}
99+
return false
100+
}
101+
102+
// Raw hex (legacy NicTool 2 format, implicitly 5000 iterations)
88103
const legacy = await this.hashAuthPbkdf2(passTry, salt, LEGACY_ITERATIONS)
89104
if (this.debug) console.log(`legacy: (${legacy === passDb}) ${legacy}`)
90105
if (legacy === passDb) {

routes/user.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ function UserRoutes(server) {
136136

137137
if (args.password) {
138138
args.pass_salt = User.generateSalt()
139-
args.password = await User.hashAuthPbkdf2(args.password, args.pass_salt)
139+
args.password = await User.hashForStorage(args.password, args.pass_salt)
140140
}
141141

142142
await User.put(args)

0 commit comments

Comments
 (0)