Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 47 additions & 9 deletions lib/user/store/base.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import crypto from 'node:crypto'

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

/**
* User domain class – pure attributes and password business logic.
*
Expand Down Expand Up @@ -60,33 +73,58 @@ class UserBase {
return salt
}

async hashAuthPbkdf2(pass, salt) {
async hashAuthPbkdf2(pass, salt, iterations = PBKDF2_ITERATIONS) {
return new Promise((resolve, reject) => {
// match the defaults for NicTool 2.x
crypto.pbkdf2(pass, salt, 5000, 32, 'sha512', (err, derivedKey) => {
crypto.pbkdf2(pass, salt, iterations, 32, 'sha512', (err, derivedKey) => {
if (err) return reject(err)
resolve(derivedKey.toString('hex'))
})
})
}

async hashForStorage(pass, salt, iterations = PBKDF2_ITERATIONS) {
const hex = await this.hashAuthPbkdf2(pass, salt, iterations)
return `${iterations}$${hex}`
}

async validPassword(passTry, passDb, username, salt) {
if (!salt && passTry === passDb) return true // plain pass, TODO, encrypt!
if (!salt && passTry === passDb) {
return { valid: true, needsUpgrade: true }
}

if (salt) {
const hashed = await this.hashAuthPbkdf2(passTry, salt)
if (this.debug) console.log(`hashed: (${hashed === passDb}) ${hashed}`)
return hashed === passDb
// Self-describing format: "iterations$hexHash" — single hash, no fallback
const m = SELF_DESCRIBING_RE.exec(passDb)
if (m) {
const storedIters = parseInt(m[1], 10)
const storedHashHex = m[2]
const hashed = await this.hashAuthPbkdf2(passTry, salt, storedIters)
if (this.debug) console.log(`self-describing: (${hashed === storedHashHex}) ${hashed}`)
if (hashed === storedHashHex) {
return { valid: true, needsUpgrade: storedIters < PBKDF2_ITERATIONS }
Comment on lines +101 to +104
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hash comparisons here use === on hex strings. For password verification it’s safer to use constant-time comparison (e.g. crypto.timingSafeEqual on same-length buffers) to reduce timing side-channels, especially since these are fixed-length derived keys.

Copilot uses AI. Check for mistakes.
}
Comment on lines +97 to +105
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storedIters is parsed from the DB and then passed directly into crypto.pbkdf2(). If the stored password is malformed (e.g. 0$...) or contains an extremely large iteration count, pbkdf2 can throw (500 on login) or become an accidental CPU DoS. Validate storedIters as a safe positive integer within a reasonable max bound and handle pbkdf2 errors by returning { valid: false, needsUpgrade: false } instead of throwing.

Copilot uses AI. Check for mistakes.
return { valid: false, needsUpgrade: false }
}

// Raw hex (legacy NicTool 2 format, implicitly 5000 iterations)
const legacy = await this.hashAuthPbkdf2(passTry, salt, LEGACY_ITERATIONS)
if (this.debug) console.log(`legacy: (${legacy === passDb}) ${legacy}`)
if (legacy === passDb) {
return { valid: true, needsUpgrade: true }
}
return { valid: false, needsUpgrade: false }
}

// Check for HMAC SHA-1 password
if (/^[0-9a-f]{40}$/.test(passDb)) {
const digest = crypto.createHmac('sha1', username.toLowerCase()).update(passTry).digest('hex')
if (this.debug) console.log(`digest: (${digest === passDb}) ${digest}`)
return digest === passDb
if (digest === passDb) {
return { valid: true, needsUpgrade: true }
}
}

return false
return { valid: false, needsUpgrade: false }
}
}

Expand Down
15 changes: 13 additions & 2 deletions lib/user/store/mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,18 @@ class UserRepoMySQL extends UserBase {
AND g.name = ?`

for (const u of await Mysql.execute(query, [username, groupName])) {
if (await this.validPassword(authTry.password, u.password, authTry.username, u.pass_salt)) {
const { valid, needsUpgrade } = await this.validPassword(authTry.password, u.password, authTry.username, u.pass_salt)
if (valid) {
if (needsUpgrade) {
const salt = this.generateSalt()
const hash = await this.hashForStorage(authTry.password, salt)
await Mysql.execute(
...Mysql.update('nt_user', `nt_user_id=${u.id}`, {
password: hash,
pass_salt: salt,
}),
)
Comment on lines +42 to +49
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the upgrade UPDATE fails (DB issue, permissions, transient error), authenticate() will currently throw and block an otherwise-valid login. Consider making the upgrade best-effort (log/report the failure but still return the authenticated user) so availability of logins doesn’t depend on the upgrade write succeeding.

Suggested change
const salt = this.generateSalt()
const hash = await this.hashForStorage(authTry.password, salt)
await Mysql.execute(
...Mysql.update('nt_user', `nt_user_id=${u.id}`, {
password: hash,
pass_salt: salt,
}),
)
try {
const salt = this.generateSalt()
const hash = await this.hashForStorage(authTry.password, salt)
await Mysql.execute(
...Mysql.update('nt_user', `nt_user_id=${u.id}`, {
password: hash,
pass_salt: salt,
}),
)
} catch (err) {
console.warn(`Password hash upgrade failed for user ${authTry.username}`, err)
}

Copilot uses AI. Check for mistakes.
}
for (const f of ['password', 'pass_salt']) {
delete u[f] // SECURITY: no longer needed
}
Expand Down Expand Up @@ -65,7 +76,7 @@ class UserRepoMySQL extends UserBase {

if (args.password) {
if (!args.pass_salt) args.pass_salt = this.generateSalt()
args.password = await this.hashAuthPbkdf2(args.password, args.pass_salt)
args.password = await this.hashForStorage(args.password, args.pass_salt)
}

const userId = await Mysql.execute(...Mysql.insert(`nt_user`, mapToDbColumn(args, userDbMap)))
Expand Down
2 changes: 1 addition & 1 deletion lib/user/store/toml.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class UserRepoTOML extends UserBase {
args = JSON.parse(JSON.stringify(args))
if (args.password) {
if (!args.pass_salt) args.pass_salt = this.generateSalt()
args.password = await this.hashAuthPbkdf2(args.password, args.pass_salt)
args.password = await this.hashForStorage(args.password, args.pass_salt)
}
Comment on lines 86 to 90
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

authenticate() still treats validPassword() as a boolean, but validPassword() now returns an object ({ valid, needsUpgrade }). Since any object is truthy, TOML auth will accept any password for a matching user. Update this repo to destructure/check .valid (and ideally handle needsUpgrade by re-saving an upgraded hash/salt).

Copilot uses AI. Check for mistakes.

const users = await this._load()
Expand Down
166 changes: 147 additions & 19 deletions lib/user/test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import { describe, it, after, before } from 'node:test'

import User from './index.js'
import Group from '../group/index.js'
import Mysql from '../mysql.js'

import userCase from '../test/user.json' with { type: 'json' }
import groupCase from '../test/group.json' with { type: 'json' }
Expand Down Expand Up @@ -77,27 +79,35 @@ describe('user', function () {
describe('validPassword', function () {
it('auths user with plain text password', async () => {
const r = await User.validPassword('test', 'test', 'demo', '')
assert.equal(r, true)
assert.deepEqual(r, { valid: true, needsUpgrade: true })
})

it('auths valid pbkdb2 password', async () => {
const r = await User.validPassword(
'YouGuessedIt!',
'050cfa70c3582be0d5bfae25138a8486dc2e6790f39bc0c4e111223ba6034432',
'unit-test',
'(ICzAm2.QfCa6.MN',
)
assert.equal(r, true)
it('auths valid self-describing PBKDF2 password', async () => {
const salt = '(ICzAm2.QfCa6.MN'
const hash = await User.hashForStorage('YouGuessedIt!', salt)
const r = await User.validPassword('YouGuessedIt!', hash, 'unit-test', salt)
assert.deepEqual(r, { valid: true, needsUpgrade: false })
})

it('rejects invalid pbkdb2 password', async () => {
const r = await User.validPassword(
'YouMissedIt!',
'050cfa70c3582be0d5bfae25138a8486dc2e6790f39bc0c4e111223ba6034432',
'unit-test',
'(ICzAm2.QfCa6.MN',
)
assert.equal(r, false)
it('rejects invalid self-describing PBKDF2 password', async () => {
const salt = '(ICzAm2.QfCa6.MN'
const hash = await User.hashForStorage('YouGuessedIt!', salt)
const r = await User.validPassword('YouMissedIt!', hash, 'unit-test', salt)
assert.deepEqual(r, { valid: false, needsUpgrade: false })
})

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

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

it('auths valid SHA1 password', async () => {
Expand All @@ -106,7 +116,7 @@ describe('user', function () {
'083007777a5241d01abba70c938c60d80be60027',
'unit-test',
)
assert.equal(r, true)
assert.deepEqual(r, { valid: true, needsUpgrade: true })
})

it('rejects invalid SHA1 password', async () => {
Expand All @@ -115,7 +125,7 @@ describe('user', function () {
'083007777a5241d01abba7Oc938c60d80be60027',
'unit-test',
)
assert.equal(r, false)
assert.deepEqual(r, { valid: false, needsUpgrade: false })
})
})

Expand Down Expand Up @@ -144,4 +154,122 @@ describe('user', function () {
assert.ok(u)
})
})

describe('password upgrade on login', () => {
const upgradeUserId = 4200
const upgradeUser = {
nt_user_id: upgradeUserId,
nt_group_id: groupCase.id,
username: 'upgrade-test',
email: 'upgrade-test@example.com',
first_name: 'Upgrade',
last_name: 'Test',
}
const testPass = 'UpgradeMe!123'
const authCreds = {
username: `${upgradeUser.username}@${groupCase.name}`,
password: testPass,
}

async function getDbPassword() {
const rows = await Mysql.execute(
'SELECT password, pass_salt FROM nt_user WHERE nt_user_id = ?',
[upgradeUserId],
)
return rows[0]
}

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

async function cleanup() {
await Mysql.execute(
'DELETE FROM nt_user WHERE nt_user_id = ?',
[upgradeUserId],
)
}

before(cleanup)
after(cleanup)

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

const result = await User.authenticate(authCreds)
assert.ok(result, 'login should succeed')

const row = await getDbPassword()
assert.ok(row.pass_salt, 'pass_salt should be set after upgrade')
assert.notEqual(row.password, testPass, 'password should be hashed')
assert.ok(row.password.includes('$'), 'password should be in self-describing format')

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

it('upgrades SHA1 password to self-describing PBKDF2 on login', async () => {
// authenticate() passes the full authTry.username (including @group) to
// validPassword(), so the HMAC key must match that full string
const sha1Hash = crypto
.createHmac('sha1', authCreds.username.toLowerCase())
.update(testPass)
.digest('hex')
await cleanup()
await insertUser(sha1Hash, null)

const result = await User.authenticate(authCreds)
assert.ok(result, 'login should succeed with SHA1 hash')

const row = await getDbPassword()
assert.ok(row.pass_salt, 'pass_salt should be set after upgrade')
assert.notEqual(row.password, sha1Hash, 'password should be re-hashed')
assert.ok(row.password.includes('$'), 'password should be in self-describing format')

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

it('upgrades PBKDF2-5000 to self-describing format on login', async () => {
const legacySalt = User.generateSalt()
const legacyHash = await User.hashAuthPbkdf2(testPass, legacySalt, 5000)
await cleanup()
await insertUser(legacyHash, legacySalt)

const result = await User.authenticate(authCreds)
assert.ok(result, 'login should succeed with legacy PBKDF2')

const row = await getDbPassword()
assert.notEqual(row.password, legacyHash, 'password should be re-hashed')
assert.notEqual(row.pass_salt, legacySalt, 'salt should be regenerated')
assert.ok(row.password.includes('$'), 'password should be in self-describing format')

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

it('does not re-hash password already in self-describing format', async () => {
const salt = User.generateSalt()
const hash = await User.hashForStorage(testPass, salt)
await cleanup()
await insertUser(hash, salt)

await User.authenticate(authCreds)

const row = await getDbPassword()
assert.equal(row.password, hash, 'password should be unchanged')
assert.equal(row.pass_salt, salt, 'salt should be unchanged')
await cleanup()
})
})
})
2 changes: 1 addition & 1 deletion routes/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ function UserRoutes(server) {

if (args.password) {
args.pass_salt = User.generateSalt()
args.password = await User.hashAuthPbkdf2(args.password, args.pass_salt)
args.password = await User.hashForStorage(args.password, args.pass_salt)
}

await User.put(args)
Expand Down
Loading