66const crypto = require ( 'node:crypto' ) ;
77const { Buffer } = require ( 'node:buffer' ) ;
88
9+ const argon2 = require ( '@node-rs/argon2' ) ;
10+ const mongoose = require ( 'mongoose' ) ;
11+
912//
1013// NOTE: scmp and tsse appear to be identical
1114// <https://github.com/freewil/scmp/issues/18>
@@ -17,6 +20,14 @@ const { Buffer } = require('node:buffer');
1720const pbkdf2 = require ( './pbkdf2' ) ;
1821
1922const config = require ( '#config' ) ;
23+ const logger = require ( '#helpers/logger' ) ;
24+
25+ const conn = mongoose . connections . find (
26+ ( conn ) => conn [ Symbol . for ( 'connection.name' ) ] === 'MONGO_URI'
27+ ) ;
28+ if ( ! conn ) {
29+ throw new Error ( 'Mongoose connection does not exist' ) ;
30+ }
2031
2132function timingSafeCompare ( a , b ) {
2233 if ( a . length !== b . length ) {
@@ -29,7 +40,7 @@ function timingSafeCompare(a, b) {
2940 return crypto . timingSafeEqual ( a , b ) ;
3041}
3142
32- async function isValidPassword ( tokens = [ ] , password ) {
43+ async function isValidPassword ( tokens = [ ] , password , instance ) {
3344 if (
3445 typeof tokens !== 'object' ||
3546 ! Array . isArray ( tokens ) ||
@@ -40,6 +51,8 @@ async function isValidPassword(tokens = [], password) {
4051 return false ;
4152
4253 let match = false ;
54+ let matchedToken = null ;
55+
4356 for ( const token of tokens ) {
4457 if (
4558 typeof token !== 'object' ||
@@ -50,22 +63,141 @@ async function isValidPassword(tokens = [], password) {
5063 )
5164 continue ;
5265
53- const rawHash = await pbkdf2 ( {
54- password,
55- salt : token . salt ,
56- iterations : config . passportLocalMongoose . iterations ,
57- keylen : config . passportLocalMongoose . keylen ,
58- digestAlgorithm : config . passportLocalMongoose . digestAlgorithm
59- } ) ;
60-
61- // const scmpResult = scmp(
62- const scmpResult = timingSafeCompare (
63- rawHash ,
64- Buffer . from ( token . hash , config . passportLocalMongoose . encoding )
65- ) ;
66- if ( scmpResult ) {
67- match = true ;
68- break ;
66+ //
67+ // if the token has already been migrated to argon2, skip pbkdf2 check
68+ //
69+ if ( token . has_pbkdf2_migration === true ) {
70+ try {
71+ const isValid = await argon2 . verify ( token . hash , password ) ;
72+ if ( isValid ) {
73+ match = true ;
74+ matchedToken = token ;
75+ break ;
76+ }
77+ } catch {
78+ // argon2 verification failed, continue to next token
79+ continue ;
80+ }
81+ } else {
82+ //
83+ // try argon2 first (new format)
84+ //
85+ try {
86+ const isValid = await argon2 . verify ( token . hash , password ) ;
87+ if ( isValid ) {
88+ match = true ;
89+ matchedToken = token ;
90+ break ;
91+ }
92+ } catch {
93+ // argon2 verification failed, try pbkdf2 (old format)
94+ }
95+
96+ //
97+ // fallback to pbkdf2 for backwards compatibility
98+ //
99+ const rawHash = await pbkdf2 ( {
100+ password,
101+ salt : token . salt ,
102+ iterations : config . passportLocalMongoose . iterations ,
103+ keylen : config . passportLocalMongoose . keylen ,
104+ digestAlgorithm : config . passportLocalMongoose . digestAlgorithm
105+ } ) ;
106+
107+ // const scmpResult = scmp(
108+ const scmpResult = timingSafeCompare (
109+ rawHash ,
110+ Buffer . from ( token . hash , config . passportLocalMongoose . encoding )
111+ ) ;
112+
113+ if ( scmpResult ) {
114+ match = true ;
115+ matchedToken = token ;
116+ break ;
117+ }
118+ }
119+ }
120+
121+ //
122+ // if match found and token needs migration, perform live migration
123+ // and automatically save if model instance is provided
124+ //
125+ if ( match && matchedToken && matchedToken . has_pbkdf2_migration !== true ) {
126+ try {
127+ // check if hash is argon2 format (starts with $argon2)
128+ const isArgon2Hash = matchedToken . hash . startsWith ( '$argon2' ) ;
129+ if ( isArgon2Hash ) {
130+ // already argon2, just mark as migrated
131+ matchedToken . has_pbkdf2_migration = true ;
132+ } else {
133+ // this is a pbkdf2 hash, perform migration
134+ const newHash = await argon2 . hash ( password , config . argon2 ) ;
135+ matchedToken . hash = newHash ;
136+ matchedToken . has_pbkdf2_migration = true ;
137+ }
138+
139+ // if model instance provided, save the migration
140+ if ( instance ) {
141+ try {
142+ if ( ! mongoose . isObjectIdOrHexString ( instance . _id ) )
143+ throw new TypeError (
144+ 'instance._id was not an ObjectId nor hex string'
145+ ) ;
146+ if ( typeof instance . object !== 'string' )
147+ throw new TypeError ( 'instance.object was undefined' ) ;
148+ if ( instance . object === 'domain' ) {
149+ // enforce schema and require all required paths (dummyproof while we migrate)
150+ if (
151+ ! tokens . every ( ( token ) => token . user && token . salt && token . hash )
152+ )
153+ throw new TypeError ( 'token missing user, salt, or hash' ) ;
154+ // TODO: this migration needs improved/safeguarded since we don't use __v version key
155+ // (e.g. if we detect `__v` then query for equality and increase it by 1 as well)
156+ await conn . models . Domains . findOneAndUpdate (
157+ {
158+ _id : instance . _id ,
159+ tokens : { $size : tokens . length }
160+ } ,
161+ {
162+ $set : {
163+ tokens
164+ }
165+ }
166+ ) ;
167+ } else if ( instance . object === 'alias' ) {
168+ // enforce schema and require all required paths (dummyproof while we migrate)
169+ if ( ! tokens . every ( ( token ) => token . salt && token . hash ) )
170+ throw new TypeError ( 'token missing user, salt, or hash' ) ;
171+ // TODO: this migration needs improved/safeguarded since we don't use __v version key
172+ // (e.g. if we detect `__v` then query for equality and increase it by 1 as well)
173+ await conn . models . Aliases . findOneAndUpdate (
174+ {
175+ _id : instance . _id ,
176+ tokens : { $size : tokens . length }
177+ } ,
178+ {
179+ $set : {
180+ tokens
181+ }
182+ }
183+ ) ;
184+ } else {
185+ throw new TypeError (
186+ 'instance.object must be equal to "domain" or "alias"'
187+ ) ;
188+ }
189+ } catch ( err ) {
190+ // log error but don't fail authentication
191+ // the migration will be retried on next authentication
192+ logger . fatal ( err ) ;
193+ const _err = new TypeError ( 'Failed to save argon2 migration' ) ;
194+ _err . err = err ;
195+ console . error ( _err ) ;
196+ }
197+ }
198+ } catch {
199+ // migration failed, but authentication succeeded
200+ // caller can retry migration later
69201 }
70202 }
71203
0 commit comments