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?

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.

2 participants