From 9f0bef76c3a338002b3578c265751cee643abb41 Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 17 Jan 2025 18:13:39 +0100 Subject: [PATCH] Update accounts-password to use argon2 instead of bcrypt Users that have a bcrypt password stored get progressively migrated to argon2 when their password is checked by `checkPasswordAsync` --- packages/accounts-base/accounts-base.d.ts | 8 +- packages/accounts-base/accounts_common.js | 8 +- packages/accounts-password/package.js | 1 + packages/accounts-password/password_server.js | 248 ++++++++++++------ packages/accounts-password/password_tests.js | 50 ++-- v3-docs/docs/api/accounts.md | 2 +- 6 files changed, 210 insertions(+), 107 deletions(-) diff --git a/packages/accounts-base/accounts-base.d.ts b/packages/accounts-base/accounts-base.d.ts index 0ab68de576..7dacf0e4a9 100644 --- a/packages/accounts-base/accounts-base.d.ts +++ b/packages/accounts-base/accounts-base.d.ts @@ -81,7 +81,7 @@ export namespace Accounts { passwordEnrollTokenExpiration?: number | undefined; passwordEnrollTokenExpirationInDays?: number | undefined; ambiguousErrorMessages?: boolean | undefined; - bcryptRounds?: number | undefined; + argon2Iterations?: number | undefined; defaultFieldSelector?: { [key: string]: 0 | 1 } | undefined; collection?: string | undefined; loginTokenExpirationHours?: number | undefined; @@ -353,10 +353,10 @@ export namespace Accounts { /** * - * Check whether the provided password matches the bcrypt'ed password in + * Check whether the provided password matches the argon2'ed password in * the database user record. `password` can be a string (in which case - * it will be run through SHA256 before bcrypt) or an object with - * properties `digest` and `algorithm` (in which case we bcrypt + * it will be run through SHA256 before argon2) or an object with + * properties `digest` and `algorithm` (in which case we argon2 * `password.digest`). */ function _checkPasswordAsync( diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 210fc004c7..f641a7607e 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -13,7 +13,7 @@ const VALID_CONFIG_KEYS = [ 'passwordEnrollTokenExpirationInDays', 'passwordEnrollTokenExpiration', 'ambiguousErrorMessages', - 'bcryptRounds', + 'argon2Iterations', 'defaultFieldSelector', 'collection', 'loginTokenExpirationHours', @@ -226,8 +226,8 @@ export class AccountsCommon { // - ambiguousErrorMessages {Boolean} // Return ambiguous error messages from login failures to prevent // user enumeration. - // - bcryptRounds {Number} - // Allows override of number of bcrypt rounds (aka work factor) used + // - argon2Iterations {Number} + // Allows override of number of argon2 iterations (aka time cost) used // to store passwords. /** @@ -245,7 +245,7 @@ export class AccountsCommon { * @param {Number} options.passwordEnrollTokenExpirationInDays The number of days from when a link to set initial password is sent until token expires and user can't set password with the link anymore. Defaults to 30. * @param {Number} options.passwordEnrollTokenExpiration The number of milliseconds from when a link to set initial password is sent until token expires and user can't set password with the link anymore. If `passwordEnrollTokenExpirationInDays` is set, it takes precedent. * @param {Boolean} options.ambiguousErrorMessages Return ambiguous error messages from login failures to prevent user enumeration. Defaults to `true`. - * @param {Number} options.bcryptRounds Allows override of number of bcrypt rounds (aka work factor) used to store passwords. The default is 10. + * @param {Number} options.argon2Iterations Allows override of number of argon2 iterations (aka time cost) used to store passwords. The default is 3. * @param {MongoFieldSpecifier} options.defaultFieldSelector To exclude by default large custom fields from `Meteor.user()` and `Meteor.findUserBy...()` functions when called without a field selector, and all `onLogin`, `onLoginFailure` and `onLogout` callbacks. Example: `Accounts.config({ defaultFieldSelector: { myBigArray: 0 }})`. Beware when using this. If, for instance, you do not include `email` when excluding the fields, you can have problems with functions like `forgotPassword` that will break because they won't have the required data available. It's recommend that you always keep the fields `_id`, `username`, and `email`. * @param {String|Mongo.Collection} options.collection A collection name or a Mongo.Collection object to hold the users. * @param {Number} options.loginTokenExpirationHours When using the package `accounts-2fa`, use this to set the amount of time a token sent is valid. As it's just a number, you can use, for example, 0.5 to make the token valid for just half hour. The default is 1 hour. diff --git a/packages/accounts-password/package.js b/packages/accounts-password/package.js index b86e57dff1..ca8a7b5651 100644 --- a/packages/accounts-password/package.js +++ b/packages/accounts-password/package.js @@ -10,6 +10,7 @@ Package.describe({ Npm.depends({ bcrypt: "5.0.1", + argon2: "0.41.1", }); Package.onUse((api) => { diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 5e15f66fcb..f97a115fb7 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -1,4 +1,5 @@ -import { hash as bcryptHash, compare as bcryptCompare } from 'bcrypt'; +import argon2 from "argon2"; +import { compare as bcryptCompare } from "bcrypt"; import { Accounts } from "meteor/accounts-base"; // Utility for grabbing user @@ -6,8 +7,9 @@ const getUserById = async (id, options) => await Meteor.users.findOneAsync(id, Accounts._addDefaultFieldSelector(options)); -// User records have a 'services.password.bcrypt' field on them to hold -// their hashed passwords. +// User records have two fields that are used for password-based login: +// - 'services.password.bcrypt', which stores the bcrypt password, which is now deprecated +// - 'services.password.argon2', which stores the argon2 password // // When the client sends a password to the server, it can either be a // string (the plaintext password) or an object with keys 'digest' and @@ -17,18 +19,25 @@ const getUserById = // strings. // // When the server receives a plaintext password as a string, it always -// hashes it with SHA256 before passing it into bcrypt. When the server +// hashes it with SHA256 before passing it into argon2. When the server // receives a password as an object, it asserts that the algorithm is -// "sha-256" and then passes the digest to bcrypt. +// "sha-256" and then passes the digest to argon2. +Accounts._argon2Iterations = () => Accounts._options.argon2Iterations || 3; -Accounts._bcryptRounds = () => Accounts._options.bcryptRounds || 10; - -// Given a 'password' from the client, extract the string that we should -// bcrypt. 'password' can be one of: -// - String (the plaintext password) -// - Object with 'digest' and 'algorithm' keys. 'algorithm' must be "sha-256". -// +/** + * Extracts the string to be encrypted using Argon2 from the given `password`. + * + * @param {string|Object} password - The password provided by the client. It can be: + * - A plaintext string password. + * - An object with the following properties: + * @property {string} digest - The hashed password. + * @property {string} algorithm - The hashing algorithm used. Must be "sha-256". + * + * @returns {string} - The resulting password string to encrypt. + * + * @throws {Error} - If the `algorithm` in the password object is not "sha-256". + */ const getPasswordString = password => { if (typeof password === "string") { password = SHA256(password); @@ -42,65 +51,137 @@ const getPasswordString = password => { return password; }; -// Use bcrypt to hash the password for storage in the database. +// Use argon2 to hash the password for storage in the database. // `password` can be a string (in which case it will be run through -// SHA256 before bcrypt) or an object with properties `digest` and -// `algorithm` (in which case we bcrypt `password.digest`). +// SHA256 before argon2) or an object with properties `digest` and +// `algorithm` (in which case we argon2 `password.digest`). // const hashPassword = async password => { password = getPasswordString(password); - return await bcryptHash(password, Accounts._bcryptRounds()); + return await argon2.hash(password, { + timeCost: Accounts._argon2Iterations(), + type: argon2.argon2id + }); }; -// Extract the number of rounds used in the specified bcrypt hash. -const getRoundsFromBcryptHash = hash => { - let rounds; - if (hash) { - const hashSegments = hash.split('$'); - if (hashSegments.length > 2) { - rounds = parseInt(hashSegments[2], 10); +/** + * Extract the number of iterations used in the specified argon2 hash + * @param hash String + * @returns {null|number} + */ +const getIterationsFromArgon2Hash = function(hash) { + const parts = hash?.split("$") || []; + if (parts.length < 4 || !parts[1].startsWith("argon2")) { + throw new Error("Invalid Argon2 hash format"); + } + + const params = parts[3].split(","); + let iterations = null; + + for (const param of params) { + if (param.startsWith("t=")) { + iterations = parseInt(param.split("=")[1], 10); + break; } } - return rounds; + + if (iterations === null) { + throw new Error("Iterations parameter not found in the hash"); + } + + return iterations; +}; +Accounts._getIterationsFromArgon2Hash = getIterationsFromArgon2Hash; + +const getUserPasswordHash = user => { + return user.services?.password?.argon2 || user.services?.password?.bcrypt; }; -// Check whether the provided password matches the bcrypt'ed password in -// the database user record. `password` can be a string (in which case -// it will be run through SHA256 before bcrypt) or an object with -// properties `digest` and `algorithm` (in which case we bcrypt -// `password.digest`). -// -// The user parameter needs at least user._id and user.services -Accounts._checkPasswordUserFields = {_id: 1, services: 1}; -// +Accounts._checkPasswordUserFields = { _id: 1, services: 1 }; + +/** + * Checks whether the provided password matches the hashed password stored in the user's database record. + * + * @param {Object} user - The user object containing at least: + * @property {string} _id - The user's unique identifier. + * @property {Object} services - The user's services data. + * @property {Object} services.password - The user's password object. + * @property {string} [services.password.argon2] - The Argon2 hashed password. + * @property {string} [services.password.bcrypt] - The bcrypt hashed password, deprecated + * + * @param {string|Object} password - The password provided by the client. It can be: + * - A plaintext string password. + * - An object with the following properties: + * @property {string} digest - The hashed password. + * @property {string} algorithm - The hashing algorithm used. Must be "sha-256". + * + * @returns {Promise} - A result object with the following properties: + * @property {string} userId - The user's unique identifier. + * @property {Object} [error] - An error object if the password does not match or an error occurs. + * + * @throws {Error} - If an unexpected error occurs during the process. + */ const checkPasswordAsync = async (user, password) => { const result = { userId: user._id }; const formattedPassword = getPasswordString(password); - const hash = user.services.password.bcrypt; - const hashRounds = getRoundsFromBcryptHash(hash); + const hash = getUserPasswordHash(user); - if (! await bcryptCompare(formattedPassword, hash)) { - result.error = Accounts._handleError("Incorrect password", false); - } else if (hash && Accounts._bcryptRounds() != hashRounds) { - // The password checks out, but the user's bcrypt hash needs to be updated. - - Meteor.defer(async () => { - await Meteor.users.updateAsync({ _id: user._id }, { - $set: { - 'services.password.bcrypt': - await bcryptHash(formattedPassword, Accounts._bcryptRounds()) - } + // bcrypt hashes start with $2a$ or $2b$ + // argon2 hashes start with $argon2i$, $argon2d$ or $argon2id$ + if (hash.startsWith("$2")) { + // migration code from bcrypt to argon2 + const match = await bcryptCompare(formattedPassword, hash); + if (!match) { + result.error = Accounts._handleError("Incorrect password", false); + } + else { + // The password checks out, but the user's stored password needs to be updated to argon2 + Meteor.defer(async () => { + await Meteor.users.updateAsync( + { _id: user._id }, + { + $set: { + "services.password.argon2": + await hashPassword(password) + }, + $unset: { + "services.password.bcrypt": 1 + } + } + ); }); - }); + } + } + else { + // argon2 password + const argon2Iterations = getIterationsFromArgon2Hash(hash); + + if (!(await argon2.verify(hash, formattedPassword))) { + result.error = Accounts._handleError("Incorrect password", false); + } + else if (hash && Accounts._argon2Iterations() !== argon2Iterations) { + // The password checks out, but the user's argon2 hash needs to be updated with the right number of iterations + Meteor.defer(async () => { + await Meteor.users.updateAsync( + { _id: user._id }, + { + $set: { + "services.password.argon2": + await hashPassword(formattedPassword) + } + } + ); + }); + } } return result; }; -Accounts._checkPasswordAsync = checkPasswordAsync; +Accounts._checkPasswordAsync = checkPasswordAsync; /// /// LOGIN @@ -185,9 +266,7 @@ Accounts.registerLoginHandler("password", async options => { Accounts._handleError("User not found"); } - - if (!user.services || !user.services.password || - !user.services.password.bcrypt) { + if (!getUserPasswordHash(user)) { Accounts._handleError("User has no password set"); } @@ -283,7 +362,7 @@ Meteor.methods( Accounts._handleError("User not found"); } - if (!user.services || !user.services.password || !user.services.password.bcrypt) { + if (!getUserPasswordHash(user)) { Accounts._handleError("User has no password set"); } @@ -302,11 +381,14 @@ Meteor.methods( await Meteor.users.updateAsync( { _id: this.userId }, { - $set: { 'services.password.bcrypt': hashed }, + $set: { 'services.password.argon2': hashed }, $pull: { 'services.resume.loginTokens': { hashedToken: { $ne: currentToken } } }, - $unset: { 'services.password.reset': 1 } + $unset: { + 'services.password.reset': 1, + 'services.password.bcrypt': 1 + } } ); @@ -320,7 +402,7 @@ Meteor.methods( * @summary Forcibly change the password for a user. * @locus Server * @param {String} userId The id of the user to update. - * @param {String} newPassword A new password for the user. + * @param {String} newPlaintextPassword A new password for the user. * @param {Object} [options] * @param {Object} options.logout Logout all current connections with this userId (default: true) * @importFromPackage accounts-base @@ -339,9 +421,12 @@ Accounts.setPasswordAsync = const update = { $unset: { - 'services.password.reset': 1 + 'services.password.reset': 1, + 'services.password.bcrypt': 1 }, - $set: {'services.password.bcrypt': await hashPassword(newPlaintextPassword)} + $set: { + 'services.password.argon2': await hashPassword(newPlaintextPassword) + } }; if (options.logout) { @@ -430,25 +515,32 @@ Accounts.generateResetToken = // if this method is called from the enroll account work-flow then // store the token record in 'services.password.enroll' db field // else store the token record in in 'services.password.reset' db field - if(reason === 'enrollAccount') { - await Meteor.users.updateAsync({_id: user._id}, { - $set : { - 'services.password.enroll': tokenRecord + if (reason === "enrollAccount") { + await Meteor.users.updateAsync( + { _id: user._id }, + { + $set: { + "services.password.enroll": tokenRecord + } } - }); + ); // before passing to template, update user object with new token - Meteor._ensure(user, 'services', 'password').enroll = tokenRecord; - } else { - await Meteor.users.updateAsync({_id: user._id}, { - $set : { - 'services.password.reset': tokenRecord + Meteor._ensure(user, "services", "password").enroll = tokenRecord; + } + else { + await Meteor.users.updateAsync( + { _id: user._id }, + { + $set: { + "services.password.reset": tokenRecord + } } - }); + ); // before passing to template, update user object with new token - Meteor._ensure(user, 'services', 'password').reset = tokenRecord; + Meteor._ensure(user, "services", "password").reset = tokenRecord; } - return {email, user, token}; + return { email, user, token }; }; /** @@ -669,10 +761,13 @@ Meteor.methods( }, { $set: { - 'services.password.bcrypt': hashed, + 'services.password.argon2': hashed, 'emails.$.verified': true }, - $unset: { 'services.password.enroll': 1 } + $unset: { + 'services.password.enroll': 1, + 'services.password.bcrypt': 1, + } }); } else { affectedRecords = await Meteor.users.updateAsync( @@ -683,10 +778,13 @@ Meteor.methods( }, { $set: { - 'services.password.bcrypt': hashed, + 'services.password.argon2': hashed, 'emails.$.verified': true }, - $unset: { 'services.password.reset': 1 } + $unset: { + 'services.password.reset': 1, + 'services.password.bcrypt': 1, + } }); } if (affectedRecords !== 1) @@ -990,7 +1088,7 @@ const createUser = const user = { services: {} }; if (password) { const hashed = await hashPassword(password); - user.services.password = { bcrypt: hashed }; + user.services.password = { argon2: hashed }; } return await Accounts._createUserCheckingDuplicates({ user, email, username, options }); diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 63af757d2c..fdd466fe63 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1171,7 +1171,7 @@ if (Meteor.isServer) (() => { // set a new password. await Accounts.setPasswordAsync(userId, 'new password'); user = await Meteor.users.findOneAsync(userId); - const oldSaltedHash = user.services.password.bcrypt; + const oldSaltedHash = user.services.password.argon2; test.isTrue(oldSaltedHash); // Send a reset password email (setting a reset token) and insert a login // token. @@ -1184,7 +1184,7 @@ if (Meteor.isServer) (() => { // reset with the same password, see we get a different salted hash await Accounts.setPasswordAsync(userId, 'new password', { logout: false }); user = await Meteor.users.findOneAsync(userId); - const newSaltedHash = user.services.password.bcrypt; + const newSaltedHash = user.services.password.argon2; test.isTrue(newSaltedHash); test.notEqual(oldSaltedHash, newSaltedHash); // No more reset token. @@ -1196,7 +1196,7 @@ if (Meteor.isServer) (() => { // reset again, see that the login tokens are gone. await Accounts.setPasswordAsync(userId, 'new password'); user = await Meteor.users.findOneAsync(userId); - const newerSaltedHash = user.services.password.bcrypt; + const newerSaltedHash = user.services.password.argon2; test.isTrue(newerSaltedHash); test.notEqual(oldSaltedHash, newerSaltedHash); test.notEqual(newSaltedHash, newerSaltedHash); @@ -1822,28 +1822,32 @@ if (Meteor.isServer) (() => { ]); }); - const getUserHashRounds = user => - Number(user.services.password.bcrypt.substring(4, 6)); - testAsyncMulti("passwords - allow custom bcrypt rounds",[ + const getUserHashArgon2Iterations = function (user) { + const hash = user?.services?.password?.argon2; + return Accounts._getIterationsFromArgon2Hash(hash); + } + + testAsyncMulti("passwords - allow custom argon2 iterations",[ async function (test) { - // Verify that a bcrypt hash generated for a new account uses the + // Verify that a argon2 hash generated for a new account uses the + // default number of iterations. let username = Random.id(); this.password = hashPassword('abc123'); this.userId1 = await Accounts.createUser({ username, password: this.password }); this.user1 = await Meteor.users.findOneAsync(this.userId1); - let rounds = getUserHashRounds(this.user1); - test.equal(rounds, Accounts._bcryptRounds()); + let rounds = getUserHashArgon2Iterations(this.user1); + test.equal(rounds, Accounts._argon2Iterations()); - // When a custom number of bcrypt rounds is set via Accounts.config, - // and an account was already created using the default number of rounds, + // When a custom number of argon2 iterations is set via Accounts.config, + // and an account was already created using the default number of iterations, // make sure that a new hash is created (and stored) using the new number - // of rounds, the next time the password is checked. - this.customRounds = 11; - Accounts._options.bcryptRounds = this.customRounds; + // of iterations, the next time the password is checked. + this.customIterations = 4; + Accounts._options.argon2Iterations = this.customIterations; await Accounts._checkPasswordAsync(this.user1, this.password); }, async function(test) { - const defaultRounds = Accounts._bcryptRounds(); + const defaultRounds = Accounts._argon2Iterations(); let rounds; let username; @@ -1852,18 +1856,18 @@ if (Meteor.isServer) (() => { Meteor.setTimeout(async () => { this.user1 = await Meteor.users.findOneAsync(this.userId1); - rounds = getUserHashRounds(this.user1); - test.equal(rounds, this.customRounds); - // When a custom number of bcrypt rounds is set, make sure it's - // used for new bcrypt password hashes. + rounds = getUserHashArgon2Iterations(this.user1); + test.equal(rounds, this.customIterations); + // When a custom number of argon2 iterations is set, make sure it's + // used for new argon2 password hashes. username = Random.id(); const userId2 = await Accounts.createUser({ username, password: this.password }); const user2 = await Meteor.users.findOneAsync(userId2); - rounds = getUserHashRounds(user2); - test.equal(rounds, this.customRounds); + rounds = getUserHashArgon2Iterations(user2); + test.equal(rounds, this.customIterations); // Cleanup - Accounts._options.bcryptRounds = defaultRounds; + Accounts._options.argon2Iterations = defaultRounds; await Meteor.users.removeAsync(this.userId1); await Meteor.users.removeAsync(userId2); resolve(); @@ -1871,7 +1875,7 @@ if (Meteor.isServer) (() => { return promise; } - ]); // default number of rounds. + ]); Tinytest.addAsync('passwords - extra params in email urls', diff --git a/v3-docs/docs/api/accounts.md b/v3-docs/docs/api/accounts.md index 676909156f..1317b97653 100644 --- a/v3-docs/docs/api/accounts.md +++ b/v3-docs/docs/api/accounts.md @@ -773,7 +773,7 @@ sign-in process, it also supports email-based sign-in including address verification and password recovery emails. The Meteor server stores passwords using the -[bcrypt](http://en.wikipedia.org/wiki/Bcrypt) algorithm. This helps +[argon2](http://en.wikipedia.org/wiki/Argon2) algorithm. This helps protect against embarrassing password leaks if the server's database is compromised.