From 9f0bef76c3a338002b3578c265751cee643abb41 Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 17 Jan 2025 18:13:39 +0100 Subject: [PATCH 01/18] 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. From ccaeffd2d3fdc651a74e2a4daa0d9c6a9dd0052c Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 17 Jan 2025 18:14:53 +0100 Subject: [PATCH 02/18] Accounts-passowrd: Adding a test case to ensure smooth migration from bcrypt to argon2 --- packages/accounts-password/password_tests.js | 43 +++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index fdd466fe63..13896cf6ae 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1871,7 +1871,7 @@ if (Meteor.isServer) (() => { await Meteor.users.removeAsync(this.userId1); await Meteor.users.removeAsync(userId2); resolve(); - }, 5000); + }, 1000); return promise; } @@ -1928,4 +1928,45 @@ if (Meteor.isServer) (() => { }); }, 'already exists'); }); + + Tinytest.addAsync("passwords - migration from bcrypt encryption to argon2", async test => { + const username = Random.id(); + const email = `${username}@bcrypt.com`; + const password = "password"; + const bcryptPasswordHash = "$2b$10$XIz481R/8TTXqtl9igiPmeZexiLkhy7oTk4pfO/oN5ymQnS5mWilC";// = brcypt(sha256('password')) + const userId = await Meteor.users.insertAsync({ + username, + emails: [{ address: email, verified: false }], + services: { + password: { + bcrypt: bcryptPasswordHash + } + } + }); + let user = await Meteor.users.findOneAsync(userId); + const isValid = await Accounts._checkPasswordAsync(user, password); + test.equal(isValid.userId, userId, "checkPassword with bcrypt - User ID should be returned"); + test.equal(typeof isValid.error, "undefined", "checkPassword with bcrypt - No error should be returned"); + + + let resolve; + const promise = new Promise(res => resolve = res); + + // wait for defered execution of user update inside _checkPasswordAsync + Meteor.setTimeout(async () => { + user = await Meteor.users.findOneAsync(userId); + // bcrypt has been unset and argon2 set + test.equal(typeof user.services.password.bcrypt, "undefined", "bcrypt should be unset"); + test.equal(typeof user.services.password.argon2, "string", "argon2 should be set"); + // password is still valid using argon2 + const isValidArgon = await Accounts._checkPasswordAsync(user, password); + test.equal(isValidArgon.userId, userId, "checkPassword with argon2 - User ID should be returned"); + test.equal(typeof isValidArgon.error, "undefined", "checkPassword with argon2 - No error should be returned"); + resolve(); + }, 1000); + + return promise + + + }); })(); From c9c8fbc6d373406f6aeee16888395d88416060ff Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 17 Jan 2025 18:17:25 +0100 Subject: [PATCH 03/18] account-password: bump version to 3.0.4 --- packages/accounts-password/package.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/accounts-password/package.js b/packages/accounts-password/package.js index ca8a7b5651..14ef15ebe2 100644 --- a/packages/accounts-password/package.js +++ b/packages/accounts-password/package.js @@ -5,7 +5,7 @@ Package.describe({ // 2.2.x in the future. The version was also bumped to 2.0.0 temporarily // during the Meteor 1.5.1 release process, so versions 2.0.0-beta.2 // through -beta.5 and -rc.0 have already been published. - version: "3.0.3", + version: "3.0.4", }); Npm.depends({ From 30be82af6112721f8ad23070771fddc070512335 Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Mon, 20 Jan 2025 09:26:38 +0100 Subject: [PATCH 04/18] accounts-password: major version and more documentation about password encryption --- packages/accounts-password/package.js | 2 +- v3-docs/docs/api/accounts.md | 35 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/accounts-password/package.js b/packages/accounts-password/package.js index 14ef15ebe2..71d15a383f 100644 --- a/packages/accounts-password/package.js +++ b/packages/accounts-password/package.js @@ -5,7 +5,7 @@ Package.describe({ // 2.2.x in the future. The version was also bumped to 2.0.0 temporarily // during the Meteor 1.5.1 release process, so versions 2.0.0-beta.2 // through -beta.5 and -rc.0 have already been published. - version: "3.0.4", + version: "4.0.0", }); Npm.depends({ diff --git a/v3-docs/docs/api/accounts.md b/v3-docs/docs/api/accounts.md index 1317b97653..c84a2777a8 100644 --- a/v3-docs/docs/api/accounts.md +++ b/v3-docs/docs/api/accounts.md @@ -777,6 +777,41 @@ The Meteor server stores passwords using the protect against embarrassing password leaks if the server's database is compromised. +### Password encryption and security + +Passwords are hashed on the client using **SHA-256** algorithm before being sent to the server. This ensures that sensitive data is never transmitted in plain text. Once received by the server, the hashed value is further encrypted and securely stored in the `Meteor.users` collection. + +Starting from `accounts-passwords:4.0.0`, the encryption algorithm used is [Argon2](http://en.wikipedia.org/wiki/Argon2). This algorithm is specifically designed to resist GPU-based brute force attacks. Argon2 has replaced the previous [bcrypt](http://en.wikipedia.org/wiki/Bcrypt) algorithm as it's now regarded as a more robust option. For more details, see the [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html). + +**Migrating from `bcrypt` to `argon2`** + +The transition from `bcrypt` to `argon2` is handled seamlessly during user logins. When a user logs in, their password is first verified against the existing bcrypt hash. If successful, the password is re-encrypted using Argon2 and the new hash is stored in the database. + +**Configuring `argon2` parameters** + +The accounts-password package allows limited customization of the Argon2 algorithm's parameters. The configurable options include: + +- `timeCost` (default: 3) – This controls the computational cost of the hashing process, affecting both the security level and performance. + +To update the timeCost, use the following configuration: + +```js +Accounts.config({ + argon2Iterations: 4 +}); +``` + +Other Argon2 parameters, such as `hashLength`, `memoryCost`, `parallelism`, and `type`, are set to default values: +- `hashLength`: 32 bytes +- `memoryCost`: 64 MB +- `parallelism`: 4 +- `type`: Argon2id (provides a blend of resistance against GPU and side-channel attacks) + +For more information about Argon2's parameters, refer to the [argon2 options documentation](https://github.com/ranisalt/node-argon2/wiki/Options). + + +### Using passwords + To add password support to your application, run this command in your terminal: ```bash From bd5b155633879507280f464a0b57f1a989a681df Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Mon, 20 Jan 2025 11:55:27 +0100 Subject: [PATCH 05/18] remove old comments (replaced by jsdoc) --- packages/accounts-base/accounts_common.js | 35 ----------------------- 1 file changed, 35 deletions(-) diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index f641a7607e..1380302f58 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -194,41 +194,6 @@ export class AccountsCommon { ? this.users.findOneAsync(userId, this._addDefaultFieldSelector(options)) : null; } - // Set up config for the accounts system. Call this on both the client - // and the server. - // - // Note that this method gets overridden on AccountsServer.prototype, but - // the overriding method calls the overridden method. - // - // XXX we should add some enforcement that this is called on both the - // client and the server. Otherwise, a user can - // 'forbidClientAccountCreation' only on the client and while it looks - // like their app is secure, the server will still accept createUser - // calls. https://github.com/meteor/meteor/issues/828 - // - // @param options {Object} an object with fields: - // - sendVerificationEmail {Boolean} - // Send email address verification emails to new users created from - // client signups. - // - forbidClientAccountCreation {Boolean} - // Do not allow clients to create accounts directly. - // - restrictCreationByEmailDomain {Function or String} - // Require created users to have an email matching the function or - // having the string as domain. - // - loginExpirationInDays {Number} - // Number of days since login until a user is logged out (login token - // expires). - // - collection {String|Mongo.Collection} - // A collection name or a Mongo.Collection object to hold the users. - // - passwordResetTokenExpirationInDays {Number} - // Number of days since password reset token creation until the - // token can't be used any longer (password reset token expires). - // - ambiguousErrorMessages {Boolean} - // Return ambiguous error messages from login failures to prevent - // user enumeration. - // - argon2Iterations {Number} - // Allows override of number of argon2 iterations (aka time cost) used - // to store passwords. /** * @summary Set global accounts options. You can also set these in `Meteor.settings.packages.accounts` without the need to call this function. From 78f16e4642614a0a137927ddb90ba8541dd4bd35 Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Mon, 20 Jan 2025 11:55:02 +0100 Subject: [PATCH 06/18] enable using other argon2 settings --- packages/accounts-base/accounts-base.d.ts | 5 +- packages/accounts-base/accounts_common.js | 10 +- packages/accounts-password/password_server.js | 91 +++++++++++-------- v3-docs/docs/api/accounts.md | 20 ++-- 4 files changed, 76 insertions(+), 50 deletions(-) diff --git a/packages/accounts-base/accounts-base.d.ts b/packages/accounts-base/accounts-base.d.ts index 7dacf0e4a9..0c44cc7657 100644 --- a/packages/accounts-base/accounts-base.d.ts +++ b/packages/accounts-base/accounts-base.d.ts @@ -81,7 +81,10 @@ export namespace Accounts { passwordEnrollTokenExpiration?: number | undefined; passwordEnrollTokenExpirationInDays?: number | undefined; ambiguousErrorMessages?: boolean | undefined; - argon2Iterations?: number | undefined; + argon2Type?: string | undefined; + argon2TimeCost: number | undefined; + argon2MemoryCost: number | undefined; + argon2Parallelism: number | undefined; defaultFieldSelector?: { [key: string]: 0 | 1 } | undefined; collection?: string | undefined; loginTokenExpirationHours?: number | undefined; diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 1380302f58..9186a9ac85 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -13,7 +13,10 @@ const VALID_CONFIG_KEYS = [ 'passwordEnrollTokenExpirationInDays', 'passwordEnrollTokenExpiration', 'ambiguousErrorMessages', - 'argon2Iterations', + 'argon2Type', + 'argon2TimeCost', + 'argon2MemoryCost', + 'argon2Parallelism', 'defaultFieldSelector', 'collection', 'loginTokenExpirationHours', @@ -210,7 +213,10 @@ 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.argon2Iterations Allows override of number of argon2 iterations (aka time cost) used to store passwords. The default is 3. + * @param {'argon2id' | 'argon2i' | 'argon2d'} options.argon2Type Allows override of the argon2 algorithm type. The default is `argon2id`. + * @param {Number} options.argon2TimeCost Allows override of number of argon2 iterations (aka time cost) used to store passwords. The default is 3. + * @param {Number} options.argon2MemoryCost Allows override of the amount of memory (in KiB) used by the argon2 algorithm. The default is 65536 (64MB). + * @param {Number} options.argon2Parallelism Allows override of the number of threads used by the argon2 algorithm. The default is 4. * @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/password_server.js b/packages/accounts-password/password_server.js index f97a115fb7..9bd9470035 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -23,7 +23,16 @@ const getUserById = // receives a password as an object, it asserts that the algorithm is // "sha-256" and then passes the digest to argon2. -Accounts._argon2Iterations = () => Accounts._options.argon2Iterations || 3; +const ARGON2_TYPES = { + argon2i: argon2.argon2i, + argon2d: argon2.argon2d, + argon2id: argon2.argon2id +}; + +Accounts._argon2Type = () => ARGON2_TYPES[Accounts._options.argon2Type] || argon2.argon2id; +Accounts._argon2TimeCost = () => Accounts._options.argon2TimeCost || 3; +Accounts._argon2MemoryCost = () => Accounts._options.argon2MemoryCost || 65536; +Accounts._argon2Parallelism = () => Accounts._options.argon2Parallelism || 4; /** * Extracts the string to be encrypted using Argon2 from the given `password`. @@ -59,39 +68,39 @@ const getPasswordString = password => { const hashPassword = async password => { password = getPasswordString(password); return await argon2.hash(password, { - timeCost: Accounts._argon2Iterations(), - type: argon2.argon2id + type: Accounts._argon2Type(), + timeCost: Accounts._argon2TimeCost(), + memoryCost: Accounts._argon2MemoryCost(), + parallelism: Accounts._argon2Parallelism(), }); }; /** - * Extract the number of iterations used in the specified argon2 hash - * @param hash String - * @returns {null|number} + * Extract readable parameters from an Argon2 hash string. + * @param {string} hash - The Argon2 hash string. + * @returns {object} An object containing the parsed parameters. + * @throws {Error} If the hash format is invalid. */ -const getIterationsFromArgon2Hash = function(hash) { - const parts = hash?.split("$") || []; - if (parts.length < 4 || !parts[1].startsWith("argon2")) { - throw new Error("Invalid Argon2 hash format"); +function getArgon2Params(hash) { + const regex = /^\$(argon2(?:i|d|id))\$v=\d+\$m=(\d+),t=(\d+),p=(\d+)/; + + const match = hash.match(regex); + + if (!match) { + throw new Error("Invalid Argon2 hash format."); } - const params = parts[3].split(","); - let iterations = null; + const [, type, memoryCost, timeCost, parallelism] = match; - for (const param of params) { - if (param.startsWith("t=")) { - iterations = parseInt(param.split("=")[1], 10); - break; - } - } + return { + type: ARGON2_TYPES[type], + timeCost: parseInt(timeCost, 10), + memoryCost: parseInt(memoryCost, 10), + parallelism: parseInt(parallelism, 10) + }; +} - if (iterations === null) { - throw new Error("Iterations parameter not found in the hash"); - } - - return iterations; -}; -Accounts._getIterationsFromArgon2Hash = getIterationsFromArgon2Hash; +Accounts._getArgon2Params = getArgon2Params; const getUserPasswordHash = user => { return user.services?.password?.argon2 || user.services?.password?.bcrypt; @@ -157,24 +166,30 @@ const checkPasswordAsync = async (user, password) => { } else { // argon2 password - const argon2Iterations = getIterationsFromArgon2Hash(hash); + const argon2Params = getArgon2Params(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) + else if (hash) { + const paramsChanged = argon2Params.memoryCost !== Accounts._argon2MemoryCost() || + argon2Params.timeCost !== Accounts._argon2TimeCost() || + argon2Params.parallelism !== Accounts._argon2Parallelism() || + argon2Params.type !== Accounts._argon2Type(); + if (paramsChanged === true) { + // The password checks out, but the user's argon2 hash needs to be updated with the right params + Meteor.defer(async () => { + await Meteor.users.updateAsync( + { _id: user._id }, + { + $set: { + "services.password.argon2": + await hashPassword(formattedPassword) + } } - } - ); - }); + ); + }); + } } } diff --git a/v3-docs/docs/api/accounts.md b/v3-docs/docs/api/accounts.md index c84a2777a8..79d54b5738 100644 --- a/v3-docs/docs/api/accounts.md +++ b/v3-docs/docs/api/accounts.md @@ -789,23 +789,25 @@ The transition from `bcrypt` to `argon2` is handled seamlessly during user login **Configuring `argon2` parameters** -The accounts-password package allows limited customization of the Argon2 algorithm's parameters. The configurable options include: +The accounts-password package allows customization of the Argon2 algorithm's parameters. The configurable options include: +- `type`: `argon2id` (provides a blend of resistance against GPU and side-channel attacks) - `timeCost` (default: 3) – This controls the computational cost of the hashing process, affecting both the security level and performance. +- `memoryCost`: 65536 (64 MB) - The amount of memory used by the algorithm in KiB per thread +- `parallelism`: 4 - The number of threads used by the algorithm -To update the timeCost, use the following configuration: - +To update the values, use the following configuration: ```js Accounts.config({ - argon2Iterations: 4 + argon2Type: "argon2id", + argon2TimeCost: 4, + argon2MemoryCost: 65536, + argon2Parallelism: 4, }); ``` -Other Argon2 parameters, such as `hashLength`, `memoryCost`, `parallelism`, and `type`, are set to default values: -- `hashLength`: 32 bytes -- `memoryCost`: 64 MB -- `parallelism`: 4 -- `type`: Argon2id (provides a blend of resistance against GPU and side-channel attacks) +Other Argon2 parameters, such as `hashLength`, are kept to default values: +- `hashLength`: 32 bytes - The length of the hash output in bytes For more information about Argon2's parameters, refer to the [argon2 options documentation](https://github.com/ranisalt/node-argon2/wiki/Options). From c3b3e4c42e9e72961e9d93afecfabdb6533cac54 Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Mon, 20 Jan 2025 12:28:58 +0100 Subject: [PATCH 07/18] update tests for argon2 settings --- packages/accounts-password/password_tests.js | 68 +++++++++++++------- 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 13896cf6ae..f50943e59f 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1822,33 +1822,47 @@ if (Meteor.isServer) (() => { ]); }); - const getUserHashArgon2Iterations = function (user) { + const getUserHashArgon2Params = function (user) { const hash = user?.services?.password?.argon2; - return Accounts._getIterationsFromArgon2Hash(hash); + return Accounts._getArgon2Params(hash); } - testAsyncMulti("passwords - allow custom argon2 iterations",[ - async function (test) { + testAsyncMulti("passwords - allow custom argon2 Params", [ + async function(test) { // Verify that a argon2 hash generated for a new account uses the - // default number of iterations. + // default params. let username = Random.id(); - this.password = hashPassword('abc123'); + this.password = hashPassword("abc123"); this.userId1 = await Accounts.createUser({ username, password: this.password }); - this.user1 = await Meteor.users.findOneAsync(this.userId1); - let rounds = getUserHashArgon2Iterations(this.user1); - test.equal(rounds, Accounts._argon2Iterations()); + this.user1 = await Meteor.users.findOneAsync(this.userId1); + let argon2Params = getUserHashArgon2Params(this.user1); + test.equal(argon2Params.type, Accounts._argon2Type()); + test.equal(argon2Params.memoryCost, Accounts._argon2MemoryCost()); + test.equal(argon2Params.timeCost, Accounts._argon2TimeCost()); + test.equal(argon2Params.parallelism, Accounts._argon2Parallelism()); - // When a custom number of argon2 iterations is set via Accounts.config, - // and an account was already created using the default number of iterations, + + // When a custom number of argon2 TimeCost is set via Accounts.config, + // and an account was already created using the default number of TimeCost, // make sure that a new hash is created (and stored) using the new number - // of iterations, the next time the password is checked. - this.customIterations = 4; - Accounts._options.argon2Iterations = this.customIterations; + // of TimeCost, the next time the password is checked. + this.customType = "argon2d"; // argon2.argon2d = 2 + this.customTimeCost = 4; + this.customMemoryCost = 32768; + this.customParallelism = 1; + Accounts._options.argon2Type = this.customType; + Accounts._options.argon2TimeCost = this.customTimeCost; + Accounts._options.argon2MemoryCost = this.customMemoryCost; + Accounts._options.argon2Parallelism = this.customParallelism; + await Accounts._checkPasswordAsync(this.user1, this.password); }, async function(test) { - const defaultRounds = Accounts._argon2Iterations(); - let rounds; + const defaultType = Accounts._argon2Type(); + const defaultTimeCost = Accounts._argon2TimeCost(); + const defaultMemoryCost = Accounts._argon2MemoryCost(); + const defaultParallelism = Accounts._argon2Parallelism(); + let params; let username; let resolve; @@ -1856,18 +1870,28 @@ if (Meteor.isServer) (() => { Meteor.setTimeout(async () => { this.user1 = await Meteor.users.findOneAsync(this.userId1); - rounds = getUserHashArgon2Iterations(this.user1); - test.equal(rounds, this.customIterations); - // When a custom number of argon2 iterations is set, make sure it's + params = getUserHashArgon2Params(this.user1); + test.equal(params.type, 2); + test.equal(params.timeCost, this.customTimeCost); + test.equal(params.memoryCost, this.customMemoryCost); + test.equal(params.parallelism, this.customParallelism); + + // When a custom number of argon2 TimeCost 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 = getUserHashArgon2Iterations(user2); - test.equal(rounds, this.customIterations); + params = getUserHashArgon2Params(user2); + test.equal(params.type, 2); + test.equal(params.timeCost, this.customTimeCost); + test.equal(params.memoryCost, this.customMemoryCost); + test.equal(params.parallelism, this.customParallelism); // Cleanup - Accounts._options.argon2Iterations = defaultRounds; + Accounts._options.argon2Type = defaultType; + Accounts._options.argon2TimeCost = defaultTimeCost; + Accounts._options.argon2MemoryCost = defaultMemoryCost; + Accounts._options.argon2Parallelism = defaultParallelism; await Meteor.users.removeAsync(this.userId1); await Meteor.users.removeAsync(userId2); resolve(); From 48e33b7bdcb3a15e3847f05b3e3b8ddb77d8e98d Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 31 Jan 2025 10:35:33 +0100 Subject: [PATCH 08/18] feat: add feature toggle argon2Enabled --- packages/accounts-base/accounts-base.d.ts | 8 +- packages/accounts-base/accounts_common.js | 4 + packages/accounts-password/password_server.js | 378 ++++++++++-------- 3 files changed, 230 insertions(+), 160 deletions(-) diff --git a/packages/accounts-base/accounts-base.d.ts b/packages/accounts-base/accounts-base.d.ts index 0c44cc7657..7fbbfde0a6 100644 --- a/packages/accounts-base/accounts-base.d.ts +++ b/packages/accounts-base/accounts-base.d.ts @@ -81,6 +81,8 @@ export namespace Accounts { passwordEnrollTokenExpiration?: number | undefined; passwordEnrollTokenExpirationInDays?: number | undefined; ambiguousErrorMessages?: boolean | undefined; + bcryptRounds?: number | undefined; + argon2Enabled?: string | false; argon2Type?: string | undefined; argon2TimeCost: number | undefined; argon2MemoryCost: number | undefined; @@ -356,10 +358,10 @@ export namespace Accounts { /** * - * Check whether the provided password matches the argon2'ed password in + * Check whether the provided password matches the encrypted password in * the database user record. `password` can be a string (in which case - * it will be run through SHA256 before argon2) or an object with - * properties `digest` and `algorithm` (in which case we argon2 + * it will be run through SHA256 before bcrypt or argon2) or an object with + * properties `digest` and `algorithm` (in which case we bcrypt/argon2 * `password.digest`). */ function _checkPasswordAsync( diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 9186a9ac85..d95eae8aba 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -13,6 +13,8 @@ const VALID_CONFIG_KEYS = [ 'passwordEnrollTokenExpirationInDays', 'passwordEnrollTokenExpiration', 'ambiguousErrorMessages', + 'bcryptRounds', + 'argon2Enabled', 'argon2Type', 'argon2TimeCost', 'argon2MemoryCost', @@ -213,6 +215,8 @@ 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 {Boolean} options.argon2Enabled Enable argon2 algorithm usage in replacement for bcrypt. The default is `false`. * @param {'argon2id' | 'argon2i' | 'argon2d'} options.argon2Type Allows override of the argon2 algorithm type. The default is `argon2id`. * @param {Number} options.argon2TimeCost Allows override of number of argon2 iterations (aka time cost) used to store passwords. The default is 3. * @param {Number} options.argon2MemoryCost Allows override of the amount of memory (in KiB) used by the argon2 algorithm. The default is 65536 (64MB). diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 9bd9470035..3498de2721 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -1,5 +1,5 @@ import argon2 from "argon2"; -import { compare as bcryptCompare } from "bcrypt"; +import { hash as bcryptHash, compare as bcryptCompare } from "bcrypt"; import { Accounts } from "meteor/accounts-base"; // Utility for grabbing user @@ -8,7 +8,7 @@ const getUserById = await Meteor.users.findOneAsync(id, Accounts._addDefaultFieldSelector(options)); // 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.bcrypt', which stores the bcrypt password, which will be deprecated // - 'services.password.argon2', which stores the argon2 password // // When the client sends a password to the server, it can either be a @@ -19,9 +19,13 @@ const getUserById = // strings. // // When the server receives a plaintext password as a string, it always -// hashes it with SHA256 before passing it into argon2. When the server +// hashes it with SHA256 before passing it into bcrypt / argon2. When the server // receives a password as an object, it asserts that the algorithm is -// "sha-256" and then passes the digest to argon2. +// "sha-256" and then passes the digest to bcrypt / argon2. + +Accounts._bcryptRounds = () => Accounts._options.bcryptRounds || 10; + +Accounts._argon2Enabled = () => Accounts._options.argon2Enabled || false; const ARGON2_TYPES = { argon2i: argon2.argon2i, @@ -35,7 +39,7 @@ Accounts._argon2MemoryCost = () => Accounts._options.argon2MemoryCost || 65536; Accounts._argon2Parallelism = () => Accounts._options.argon2Parallelism || 4; /** - * Extracts the string to be encrypted using Argon2 from the given `password`. + * Extracts the string to be encrypted using bcrypt or Argon2 from the given `password`. * * @param {string|Object} password - The password provided by the client. It can be: * - A plaintext string password. @@ -50,31 +54,51 @@ Accounts._argon2Parallelism = () => Accounts._options.argon2Parallelism || 4; const getPasswordString = password => { if (typeof password === "string") { password = SHA256(password); - } else { // 'password' is an object + } + else { // 'password' is an object if (password.algorithm !== "sha-256") { throw new Error("Invalid password hash algorithm. " + - "Only 'sha-256' is allowed."); + "Only 'sha-256' is allowed."); } password = password.digest; } return password; }; -// 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 argon2) or an object with properties `digest` and -// `algorithm` (in which case we argon2 `password.digest`). -// -const hashPassword = async password => { +/** + * Encrypt the given `password` using either bcrypt or Argon2. + * @param password can be a string (in which case it will be run through SHA256 before encryption) or an object with properties `digest` and `algorithm` (in which case we bcrypt or Argon2 `password.digest`). + * @returns {Promise} The encrypted password. + */ +const hashPassword = async (password) => { password = getPasswordString(password); - return await argon2.hash(password, { - type: Accounts._argon2Type(), - timeCost: Accounts._argon2TimeCost(), - memoryCost: Accounts._argon2MemoryCost(), - parallelism: Accounts._argon2Parallelism(), - }); + if (Accounts._argon2Enabled() === true) { + return await argon2.hash(password, { + type: Accounts._argon2Type(), + timeCost: Accounts._argon2TimeCost(), + memoryCost: Accounts._argon2MemoryCost(), + parallelism: Accounts._argon2Parallelism() + }); + } + else { + return await bcryptHash(password, Accounts._bcryptRounds()); + } }; +// 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); + } + } + return rounds; +}; +Accounts._getRoundsFromBcryptHash = getRoundsFromBcryptHash; + + /** * Extract readable parameters from an Argon2 hash string. * @param {string} hash - The Argon2 hash string. @@ -108,6 +132,49 @@ const getUserPasswordHash = user => { Accounts._checkPasswordUserFields = { _id: 1, services: 1 }; +const isBcrypt = (hash) => { + // bcrypt hashes start with $2a$ or $2b$ + // argon2 hashes start with $argon2i$, $argon2d$ or $argon2id$ + return hash.startsWith("$2"); +}; + +const updateUserPasswordDefered = (user, formattedPassword) => { + Meteor.defer(async () => { + await updateUserPassword(user, formattedPassword); + }); +}; + +/** + * Hashes the provided password and returns an object that can be used to update the user's password. + * @param formattedPassword + * @returns {Promise<{$set: {"services.password.bcrypt": string}}|{$unset: {"services.password.bcrypt": number}, $set: {"services.password.argon2": string}}>} + */ +const getUpdatorForUserPassword = async (formattedPassword) => { + const encryptedPassword = await hashPassword(formattedPassword); + if (Accounts._argon2Enabled() === false) { + return { + $set: { + "services.password.bcrypt": encryptedPassword + } + }; + } + else if (Accounts._argon2Enabled() === true) { + return { + $set: { + "services.password.argon2": encryptedPassword + }, + $unset: { + "services.password.bcrypt": 1 + } + }; + } +}; + +const updateUserPassword = async (user, formattedPassword) => { + const updator = await getUpdatorForUserPassword(formattedPassword); + await Meteor.users.updateAsync({ _id: user._id }, updator); +}; + /** * Checks whether the provided password matches the hashed password stored in the user's database record. * @@ -138,60 +205,55 @@ const checkPasswordAsync = async (user, password) => { const formattedPassword = getPasswordString(password); const hash = getUserPasswordHash(user); - // 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 argon2Enabled = Accounts._argon2Enabled(); + if (argon2Enabled === false) { + const hashRounds = getRoundsFromBcryptHash(hash); 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 argon2Params = getArgon2Params(hash); - - if (!(await argon2.verify(hash, formattedPassword))) { - result.error = Accounts._handleError("Incorrect password", false); - } else if (hash) { - const paramsChanged = argon2Params.memoryCost !== Accounts._argon2MemoryCost() || - argon2Params.timeCost !== Accounts._argon2TimeCost() || - argon2Params.parallelism !== Accounts._argon2Parallelism() || - argon2Params.type !== Accounts._argon2Type(); + const paramsChanged = hashRounds !== Accounts._bcryptRounds(); + // The password checks out, but the user's bcrypt hash needs to be updated + // to match current bcrypt settings if (paramsChanged === true) { - // The password checks out, but the user's argon2 hash needs to be updated with the right params - Meteor.defer(async () => { - await Meteor.users.updateAsync( - { _id: user._id }, - { - $set: { - "services.password.argon2": - await hashPassword(formattedPassword) - } - } - ); - }); + updateUserPasswordDefered(user, { digest: formattedPassword, algorithm: "sha-256" }); } } } + else if (argon2Enabled === true) { + if (isBcrypt(hash)) { + // 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 + updateUserPasswordDefered(user, { digest: formattedPassword, algorithm: "sha-256" }); + } + } + else { + // argon2 password + const argon2Params = getArgon2Params(hash); + const match = await argon2.verify(hash, formattedPassword); + if (!match) { + result.error = Accounts._handleError("Incorrect password", false); + } + else if (hash) { + const paramsChanged = argon2Params.memoryCost !== Accounts._argon2MemoryCost() || + argon2Params.timeCost !== Accounts._argon2TimeCost() || + argon2Params.parallelism !== Accounts._argon2Parallelism() || + argon2Params.type !== Accounts._argon2Type(); + if (paramsChanged === true) { + // The password checks out, but the user's argon2 hash needs to be updated with the right params + updateUserPasswordDefered(user, { digest: formattedPassword, algorithm: "sha-256" }); + } + } + } + } + return result; }; @@ -361,54 +423,54 @@ Accounts.setUsername = // `digest` and `algorithm` (representing the SHA256 of the password). Meteor.methods( { - changePassword: async function (oldPassword, newPassword) { - check(oldPassword, passwordValidator); - check(newPassword, passwordValidator); + changePassword: async function(oldPassword, newPassword) { + check(oldPassword, passwordValidator); + check(newPassword, passwordValidator); - if (!this.userId) { - throw new Meteor.Error(401, "Must be logged in"); - } - - const user = await getUserById(this.userId, {fields: { - services: 1, - ...Accounts._checkPasswordUserFields, - }}); - if (!user) { - Accounts._handleError("User not found"); - } - - if (!getUserPasswordHash(user)) { - Accounts._handleError("User has no password set"); - } - - const result = await checkPasswordAsync(user, oldPassword); - if (result.error) { - throw result.error; - } - - const hashed = await hashPassword(newPassword); - - // It would be better if this removed ALL existing tokens and replaced - // the token for the current connection with a new one, but that would - // be tricky, so we'll settle for just replacing all tokens other than - // the one for the current connection. - const currentToken = Accounts._getLoginToken(this.connection.id); - await Meteor.users.updateAsync( - { _id: this.userId }, - { - $set: { 'services.password.argon2': hashed }, - $pull: { - 'services.resume.loginTokens': { hashedToken: { $ne: currentToken } } - }, - $unset: { - 'services.password.reset': 1, - 'services.password.bcrypt': 1 + if (!this.userId) { + throw new Meteor.Error(401, "Must be logged in"); } - } - ); - return {passwordChanged: true}; -}}); + const user = await getUserById(this.userId, { + fields: { + services: 1, + ...Accounts._checkPasswordUserFields + } + }); + if (!user) { + Accounts._handleError("User not found"); + } + + if (!getUserPasswordHash(user)) { + Accounts._handleError("User has no password set"); + } + + const result = await checkPasswordAsync(user, oldPassword); + if (result.error) { + throw result.error; + } + + // It would be better if this removed ALL existing tokens and replaced + // the token for the current connection with a new one, but that would + // be tricky, so we'll settle for just replacing all tokens other than + // the one for the current connection. + const currentToken = Accounts._getLoginToken(this.connection.id); + const updator = await getUpdatorForUserPassword(newPassword); + + await Meteor.users.updateAsync( + { _id: this.userId }, + { + $set: updator.$set, + $pull: { + "services.resume.loginTokens": { hashedToken: { $ne: currentToken } } + }, + $unset: { "services.password.reset": 1, ...updator.$unset } + } + ); + + return { passwordChanged: true }; + } + }); // Force change the users password. @@ -424,33 +486,27 @@ Meteor.methods( */ Accounts.setPasswordAsync = async (userId, newPlaintextPassword, options) => { - check(userId, String); - check(newPlaintextPassword, Match.Where(str => Match.test(str, String) && str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256)); - check(options, Match.Maybe({ logout: Boolean })); - options = { logout: true , ...options }; + check(userId, String); + check(newPlaintextPassword, Match.Where(str => Match.test(str, String) && str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256)); + check(options, Match.Maybe({ logout: Boolean })); + options = { logout: true, ...options }; - const user = await getUserById(userId, { fields: { _id: 1 } }); - if (!user) { - throw new Meteor.Error(403, "User not found"); - } - - const update = { - $unset: { - 'services.password.reset': 1, - 'services.password.bcrypt': 1 - }, - $set: { - 'services.password.argon2': await hashPassword(newPlaintextPassword) + const user = await getUserById(userId, { fields: { _id: 1 } }); + if (!user) { + throw new Meteor.Error(403, "User not found"); } + + let updator = await getUpdatorForUserPassword(newPlaintextPassword); + updator.$unset = updator.$unset || {}; + updator.$unset["services.password.reset"] = 1; + + if (options.logout) { + updator.$unset["services.resume.loginTokens"] = 1; + } + + await Meteor.users.updateAsync({ _id: user._id }, updator); }; - if (options.logout) { - update.$unset['services.resume.loginTokens'] = 1; - } - - await Meteor.users.updateAsync({_id: user._id}, update); -}; - /// /// RESETTING VIA EMAIL /// @@ -749,8 +805,6 @@ Meteor.methods( error: new Meteor.Error(403, "Token has invalid email address") }; - const hashed = await hashPassword(newPassword); - // NOTE: We're about to invalidate tokens on the user, who we might be // logged in as. Make sure to avoid logging ourselves out if this // happens. But also make sure not to leave the connection in a state @@ -760,6 +814,8 @@ Meteor.methods( const resetToOldToken = () => Accounts._setLoginToken(user._id, this.connection, oldToken); + const updator = await getUpdatorForUserPassword(newPassword); + try { // Update the user record by: // - Changing the password to the new one @@ -771,34 +827,35 @@ Meteor.methods( affectedRecords = await Meteor.users.updateAsync( { _id: user._id, - 'emails.address': email, - 'services.password.enroll.token': token + "emails.address": email, + "services.password.enroll.token": token }, { $set: { - 'services.password.argon2': hashed, - 'emails.$.verified': true + "emails.$.verified": true, + ...updator.$set }, $unset: { - 'services.password.enroll': 1, - 'services.password.bcrypt': 1, + "services.password.enroll": 1, + ...updator.$unset } }); - } else { + } + else { affectedRecords = await Meteor.users.updateAsync( { _id: user._id, - 'emails.address': email, - 'services.password.reset.token': token + "emails.address": email, + "services.password.reset.token": token }, { $set: { - 'services.password.argon2': hashed, - 'emails.$.verified': true + "emails.$.verified": true, + ...updator.$set }, $unset: { - 'services.password.reset': 1, - 'services.password.bcrypt': 1, + "services.password.reset": 1, + ...updator.$unset } }); } @@ -817,15 +874,16 @@ Meteor.methods( await Accounts._clearAllLoginTokens(user._id); if (Accounts._check2faEnabled?.(user)) { - return { - userId: user._id, - error: Accounts._handleError( - 'Changed password, but user not logged in because 2FA is enabled', - false, - '2fa-enabled' - ), - }; - }return { userId: user._id }; + return { + userId: user._id, + error: Accounts._handleError( + 'Changed password, but user not logged in because 2FA is enabled', + false, + '2fa-enabled' + ), + }; + } + return { userId: user._id }; } ); } @@ -1103,7 +1161,13 @@ const createUser = const user = { services: {} }; if (password) { const hashed = await hashPassword(password); - user.services.password = { argon2: hashed }; + const argon2Enabled = Accounts._argon2Enabled(); + if (argon2Enabled === false) { + user.services.password = { bcrypt: hashed }; + } + else { + user.services.password = { argon2: hashed }; + } } return await Accounts._createUserCheckingDuplicates({ user, email, username, options }); From 9325a6f1df741ca8ffb319b6cfcaaf193cd96eef Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 31 Jan 2025 10:35:59 +0100 Subject: [PATCH 09/18] fix: remove console log of Urls when Meteor.isPackageTest --- packages/accounts-password/password_server.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 3498de2721..8d738b7fd5 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -697,7 +697,7 @@ Accounts.sendResetPasswordEmail = const options = await Accounts.generateOptionsForEmail(realEmail, user, url, 'resetPassword'); await Email.sendAsync(options); - if (Meteor.isDevelopment) { + if (Meteor.isDevelopment && !Meteor.isPackageTest) { console.log(`\nReset password URL: ${ url }`); } return { email: realEmail, user, token, url, options }; @@ -733,7 +733,7 @@ Accounts.sendEnrollmentEmail = await Accounts.generateOptionsForEmail(realEmail, user, url, 'enrollAccount'); await Email.sendAsync(options); - if (Meteor.isDevelopment) { + if (Meteor.isDevelopment && !Meteor.isPackageTest) { console.log(`\nEnrollment email URL: ${ url }`); } return { email: realEmail, user, token, url, options }; @@ -919,7 +919,7 @@ Accounts.sendVerificationEmail = const url = Accounts.urls.verifyEmail(token, extraParams); const options = await Accounts.generateOptionsForEmail(realEmail, user, url, 'verifyEmail'); await Email.sendAsync(options); - if (Meteor.isDevelopment) { + if (Meteor.isDevelopment && !Meteor.isPackageTest) { console.log(`\nVerification email URL: ${ url }`); } return { email: realEmail, user, token, url, options }; From 41de1fe907c660399f876ac20f367e45cd9dfb4c Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 31 Jan 2025 11:03:34 +0100 Subject: [PATCH 10/18] fix: revert to initial password_tests file --- packages/accounts-password/password_tests.js | 319 ++++++++----------- 1 file changed, 125 insertions(+), 194 deletions(-) diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index f50943e59f..7e895374cd 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -802,7 +802,7 @@ if (Meteor.isClient) (() => { // Can update own profile using ID. await Meteor.users.updateAsync( this.userId, { $set: { 'profile.updated': 42 } }, - ); + ); test.equal(42, Meteor.user().profile.updated); }, logoutStep @@ -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.argon2; + const oldSaltedHash = user.services.password.bcrypt; 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.argon2; + const newSaltedHash = user.services.password.bcrypt; 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.argon2; + const newerSaltedHash = user.services.password.bcrypt; test.isTrue(newerSaltedHash); test.notEqual(oldSaltedHash, newerSaltedHash); test.notEqual(newSaltedHash, newerSaltedHash); @@ -1727,142 +1727,124 @@ if (Meteor.isServer) (() => { }); Tinytest.addAsync("passwords - add email when user has not an existing email", - async test => { - const userId = await Accounts.createUser({ - username: `user${ Random.id() }` - }); + async test => { + const userId = await Accounts.createUser({ + username: `user${ Random.id() }` + }); - const newEmail = `${ Random.id() }@turing.com`; - await Accounts.addEmailAsync(userId, newEmail); - const u1 = await Accounts._findUserByQuery({ id: userId }) - test.equal(u1.emails, [ - { address: newEmail, verified: false }, - ]); - }); + const newEmail = `${ Random.id() }@turing.com`; + await Accounts.addEmailAsync(userId, newEmail); + const u1 = await Accounts._findUserByQuery({ id: userId }) + test.equal(u1.emails, [ + { address: newEmail, verified: false }, + ]); + }); Tinytest.addAsync("passwords - add email when the user has an existing email " + "only differing in case", async test => { - const origEmail = `${ Random.id() }@turing.com`; - const userId = await Accounts.createUser({ - email: origEmail + const origEmail = `${ Random.id() }@turing.com`; + const userId = await Accounts.createUser({ + email: origEmail + }); + + const newEmail = `${ Random.id() }@turing.com`; + await Accounts.addEmailAsync(userId, newEmail); + + const thirdEmail = origEmail.toUpperCase(); + await Accounts.addEmailAsync(userId, thirdEmail, true); + const u1 = await Accounts._findUserByQuery({ id: userId }) + test.equal(u1.emails, [ + { address: thirdEmail, verified: true }, + { address: newEmail, verified: false } + ]); }); - const newEmail = `${ Random.id() }@turing.com`; - await Accounts.addEmailAsync(userId, newEmail); - - const thirdEmail = origEmail.toUpperCase(); - await Accounts.addEmailAsync(userId, thirdEmail, true); - const u1 = await Accounts._findUserByQuery({ id: userId }) - test.equal(u1.emails, [ - { address: thirdEmail, verified: true }, - { address: newEmail, verified: false } - ]); - }); - Tinytest.addAsync("passwords - add email should fail when there is an existing " + "user with an email only differing in case", async test => { - const user1Email = `${ Random.id() }@turing.com`; - const userId1 = await Accounts.createUser({ - email: user1Email + const user1Email = `${ Random.id() }@turing.com`; + const userId1 = await Accounts.createUser({ + email: user1Email + }); + + const user2Email = `${ Random.id() }@turing.com`; + const userId2 = await Accounts.createUser({ + email: user2Email + }); + + const dupEmail = user1Email.toUpperCase(); + await test.throwsAsync( + async () => await Accounts.addEmailAsync(userId2, dupEmail), + /Email already exists/ + ); + + const u1 = await Accounts._findUserByQuery({ id: userId1 }) + test.equal(u1.emails, [ + { address: user1Email, verified: false } + ]); + const u2 = await Accounts._findUserByQuery({ id: userId2 }) + test.equal(u2.emails, [ + { address: user2Email, verified: false } + ]); }); - const user2Email = `${ Random.id() }@turing.com`; - const userId2 = await Accounts.createUser({ - email: user2Email - }); - - const dupEmail = user1Email.toUpperCase(); - await test.throwsAsync( - async () => await Accounts.addEmailAsync(userId2, dupEmail), - /Email already exists/ - ); - - const u1 = await Accounts._findUserByQuery({ id: userId1 }) - test.equal(u1.emails, [ - { address: user1Email, verified: false } - ]); - const u2 = await Accounts._findUserByQuery({ id: userId2 }) - test.equal(u2.emails, [ - { address: user2Email, verified: false } - ]); - }); - Tinytest.addAsync("passwords - remove email", async test => { - const origEmail = `${ Random.id() }@turing.com`; - const userId = await Accounts.createUser({ - email: origEmail + const origEmail = `${ Random.id() }@turing.com`; + const userId = await Accounts.createUser({ + email: origEmail + }); + + const newEmail = `${ Random.id() }@turing.com`; + await Accounts.addEmailAsync(userId, newEmail); + + const thirdEmail = `${ Random.id() }@turing.com`; + await Accounts.addEmailAsync(userId, thirdEmail, true); + const u1 = await Accounts._findUserByQuery({ id: userId }) + test.equal(u1.emails, [ + { address: origEmail, verified: false }, + { address: newEmail, verified: false }, + { address: thirdEmail, verified: true } + ]); + + await Accounts.removeEmail(userId, newEmail); + const u2 = await Accounts._findUserByQuery({ id: userId }) + test.equal(u2.emails, [ + { address: origEmail, verified: false }, + { address: thirdEmail, verified: true } + ]); + + await Accounts.removeEmail(userId, origEmail); + const u3 = await Accounts._findUserByQuery({ id: userId }) + test.equal(u3.emails, [ + { address: thirdEmail, verified: true } + ]); }); - const newEmail = `${ Random.id() }@turing.com`; - await Accounts.addEmailAsync(userId, newEmail); - - const thirdEmail = `${ Random.id() }@turing.com`; - await Accounts.addEmailAsync(userId, thirdEmail, true); - const u1 = await Accounts._findUserByQuery({ id: userId }) - test.equal(u1.emails, [ - { address: origEmail, verified: false }, - { address: newEmail, verified: false }, - { address: thirdEmail, verified: true } - ]); - - await Accounts.removeEmail(userId, newEmail); - const u2 = await Accounts._findUserByQuery({ id: userId }) - test.equal(u2.emails, [ - { address: origEmail, verified: false }, - { address: thirdEmail, verified: true } - ]); - - await Accounts.removeEmail(userId, origEmail); - const u3 = await Accounts._findUserByQuery({ id: userId }) - test.equal(u3.emails, [ - { address: thirdEmail, verified: true } - ]); - }); - - const getUserHashArgon2Params = function (user) { - const hash = user?.services?.password?.argon2; - return Accounts._getArgon2Params(hash); - } - - testAsyncMulti("passwords - allow custom argon2 Params", [ - async function(test) { - // Verify that a argon2 hash generated for a new account uses the - // default params. + const getUserHashRounds = user => + Number(user.services.password.bcrypt.substring(4, 6)); + testAsyncMulti("passwords - allow custom bcrypt rounds",[ + async function (test) { + // Verify that a bcrypt hash generated for a new account uses the let username = Random.id(); - this.password = hashPassword("abc123"); + this.password = hashPassword('abc123'); this.userId1 = await Accounts.createUser({ username, password: this.password }); - this.user1 = await Meteor.users.findOneAsync(this.userId1); - let argon2Params = getUserHashArgon2Params(this.user1); - test.equal(argon2Params.type, Accounts._argon2Type()); - test.equal(argon2Params.memoryCost, Accounts._argon2MemoryCost()); - test.equal(argon2Params.timeCost, Accounts._argon2TimeCost()); - test.equal(argon2Params.parallelism, Accounts._argon2Parallelism()); + this.user1 = await Meteor.users.findOneAsync(this.userId1); + let rounds = getUserHashRounds(this.user1); + test.equal(rounds, Accounts._bcryptRounds()); - - // When a custom number of argon2 TimeCost is set via Accounts.config, - // and an account was already created using the default number of TimeCost, + // When a custom number of bcrypt rounds is set via Accounts.config, + // and an account was already created using the default number of rounds, // make sure that a new hash is created (and stored) using the new number - // of TimeCost, the next time the password is checked. - this.customType = "argon2d"; // argon2.argon2d = 2 - this.customTimeCost = 4; - this.customMemoryCost = 32768; - this.customParallelism = 1; - Accounts._options.argon2Type = this.customType; - Accounts._options.argon2TimeCost = this.customTimeCost; - Accounts._options.argon2MemoryCost = this.customMemoryCost; - Accounts._options.argon2Parallelism = this.customParallelism; - + // of rounds, the next time the password is checked. + this.customRounds = 11; + Accounts._options.bcryptRounds = this.customRounds; await Accounts._checkPasswordAsync(this.user1, this.password); }, async function(test) { - const defaultType = Accounts._argon2Type(); - const defaultTimeCost = Accounts._argon2TimeCost(); - const defaultMemoryCost = Accounts._argon2MemoryCost(); - const defaultParallelism = Accounts._argon2Parallelism(); - let params; + const defaultRounds = Accounts._bcryptRounds(); + let rounds; let username; let resolve; @@ -1870,60 +1852,50 @@ if (Meteor.isServer) (() => { Meteor.setTimeout(async () => { this.user1 = await Meteor.users.findOneAsync(this.userId1); - params = getUserHashArgon2Params(this.user1); - test.equal(params.type, 2); - test.equal(params.timeCost, this.customTimeCost); - test.equal(params.memoryCost, this.customMemoryCost); - test.equal(params.parallelism, this.customParallelism); - - // When a custom number of argon2 TimeCost is set, make sure it's - // used for new argon2 password hashes. + 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. username = Random.id(); const userId2 = await Accounts.createUser({ username, password: this.password }); const user2 = await Meteor.users.findOneAsync(userId2); - params = getUserHashArgon2Params(user2); - test.equal(params.type, 2); - test.equal(params.timeCost, this.customTimeCost); - test.equal(params.memoryCost, this.customMemoryCost); - test.equal(params.parallelism, this.customParallelism); + rounds = getUserHashRounds(user2); + test.equal(rounds, this.customRounds); // Cleanup - Accounts._options.argon2Type = defaultType; - Accounts._options.argon2TimeCost = defaultTimeCost; - Accounts._options.argon2MemoryCost = defaultMemoryCost; - Accounts._options.argon2Parallelism = defaultParallelism; + Accounts._options.bcryptRounds = defaultRounds; await Meteor.users.removeAsync(this.userId1); await Meteor.users.removeAsync(userId2); resolve(); - }, 1000); + }, 5000); return promise; } - ]); + ]); // default number of rounds. Tinytest.addAsync('passwords - extra params in email urls', async (test) => { - const username = Random.id(); - const email = `${ username }-intercept@example.com`; + const username = Random.id(); + const email = `${ username }-intercept@example.com`; - const userId = await Accounts.createUser({ - username: username, - email: email + const userId = await Accounts.createUser({ + username: username, + email: email + }); + + const extraParams = { test: 'success' }; + await Accounts.sendEnrollmentEmail(userId, email, null, extraParams); + + const [enrollPasswordEmailOptions] = + await Meteor.callAsync("getInterceptedEmails", email); + + const re = new RegExp(`${Meteor.absoluteUrl()}(\\S*)`); + const match = enrollPasswordEmailOptions.text.match(re); + const url = new URL(match) + test.equal(url.searchParams.get('test'), extraParams.test); }); - const extraParams = { test: 'success' }; - await Accounts.sendEnrollmentEmail(userId, email, null, extraParams); - - const [enrollPasswordEmailOptions] = - await Meteor.callAsync("getInterceptedEmails", email); - - const re = new RegExp(`${Meteor.absoluteUrl()}(\\S*)`); - const match = enrollPasswordEmailOptions.text.match(re); - const url = new URL(match) - test.equal(url.searchParams.get('test'), extraParams.test); - }); - Tinytest.addAsync('passwords - createUserAsync', async test => { const username = Random.id(); const email = `${username}-intercept@example.com`; @@ -1952,45 +1924,4 @@ if (Meteor.isServer) (() => { }); }, 'already exists'); }); - - Tinytest.addAsync("passwords - migration from bcrypt encryption to argon2", async test => { - const username = Random.id(); - const email = `${username}@bcrypt.com`; - const password = "password"; - const bcryptPasswordHash = "$2b$10$XIz481R/8TTXqtl9igiPmeZexiLkhy7oTk4pfO/oN5ymQnS5mWilC";// = brcypt(sha256('password')) - const userId = await Meteor.users.insertAsync({ - username, - emails: [{ address: email, verified: false }], - services: { - password: { - bcrypt: bcryptPasswordHash - } - } - }); - let user = await Meteor.users.findOneAsync(userId); - const isValid = await Accounts._checkPasswordAsync(user, password); - test.equal(isValid.userId, userId, "checkPassword with bcrypt - User ID should be returned"); - test.equal(typeof isValid.error, "undefined", "checkPassword with bcrypt - No error should be returned"); - - - let resolve; - const promise = new Promise(res => resolve = res); - - // wait for defered execution of user update inside _checkPasswordAsync - Meteor.setTimeout(async () => { - user = await Meteor.users.findOneAsync(userId); - // bcrypt has been unset and argon2 set - test.equal(typeof user.services.password.bcrypt, "undefined", "bcrypt should be unset"); - test.equal(typeof user.services.password.argon2, "string", "argon2 should be set"); - // password is still valid using argon2 - const isValidArgon = await Accounts._checkPasswordAsync(user, password); - test.equal(isValidArgon.userId, userId, "checkPassword with argon2 - User ID should be returned"); - test.equal(typeof isValidArgon.error, "undefined", "checkPassword with argon2 - No error should be returned"); - resolve(); - }, 1000); - - return promise - - - }); })(); From 15c249f6560d4c943e236b750e144fc749ffe632 Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 31 Jan 2025 11:02:16 +0100 Subject: [PATCH 11/18] fix: rename hashPassword in test file to hashPasswordWithSha to avoid confusion with server side hashPassword used to encrypt password --- packages/accounts-password/password_tests.js | 46 ++++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 7e895374cd..e488c9a89e 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -10,7 +10,7 @@ const makeTestConnAsync = }) const simplePollAsync = (fn) => new Promise((resolve, reject) => simplePoll(fn,resolve,reject)) -function hashPassword(password) { +function hashPasswordWithSha(password) { return { digest: SHA256(password), algorithm: "sha-256" @@ -486,7 +486,7 @@ if (Meteor.isClient) (() => { function (test, expect) { this.secondConn = DDP.connect(Meteor.absoluteUrl()); this.secondConn.call('login', - { user: { username: this.username }, password: hashPassword(this.password) }, + { user: { username: this.username }, password: hashPasswordWithSha(this.password) }, expect((err, result) => { test.isFalse(err); this.secondConn.setUserId(result.id); @@ -1230,7 +1230,7 @@ if (Meteor.isServer) (() => { const username = Random.id(); const id = await Accounts.createUser({ username: username, - password: hashPassword('password') + password: hashPasswordWithSha('password') }); const { @@ -1245,7 +1245,7 @@ if (Meteor.isServer) (() => { const result = await clientConn.callAsync('login', { user: { username: username }, - password: hashPassword('password') + password: hashPasswordWithSha('password') }); test.isTrue(result); @@ -1278,7 +1278,7 @@ if (Meteor.isServer) (() => { const userId = await Accounts.createUser({ username: username, email: email, - password: hashPassword("old-password") + password: hashPasswordWithSha("old-password") }); const user = await Meteor.users.findOneAsync(userId); @@ -1297,7 +1297,7 @@ if (Meteor.isServer) (() => { await test.throwsAsync( async () => - await Meteor.callAsync("resetPassword", resetPasswordToken, hashPassword("new-password")), + await Meteor.callAsync("resetPassword", resetPasswordToken, hashPasswordWithSha("new-password")), /Token has invalid email address/ ); await test.throwsAsync( @@ -1306,7 +1306,7 @@ if (Meteor.isServer) (() => { "login", { user: { username: username }, - password: hashPassword("new-password") + password: hashPasswordWithSha("new-password") } ), /Something went wrong. Please check your credentials./); @@ -1321,7 +1321,7 @@ if (Meteor.isServer) (() => { const userId = await Accounts.createUser({ username: username, email: email, - password: hashPassword("old-password") + password: hashPasswordWithSha("old-password") }); const user = await Meteor.users.findOneAsync(userId); @@ -1338,11 +1338,11 @@ if (Meteor.isServer) (() => { test.isTrue(await clientConn.callAsync( "resetPassword", resetPasswordToken, - hashPassword("new-password") + hashPasswordWithSha("new-password") )); test.isTrue(await clientConn.callAsync("login", { user: { username }, - password: hashPassword("new-password") + password: hashPasswordWithSha("new-password") })); }); @@ -1355,7 +1355,7 @@ if (Meteor.isServer) (() => { const userId = await Accounts.createUser({ username: username, email: email, - password: hashPassword("old-password") + password: hashPasswordWithSha("old-password") }); const user = await Meteor.users.findOneAsync(userId); @@ -1373,7 +1373,7 @@ if (Meteor.isServer) (() => { await Meteor.users.updateAsync(userId, { $set: { "services.password.reset.when": new Date(Date.now() + -5 * 24 * 3600 * 1000) } }); try { - await Meteor.callAsync("resetPassword", resetPasswordToken, hashPassword("new-password")) + await Meteor.callAsync("resetPassword", resetPasswordToken, hashPasswordWithSha("new-password")) } catch (e) { test.throws(() => { throw e; @@ -1385,7 +1385,7 @@ if (Meteor.isServer) (() => { "login", { user: { username: username }, - password: hashPassword("new-password") + password: hashPasswordWithSha("new-password") } ), /Something went wrong. Please check your credentials./); @@ -1405,7 +1405,7 @@ if (Meteor.isServer) (() => { { username: username, email: email, - password: hashPassword(password) + password: hashPasswordWithSha(password) }, ); @@ -1432,7 +1432,7 @@ if (Meteor.isServer) (() => { await Accounts.createUser( { email: email, - password: hashPassword('password') + password: hashPasswordWithSha('password') } ); await Accounts.sendResetPasswordEmail(userId, email); @@ -1452,7 +1452,7 @@ if (Meteor.isServer) (() => { await Accounts.createUser( { email: email, - password: hashPassword('password') + password: hashPasswordWithSha('password') } ); await Accounts.sendResetPasswordEmail(userId, email); @@ -1498,12 +1498,12 @@ if (Meteor.isServer) (() => { await clientConn.callAsync( "resetPassword", enrollPasswordToken, - hashPassword("new-password")) + hashPasswordWithSha("new-password")) ); test.isTrue( await clientConn.callAsync("login", { user: { username }, - password: hashPassword("new-password") + password: hashPasswordWithSha("new-password") }) ); @@ -1535,7 +1535,7 @@ if (Meteor.isServer) (() => { await Meteor.users.updateAsync(userId, { $set: { "services.password.enroll.when": new Date(Date.now() + -35 * 24 * 3600 * 1000) } }); await test.throwsAsync( - async () => await Meteor.callAsync("resetPassword", enrollPasswordToken, hashPassword("new-password")), + async () => await Meteor.callAsync("resetPassword", enrollPasswordToken, hashPasswordWithSha("new-password")), /Token expired/ ); }); @@ -1544,7 +1544,7 @@ if (Meteor.isServer) (() => { async test => { const email = `${ test.id }-intercept@example.com`; const userId = - await Accounts.createUser({ email: email, password: hashPassword('password') }); + await Accounts.createUser({ email: email, password: hashPasswordWithSha('password') }); await Accounts.sendEnrollmentEmail(userId, email); const user1 = await Meteor.users.findOneAsync(userId); @@ -1561,7 +1561,7 @@ if (Meteor.isServer) (() => { const userId = await Accounts.createUser({ email: email, - password: hashPassword('password') + password: hashPasswordWithSha('password') }); await Accounts.sendEnrollmentEmail(userId, email); @@ -1580,7 +1580,7 @@ if (Meteor.isServer) (() => { async test => { const email = `${ test.id }-intercept@example.com`; const userId = - await Accounts.createUser({ email: email, password: hashPassword('password') }); + await Accounts.createUser({ email: email, password: hashPasswordWithSha('password') }); await Accounts.sendResetPasswordEmail(userId, email); const user1 = await Meteor.users.findOneAsync(userId); @@ -1828,7 +1828,7 @@ if (Meteor.isServer) (() => { async function (test) { // Verify that a bcrypt hash generated for a new account uses the let username = Random.id(); - this.password = hashPassword('abc123'); + this.password = hashPasswordWithSha('abc123'); this.userId1 = await Accounts.createUser({ username, password: this.password }); this.user1 = await Meteor.users.findOneAsync(this.userId1); let rounds = getUserHashRounds(this.user1); From 107f2621caa8a2ff5170488f0553cd24791f9c82 Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 31 Jan 2025 14:47:52 +0100 Subject: [PATCH 12/18] feat: add tests for argon : migration, set password and argon parameters change --- packages/accounts-password/package.js | 3 +- .../accounts-password/password_argon_tests.js | 183 ++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 packages/accounts-password/password_argon_tests.js diff --git a/packages/accounts-password/package.js b/packages/accounts-password/package.js index 71d15a383f..c4160683ba 100644 --- a/packages/accounts-password/package.js +++ b/packages/accounts-password/package.js @@ -41,10 +41,11 @@ Package.onTest((api) => { "email", "check", "ddp", - "ecmascript", + "ecmascript" ]); api.addFiles("password_tests_setup.js", "server"); api.addFiles("password_tests.js", ["client", "server"]); api.addFiles("email_tests_setup.js", "server"); api.addFiles("email_tests.js", "client"); + api.addFiles("password_argon_tests.js", ["client", "server"]); }); diff --git a/packages/accounts-password/password_argon_tests.js b/packages/accounts-password/password_argon_tests.js new file mode 100644 index 0000000000..51eefc66e7 --- /dev/null +++ b/packages/accounts-password/password_argon_tests.js @@ -0,0 +1,183 @@ +if (Meteor.isServer) { + Tinytest.addAsync("passwords Argon - migration from bcrypt encryption to argon2", async (test) => { + Accounts._options.argon2Enabled = false; + const username = Random.id(); + const email = `${username}@bcrypt.com`; + const password = "password"; + const userId = await Accounts.createUser( + { + username: username, + email: email, + password: password + } + ); + Accounts._options.argon2Enabled = true; + let user = await Meteor.users.findOneAsync(userId); + const isValid = await Accounts._checkPasswordAsync(user, password); + test.equal(isValid.userId, userId, "checkPassword with bcrypt - User ID should be returned"); + test.equal(typeof isValid.error, "undefined", "checkPassword with bcrypt - No error should be returned"); + + + // wait for defered execution of user update inside _checkPasswordAsync + await new Promise((resolve) => { + Meteor.setTimeout(async () => { + user = await Meteor.users.findOneAsync(userId); + // bcrypt has been unset and argon2 set + test.equal(typeof user.services.password.bcrypt, "undefined", "bcrypt should be unset"); + test.equal(typeof user.services.password.argon2, "string", "argon2 should be set"); + // password is still valid using argon2 + const isValidArgon = await Accounts._checkPasswordAsync(user, password); + test.equal(isValidArgon.userId, userId, "checkPassword with argon2 - User ID should be returned"); + test.equal(typeof isValidArgon.error, "undefined", "checkPassword with argon2 - No error should be returned"); + resolve(); + }, 100); + }); + + // cleanup + Accounts._options.argon2Enabled = false; + await Meteor.users.removeAsync(userId); + }); + + + Tinytest.addAsync("passwords Argon - setPassword", async (test) => { + Accounts._options.argon2Enabled = true; + const username = Random.id(); + const email = `${username}-intercept@example.com`; + + const userId = await Accounts.createUser({ username: username, email: email }); + + let user = await Meteor.users.findOneAsync(userId); + // no services yet. + test.equal(user.services.password, undefined); + + // set a new password. + await Accounts.setPasswordAsync(userId, "new password"); + user = await Meteor.users.findOneAsync(userId); + const oldSaltedHash = user.services.password.argon2; + test.isTrue(oldSaltedHash); + // Send a reset password email (setting a reset token) and insert a login + // token. + await Accounts.sendResetPasswordEmail(userId, email); + await Accounts._insertLoginToken(userId, Accounts._generateStampedLoginToken()); + const user2 = await Meteor.users.findOneAsync(userId); + test.isTrue(user2.services.password.reset); + test.isTrue(user2.services.resume.loginTokens); + + // 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.argon2; + test.isTrue(newSaltedHash); + test.notEqual(oldSaltedHash, newSaltedHash); + // No more reset token. + const user3 = await Meteor.users.findOneAsync(userId); + test.isFalse(user3.services.password.reset); + // But loginTokens are still here since we did logout: false. + test.isTrue(user3.services.resume.loginTokens); + + // 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.argon2; + test.isTrue(newerSaltedHash); + test.notEqual(oldSaltedHash, newerSaltedHash); + test.notEqual(newSaltedHash, newerSaltedHash); + // No more tokens. + const user4 = await Meteor.users.findOneAsync(userId); + test.isFalse(user4.services.password.reset); + test.isFalse(user4.services.resume.loginTokens); + + // cleanup + Accounts._options.argon2Enabled = false; + await Meteor.users.removeAsync(userId); + }); + + + + const getUserHashArgon2Params = function (user) { + const hash = user?.services?.password?.argon2; + return Accounts._getArgon2Params(hash); + } + const hashPasswordWithSha = function (password) { + return { + digest: SHA256(password), + algorithm: "sha-256" + }; + } + + testAsyncMulti("passwords Argon - allow custom argon2 Params and ensure migration if changed", [ + async function(test) { + Accounts._options.argon2Enabled = true; + // Verify that a argon2 hash generated for a new account uses the + // default params. + let username = Random.id(); + this.password = hashPasswordWithSha("abc123"); + this.userId1 = await Accounts.createUserAsync({ username, password: this.password }); + this.user1 = await Meteor.users.findOneAsync(this.userId1); + let argon2Params = getUserHashArgon2Params(this.user1); + test.equal(argon2Params.type, Accounts._argon2Type()); + test.equal(argon2Params.memoryCost, Accounts._argon2MemoryCost()); + test.equal(argon2Params.timeCost, Accounts._argon2TimeCost()); + test.equal(argon2Params.parallelism, Accounts._argon2Parallelism()); + + + // When a custom number of argon2 TimeCost is set via Accounts.config, + // and an account was already created using the default number of TimeCost, + // make sure that a new hash is created (and stored) using the new number + // of TimeCost, the next time the password is checked. + this.customType = "argon2d"; // argon2.argon2d = 2 + this.customTimeCost = 4; + this.customMemoryCost = 32768; + this.customParallelism = 1; + Accounts._options.argon2Type = this.customType; + Accounts._options.argon2TimeCost = this.customTimeCost; + Accounts._options.argon2MemoryCost = this.customMemoryCost; + Accounts._options.argon2Parallelism = this.customParallelism; + + await Accounts._checkPasswordAsync(this.user1, this.password); + }, + async function(test) { + const defaultType = Accounts._argon2Type(); + const defaultTimeCost = Accounts._argon2TimeCost(); + const defaultMemoryCost = Accounts._argon2MemoryCost(); + const defaultParallelism = Accounts._argon2Parallelism(); + let params; + let username; + + let resolve; + const promise = new Promise(res => resolve = res); + + Meteor.setTimeout(async () => { + this.user1 = await Meteor.users.findOneAsync(this.userId1); + params = getUserHashArgon2Params(this.user1); + test.equal(params.type, 2); + test.equal(params.timeCost, this.customTimeCost); + test.equal(params.memoryCost, this.customMemoryCost); + test.equal(params.parallelism, this.customParallelism); + + // When a custom number of argon2 TimeCost 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); + params = getUserHashArgon2Params(user2); + test.equal(params.type, 2); + test.equal(params.timeCost, this.customTimeCost); + test.equal(params.memoryCost, this.customMemoryCost); + test.equal(params.parallelism, this.customParallelism); + + // Cleanup + Accounts._options.argon2Enabled = false; + Accounts._options.argon2Type = defaultType; + Accounts._options.argon2TimeCost = defaultTimeCost; + Accounts._options.argon2MemoryCost = defaultMemoryCost; + Accounts._options.argon2Parallelism = defaultParallelism; + await Meteor.users.removeAsync(this.userId1); + await Meteor.users.removeAsync(userId2); + resolve(); + }, 1000); + + return promise; + } + ]); +} From df0a2ef6aeb78b9ffcebdb692e0b02c870027ab5 Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 31 Jan 2025 15:14:37 +0100 Subject: [PATCH 13/18] feat: enable rollback from argon2 to bcrypt --- .../accounts-password/password_argon_tests.js | 36 +++++++++++++++ packages/accounts-password/password_server.js | 44 ++++++++++++++----- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/packages/accounts-password/password_argon_tests.js b/packages/accounts-password/password_argon_tests.js index 51eefc66e7..d580cf0512 100644 --- a/packages/accounts-password/password_argon_tests.js +++ b/packages/accounts-password/password_argon_tests.js @@ -92,8 +92,44 @@ if (Meteor.isServer) { await Meteor.users.removeAsync(userId); }); + Tinytest.addAsync("passwords Argon - migration from argon2 encryption to bcrypt", async (test) => { + Accounts._options.argon2Enabled = true; + const username = Random.id(); + const email = `${username}@bcrypt.com`; + const password = "password"; + const userId = await Accounts.createUser( + { + username: username, + email: email, + password: password + } + ); + Accounts._options.argon2Enabled = false; + let user = await Meteor.users.findOneAsync(userId); + const isValidArgon = await Accounts._checkPasswordAsync(user, password); + test.equal(isValidArgon.userId, userId, "checkPassword with argon2 - User ID should be returned"); + test.equal(typeof isValidArgon.error, "undefined", "checkPassword with argon2 - No error should be returned"); + // wait for defered execution of user update inside _checkPasswordAsync + await new Promise((resolve) => { + Meteor.setTimeout(async () => { + user = await Meteor.users.findOneAsync(userId); + // bcrypt has been unset and argon2 set + test.equal(typeof user.services.password.argon2, "undefined", "argon2 should be unset"); + test.equal(typeof user.services.password.bcrypt, "string", "bcrypt should be set"); + // password is still valid using argon2 + const isValidBcrypt = await Accounts._checkPasswordAsync(user, password); + test.equal(isValidBcrypt.userId, userId, "checkPassword with argon2 - User ID should be returned"); + test.equal(typeof isValidBcrypt.error, "undefined", "checkPassword with argon2 - No error should be returned"); + resolve(); + }, 100); + }); + + // cleanup + await Meteor.users.removeAsync(userId); + }); + const getUserHashArgon2Params = function (user) { const hash = user?.services?.password?.argon2; return Accounts._getArgon2Params(hash); diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 8d738b7fd5..b6fd1c2348 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -134,10 +134,14 @@ Accounts._checkPasswordUserFields = { _id: 1, services: 1 }; const isBcrypt = (hash) => { // bcrypt hashes start with $2a$ or $2b$ - // argon2 hashes start with $argon2i$, $argon2d$ or $argon2id$ return hash.startsWith("$2"); }; +const isArgon = (hash) => { + // argon2 hashes start with $argon2i$, $argon2d$ or $argon2id$ + return hash.startsWith("$argon2"); +} + const updateUserPasswordDefered = (user, formattedPassword) => { Meteor.defer(async () => { await updateUserPassword(user, formattedPassword); @@ -155,6 +159,9 @@ const getUpdatorForUserPassword = async (formattedPassword) => { return { $set: { "services.password.bcrypt": encryptedPassword + }, + $unset: { + "services.password.argon2": 1 } }; } @@ -208,19 +215,34 @@ const checkPasswordAsync = async (user, password) => { const argon2Enabled = Accounts._argon2Enabled(); if (argon2Enabled === false) { - const hashRounds = getRoundsFromBcryptHash(hash); - const match = await bcryptCompare(formattedPassword, hash); - if (!match) { - result.error = Accounts._handleError("Incorrect password", false); - } - else if (hash) { - const paramsChanged = hashRounds !== Accounts._bcryptRounds(); - // The password checks out, but the user's bcrypt hash needs to be updated - // to match current bcrypt settings - if (paramsChanged === true) { + if (isArgon(hash)) { + // this is a rollback feature, enabling to switch back from argon2 to bcrypt if needed + // TODO : deprecate this + console.warn("User has an argon2 password and argon2 is not enabled, rolling back to bcrypt encryption"); + const match = await argon2.verify(hash, formattedPassword); + 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 updateUserPasswordDefered(user, { digest: formattedPassword, algorithm: "sha-256" }); } } + else { + const hashRounds = getRoundsFromBcryptHash(hash); + const match = await bcryptCompare(formattedPassword, hash); + if (!match) { + result.error = Accounts._handleError("Incorrect password", false); + } + else if (hash) { + const paramsChanged = hashRounds !== Accounts._bcryptRounds(); + // The password checks out, but the user's bcrypt hash needs to be updated + // to match current bcrypt settings + if (paramsChanged === true) { + updateUserPasswordDefered(user, { digest: formattedPassword, algorithm: "sha-256" }); + } + } + } } else if (argon2Enabled === true) { if (isBcrypt(hash)) { From 7aaf607c8631178aecf65b0370fb2bc24c5813fb Mon Sep 17 00:00:00 2001 From: Victor Parpoil Date: Fri, 31 Jan 2025 15:47:45 +0100 Subject: [PATCH 14/18] adding the feature toggle to the docs --- v3-docs/docs/api/accounts.md | 40 +++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/v3-docs/docs/api/accounts.md b/v3-docs/docs/api/accounts.md index 79d54b5738..13acb2cb97 100644 --- a/v3-docs/docs/api/accounts.md +++ b/v3-docs/docs/api/accounts.md @@ -772,24 +772,49 @@ authentication. In addition to the basic username and password-based sign-in process, it also supports email-based sign-in including address verification and password recovery emails. -The Meteor server stores passwords using the -[argon2](http://en.wikipedia.org/wiki/Argon2) algorithm. This helps +### Password encryption and security + +Starting from `accounts-passwords:4.0.0`, you can choose which algorithm is used by the Meteor server to store passwords : either [bcrypt](http://en.wikipedia.org/wiki/Bcrypt) or +[Argon2](http://en.wikipedia.org/wiki/Argon2) algorithm. Both are robust and contribute to protect against embarrassing password leaks if the server's database is compromised. -### Password encryption and security +Before version 4.0.0, `bcrypt` was the only available option. argon2 has been introduced because it is considered the most secure option. This algorithm is specifically designed to resist GPU-based brute force attacks. For more details, see the [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html). + +As of January 2025, **`bcrypt` is still the default option** to enable a smooth transition. In the future, `argon2` will replace `bcrypt` as default and `bcrypt` option will be deprecated. Passwords are hashed on the client using **SHA-256** algorithm before being sent to the server. This ensures that sensitive data is never transmitted in plain text. Once received by the server, the hashed value is further encrypted and securely stored in the `Meteor.users` collection. -Starting from `accounts-passwords:4.0.0`, the encryption algorithm used is [Argon2](http://en.wikipedia.org/wiki/Argon2). This algorithm is specifically designed to resist GPU-based brute force attacks. Argon2 has replaced the previous [bcrypt](http://en.wikipedia.org/wiki/Bcrypt) algorithm as it's now regarded as a more robust option. For more details, see the [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html). -**Migrating from `bcrypt` to `argon2`** +**About the migration process from `bcrypt` to `argon2`** -The transition from `bcrypt` to `argon2` is handled seamlessly during user logins. When a user logs in, their password is first verified against the existing bcrypt hash. If successful, the password is re-encrypted using Argon2 and the new hash is stored in the database. +The transition from `bcrypt` to `argon2` happens automatically upon user login. If Argon2 encryption is enabled in an existing application, each user's password is re-encrypted during their next successful login. +- Step 1: The password is first validated against the existing `bcrypt` hash. +- Step 2: If authentication succeeds, the password is re-encrypted using `Argon2`. +- Step 3: The new `Argon2` hash replaces the old `bcrypt` hash in the database. + + +To monitor the migration progress, you can count users still using bcrypt: +```js +const bcryptUsers = await Meteor.users.find({ "services.password.bcrypt": { $exists: true } }).countAsync(); +const totalUsers = await Meteor.users.find({ "services.password": { $exists: true } }).countAsync(); +console.log("Remaining users to migrate:", bcryptUsers, "/", totalUsers); +``` +Once `bcryptUsers` reaches 0, the migration is complete. + +**Enabling Argon2 encryption** + +To enable Argon2 encryption, you need a small configuration change on the server: + +```js +Accounts.config({ + argon2Enabled: true, +}); +``` **Configuring `argon2` parameters** -The accounts-password package allows customization of the Argon2 algorithm's parameters. The configurable options include: +One enabled, the `accounts-password` package allows customization of Argon2's parameters. The configurable options include: - `type`: `argon2id` (provides a blend of resistance against GPU and side-channel attacks) - `timeCost` (default: 3) – This controls the computational cost of the hashing process, affecting both the security level and performance. @@ -799,6 +824,7 @@ The accounts-password package allows customization of the Argon2 algorithm's par To update the values, use the following configuration: ```js Accounts.config({ + argon2Enabled: true, argon2Type: "argon2id", argon2TimeCost: 4, argon2MemoryCost: 65536, From bc9e24e12be1668203c3556efc5d64378bc6c607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Tue, 4 Feb 2025 17:51:38 +0100 Subject: [PATCH 15/18] fix waitUntil scenario --- packages/test-helpers/wait.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/test-helpers/wait.js b/packages/test-helpers/wait.js index 955b17fb42..fd7c42c84e 100644 --- a/packages/test-helpers/wait.js +++ b/packages/test-helpers/wait.js @@ -5,7 +5,8 @@ function isPromise(obj) { waitUntil = function _waitUntil(checkFunction, { timeout = 15_000, interval = 200, leading = true, description = '' } = {}) { let waitTime = interval; return new Promise((resolve, reject) => { - if (leading && checkFunction()) { + const shouldWait = checkFunction(); + if (leading && !isPromise(shouldWait) && shouldWait) { resolve(); return; } From aa3b2208bdf6fe524c695b6a6ae59f4c1865f01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Tue, 4 Feb 2025 17:52:15 +0100 Subject: [PATCH 16/18] add waitUntil on brypt and argon2 migration to solve flakiness on slower execution contexts --- .../accounts-password/password_argon_tests.js | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/packages/accounts-password/password_argon_tests.js b/packages/accounts-password/password_argon_tests.js index d580cf0512..c1fb806d9a 100644 --- a/packages/accounts-password/password_argon_tests.js +++ b/packages/accounts-password/password_argon_tests.js @@ -17,21 +17,22 @@ if (Meteor.isServer) { test.equal(isValid.userId, userId, "checkPassword with bcrypt - User ID should be returned"); test.equal(typeof isValid.error, "undefined", "checkPassword with bcrypt - No error should be returned"); + // wait for the migration to happen + await waitUntil( + async () => { + user = await Meteor.users.findOneAsync(userId); + return ( + typeof user.services.password.bcrypt === "undefined" && + typeof user.services.password.argon2 === "string" + ); + }, + { description: "bcrypt should be unset and argon2 should be set" } + ); - // wait for defered execution of user update inside _checkPasswordAsync - await new Promise((resolve) => { - Meteor.setTimeout(async () => { - user = await Meteor.users.findOneAsync(userId); - // bcrypt has been unset and argon2 set - test.equal(typeof user.services.password.bcrypt, "undefined", "bcrypt should be unset"); - test.equal(typeof user.services.password.argon2, "string", "argon2 should be set"); - // password is still valid using argon2 - const isValidArgon = await Accounts._checkPasswordAsync(user, password); - test.equal(isValidArgon.userId, userId, "checkPassword with argon2 - User ID should be returned"); - test.equal(typeof isValidArgon.error, "undefined", "checkPassword with argon2 - No error should be returned"); - resolve(); - }, 100); - }); + // password is still valid using argon2 + const isValidArgon = await Accounts._checkPasswordAsync(user, password); + test.equal(isValidArgon.userId, userId, "checkPassword with argon2 - User ID should be returned"); + test.equal(typeof isValidArgon.error, "undefined", "checkPassword with argon2 - No error should be returned"); // cleanup Accounts._options.argon2Enabled = false; @@ -110,21 +111,22 @@ if (Meteor.isServer) { test.equal(isValidArgon.userId, userId, "checkPassword with argon2 - User ID should be returned"); test.equal(typeof isValidArgon.error, "undefined", "checkPassword with argon2 - No error should be returned"); + // wait for the migration to happen + await waitUntil( + async () => { + user = await Meteor.users.findOneAsync(userId); + return ( + typeof user.services.password.bcrypt === "string" && + typeof user.services.password.argon2 === "undefined" + ); + }, + { description: "bcrypt should be string and argon2 should be undefined" } + ); - // wait for defered execution of user update inside _checkPasswordAsync - await new Promise((resolve) => { - Meteor.setTimeout(async () => { - user = await Meteor.users.findOneAsync(userId); - // bcrypt has been unset and argon2 set - test.equal(typeof user.services.password.argon2, "undefined", "argon2 should be unset"); - test.equal(typeof user.services.password.bcrypt, "string", "bcrypt should be set"); - // password is still valid using argon2 - const isValidBcrypt = await Accounts._checkPasswordAsync(user, password); - test.equal(isValidBcrypt.userId, userId, "checkPassword with argon2 - User ID should be returned"); - test.equal(typeof isValidBcrypt.error, "undefined", "checkPassword with argon2 - No error should be returned"); - resolve(); - }, 100); - }); + // password is still valid using bcrypt + const isValidBcrypt = await Accounts._checkPasswordAsync(user, password); + test.equal(isValidBcrypt.userId, userId, "checkPassword with argon2 - User ID should be returned"); + test.equal(typeof isValidBcrypt.error, "undefined", "checkPassword with argon2 - No error should be returned"); // cleanup await Meteor.users.removeAsync(userId); From 4e57697b1393f3838339a07217545044984c59d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 5 Feb 2025 17:16:42 +0100 Subject: [PATCH 17/18] specify alpha version for new feature --- packages/accounts-base/package.js | 2 +- packages/accounts-password/package.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/accounts-base/package.js b/packages/accounts-base/package.js index d9a2fd72ee..db96d4d13d 100644 --- a/packages/accounts-base/package.js +++ b/packages/accounts-base/package.js @@ -1,6 +1,6 @@ Package.describe({ summary: "A user account system", - version: "3.0.4", + version: "3.1.0-alpha.0", }); Package.onUse((api) => { diff --git a/packages/accounts-password/package.js b/packages/accounts-password/package.js index c4160683ba..d6be0e1ee4 100644 --- a/packages/accounts-password/package.js +++ b/packages/accounts-password/package.js @@ -5,7 +5,7 @@ Package.describe({ // 2.2.x in the future. The version was also bumped to 2.0.0 temporarily // during the Meteor 1.5.1 release process, so versions 2.0.0-beta.2 // through -beta.5 and -rc.0 have already been published. - version: "4.0.0", + version: "4.0.0-alpha.0", }); Npm.depends({ From c3805c7178933804e30dfd9b1d8f0c5d3f64df3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 5 Feb 2025 17:38:00 +0100 Subject: [PATCH 18/18] fix versions for next alpha --- packages/accounts-password/package.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/accounts-password/package.js b/packages/accounts-password/package.js index d6be0e1ee4..3eefb8a7f5 100644 --- a/packages/accounts-password/package.js +++ b/packages/accounts-password/package.js @@ -14,10 +14,10 @@ Npm.depends({ }); Package.onUse((api) => { - api.use(["accounts-base", "sha", "ejson", "ddp"], ["client", "server"]); + api.use(["accounts-base@3.1.0-alpha.0", "sha", "ejson", "ddp"], ["client", "server"]); // Export Accounts (etc) to packages using this one. - api.imply("accounts-base", ["client", "server"]); + api.imply("accounts-base@3.1.0-alpha.0", ["client", "server"]); api.use("email", "server"); api.use("random", "server");