Skip to content

feat: self-describing password hashes (iterations$hex)#41

Open
aberoham wants to merge 1 commit intoNicTool:mainfrom
aberoham:feat/password-upgrade
Open

feat: self-describing password hashes (iterations$hex)#41
aberoham wants to merge 1 commit intoNicTool:mainfrom
aberoham:feat/password-upgrade

Conversation

@aberoham
Copy link
Copy Markdown
Contributor

@aberoham aberoham commented Apr 8, 2026

Depends on #40 -- will rebase onto main once that merges.

Matt -- this one tackles the password storage situation. Right now the DB has a mix of plain text, SHA-1, and PBKDF2-5000 passwords with no way to tell which is which. The approach here is what I'd call "self-describing" hashes -- the stored format becomes iterations$hexHash, so validPassword() always knows exactly how to verify without guessing.

The thing that bugged me about the naive approach (try current iterations, fall back to 5000, fall back to SHA-1...) is that every failed login pays the cost of all those tiers. That's basically a free DoS amplifier. With self-describing hashes, a wrong password costs exactly one PBKDF2 computation no matter how many legacy formats have existed.

The upgrade path is transparent -- when someone logs in successfully with any legacy format, their hash gets silently rewritten to 220000$hex with a fresh salt. Future iteration bumps just need a PBKDF2_ITERATIONS env var change, zero code touches, and users upgrade on next login.

The interesting bits to look at are in userBase.js -- hashForStorage() wraps the existing hashAuthPbkdf2() to produce the self-describing format, and validPassword() now returns { valid, needsUpgrade } instead of a bare boolean (cleaner than the mutable state approach claude tried first). All the storage sites (create, authenticate, PUT /user) go through hashForStorage() now.

No new dependencies, just node:crypto.

All 345 tests pass in docker. The upgrade tests cover all four paths -- plain text, SHA-1, PBKDF2-5000, and already-current (should not re-hash). Each one does a round-trip to verify login still works after the upgrade.

Let me know if the format choice makes sense or if you'd rather store it differently.

Abe

@aberoham aberoham force-pushed the feat/password-upgrade branch 2 times, most recently from 13d2dae to 3d4ef53 Compare April 8, 2026 23:36
@aberoham aberoham changed the title feat: upgrade legacy password hashes on login (#30) feat: self-describing password hashes (iterations$hex) Apr 8, 2026
@aberoham aberoham force-pushed the feat/password-upgrade branch from 3d4ef53 to 1d023e3 Compare April 8, 2026 23:56
@aberoham aberoham marked this pull request as ready for review April 10, 2026 20:54
@msimerson
Copy link
Copy Markdown
Contributor

hmmm, needs to be rebased. I like the idea and description.

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.

Format: `iterations$hexHash` (validated by /^\d+\$[0-9a-f]{64}$/).

Legacy formats (plain text, SHA-1, raw PBKDF2-5000) are detected
and silently upgraded to self-describing format on login.

validPassword() returns { valid, needsUpgrade } instead of setting
mutable instance state — callers destructure the result directly.

PBKDF2_ITERATIONS env var (default 220000) is validated at module
load. Future iteration bumps require zero code changes — users at
older iterations upgrade on next login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@aberoham aberoham force-pushed the feat/password-upgrade branch from 7eb6edd to bfa7cbe Compare April 11, 2026 07:33
Copy link
Copy Markdown
Contributor

@msimerson msimerson left a comment

Choose a reason for hiding this comment

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

Sorry, my PR collided with this. Self-merge once you've rebased.

@msimerson
Copy link
Copy Markdown
Contributor

Also, in the PR description you should be saying 'fixes #30', right?

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates NicTool’s user password storage and verification to use a self-describing PBKDF2 format (<iterations>$<hexHash>), enabling deterministic verification and transparent upgrades of legacy password formats on successful login.

Changes:

  • Introduces hashForStorage() and updates PBKDF2 hashing to support configurable iterations (PBKDF2_ITERATIONS, default 220000).
  • Updates MySQL auth flow to upgrade legacy passwords (plaintext/SHA-1/PBKDF2-5000) to the self-describing format after successful login.
  • Updates hashing at password write sites and extends unit tests to cover verification + upgrade paths.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
routes/user.js Uses hashForStorage() when updating a user password via PUT /user/{id}.
lib/user/test.js Updates password-verification expectations and adds end-to-end “upgrade on login” tests using the MySQL store.
lib/user/store/toml.js Switches TOML user creation to store self-describing hashes.
lib/user/store/mysql.js Implements login-time password upgrade and stores self-describing hashes for new/updated passwords.
lib/user/store/base.js Adds iteration configuration, self-describing hash generation, and {valid, needsUpgrade} return shape for validPassword().

Comment on lines +97 to +105
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 }
}
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.
Comment on lines +101 to +104
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 }
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 +42 to +49
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,
}),
)
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.
Comment on lines 86 to 90
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)
}
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.
Copy link
Copy Markdown
Contributor

@msimerson msimerson left a comment

Choose a reason for hiding this comment

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

address issues raised by @copilot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants