-
-
Notifications
You must be signed in to change notification settings - Fork 2
feat: self-describing password hashes (iterations$hex) #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| * | ||
|
|
@@ -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
+97
to
+105
|
||
| 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 } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||||
| 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) | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
|
|
||
| const users = await this._load() | ||
|
|
||
There was a problem hiding this comment.
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.timingSafeEqualon same-length buffers) to reduce timing side-channels, especially since these are fixed-length derived keys.