From cf5603c8e3cb6bfaa9104a74d2fe78622bf1a7dc Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 30 Jan 2023 19:38:13 +0700 Subject: [PATCH] Finish preliminary backwards-compatible transition from user encryption scheme v1 to v2 with argon2 and protected key --- backend/src/app.ts | 2 + .../src/controllers/v1/passwordController.ts | 37 ++- .../src/controllers/v1/signupController.ts | 208 +-------------- backend/src/controllers/v2/authController.ts | 16 +- backend/src/controllers/v2/index.ts | 2 + .../src/controllers/v2/signupController.ts | 239 ++++++++++++++++++ backend/src/helpers/user.ts | 34 ++- backend/src/models/user.ts | 26 +- backend/src/routes/v1/password.ts | 46 ++-- backend/src/routes/v1/signup.ts | 37 +-- backend/src/routes/v2/index.ts | 2 + backend/src/routes/v2/signup.ts | 49 ++++ docs/security/data-model.mdx | 36 ++- docs/security/mechanics.mdx | 6 +- frontend/.eslintrc.js | 6 + frontend/next.config.js | 30 ++- frontend/package-lock.json | 33 +++ frontend/package.json | 3 + .../src/components/signup/UserInfoStep.tsx | 112 +++++--- .../src/components/utilities/attemptLogin.ts | 86 +++++-- .../utilities/cryptography/aes-256-gcm.ts | 4 +- .../utilities/cryptography/changePassword.ts | 100 +++++--- .../utilities/cryptography/crypto.ts | 54 +++- .../utilities/saveTokenToLocalStorage.ts | 41 ++- .../src/pages/api/auth/ChangePassword2.ts | 28 +- .../auth/CompleteAccountInformationSignup.ts | 40 ++- .../CompleteAccountInformationSignupInvite.ts | 31 ++- frontend/src/pages/api/auth/Login1.ts | 2 +- frontend/src/pages/api/auth/Login2.ts | 11 +- frontend/src/pages/api/auth/Logout.ts | 14 +- .../auth/resetPasswordOnAccountRecovery.ts | 29 ++- frontend/src/pages/password-reset.tsx | 67 ++++- frontend/src/pages/signupinvite.tsx | 105 +++++--- 33 files changed, 1058 insertions(+), 478 deletions(-) create mode 100644 backend/src/controllers/v2/signupController.ts create mode 100644 backend/src/routes/v2/signup.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 831a046ee..828822aea 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -42,6 +42,7 @@ import { integrationAuth as v1IntegrationAuthRouter } from './routes/v1'; import { + signup as v2SignupRouter, auth as v2AuthRouter, users as v2UsersRouter, organizations as v2OrganizationsRouter, @@ -110,6 +111,7 @@ app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); // v2 routes +app.use('/api/v2/signup', v2SignupRouter); app.use('/api/v2/auth', v2AuthRouter); app.use('/api/v2/users', v2UsersRouter); app.use('/api/v2/organizations', v2OrganizationsRouter); diff --git a/backend/src/controllers/v1/passwordController.ts b/backend/src/controllers/v1/passwordController.ts index 6d10fcbbd..5c867a974 100644 --- a/backend/src/controllers/v1/passwordController.ts +++ b/backend/src/controllers/v1/passwordController.ts @@ -165,8 +165,18 @@ export const srp1 = async (req: Request, res: Response) => { */ export const changePassword = async (req: Request, res: Response) => { try { - const { clientProof, encryptedPrivateKey, iv, tag, salt, verifier } = - req.body; + const { + clientProof, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + } = req.body; + const user = await User.findOne({ email: req.user.email }).select('+salt +verifier'); @@ -192,9 +202,13 @@ export const changePassword = async (req: Request, res: Response) => { await User.findByIdAndUpdate( req.user._id.toString(), { + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, salt, verifier }, @@ -322,9 +336,12 @@ export const getBackupPrivateKey = async (req: Request, res: Response) => { export const resetPassword = async (req: Request, res: Response) => { try { const { + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier, } = req.body; @@ -332,9 +349,13 @@ export const resetPassword = async (req: Request, res: Response) => { await User.findByIdAndUpdate( req.user._id.toString(), { + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, salt, verifier }, diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index 1d56ae0aa..961bb162c 100644 --- a/backend/src/controllers/v1/signupController.ts +++ b/backend/src/controllers/v1/signupController.ts @@ -1,16 +1,12 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; import { JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET } from '../../config'; -import { User, MembershipOrg } from '../../models'; -import { completeAccount } from '../../helpers/user'; +import { User } from '../../models'; import { sendEmailVerification, checkEmailVerification, - initializeDefaultOrg } from '../../helpers/signup'; -import { issueTokens, createToken } from '../../helpers/auth'; -import { INVITED, ACCEPTED } from '../../variables'; -import axios from 'axios'; +import { createToken } from '../../helpers/auth'; /** * Signup step 1: Initialize account for user under email [email] and send a verification code @@ -102,202 +98,4 @@ export const verifyEmailSignup = async (req: Request, res: Response) => { user, token }); -}; - -/** - * Complete setting up user by adding their personal and auth information as part of the - * signup flow - * @param req - * @param res - * @returns - */ -export const completeAccountSignup = async (req: Request, res: Response) => { - let user, token, refreshToken; - try { - const { - email, - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier, - organizationName - } = req.body; - - // get user - user = await User.findOne({ email }); - - if (!user || (user && user?.publicKey)) { - // case 1: user doesn't exist. - // case 2: user has already completed account - return res.status(403).send({ - error: 'Failed to complete account for complete user' - }); - } - - // complete setting up user's account - user = await completeAccount({ - userId: user._id.toString(), - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier - }); - - if (!user) - throw new Error('Failed to complete account for non-existent user'); // ensure user is non-null - - // initialize default organization and workspace - await initializeDefaultOrg({ - organizationName, - user - }); - - // update organization membership statuses that are - // invited to completed with user attached - await MembershipOrg.updateMany( - { - inviteEmail: email, - status: INVITED - }, - { - user, - status: ACCEPTED - } - ); - - // issue tokens - const tokens = await issueTokens({ - userId: user._id.toString() - }); - - token = tokens.token; - refreshToken = tokens.refreshToken; - - // sending a welcome email to new users - if (process.env.LOOPS_API_KEY) { - await axios.post("https://app.loops.so/api/v1/events/send", { - "email": email, - "eventName": "Sign Up", - "firstName": firstName, - "lastName": lastName - }, { - headers: { - "Accept": "application/json", - "Authorization": "Bearer " + process.env.LOOPS_API_KEY - }, - }); - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to complete account setup' - }); - } - - return res.status(200).send({ - message: 'Successfully set up account', - user, - token, - refreshToken - }); -}; -/** - * Complete setting up user by adding their personal and auth information as part of the - * invite flow - * @param req - * @param res - * @returns - */ -export const completeAccountInvite = async (req: Request, res: Response) => { - let user, token, refreshToken; - try { - const { - email, - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier - } = req.body; - - // get user - user = await User.findOne({ email }); - - if (!user || (user && user?.publicKey)) { - // case 1: user doesn't exist. - // case 2: user has already completed account - return res.status(403).send({ - error: 'Failed to complete account for complete user' - }); - } - - const membershipOrg = await MembershipOrg.findOne({ - inviteEmail: email, - status: INVITED - }); - - if (!membershipOrg) throw new Error('Failed to find invitations for email'); - - // complete setting up user's account - user = await completeAccount({ - userId: user._id.toString(), - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier - }); - - if (!user) - throw new Error('Failed to complete account for non-existent user'); - - // update organization membership statuses that are - // invited to completed with user attached - await MembershipOrg.updateMany( - { - inviteEmail: email, - status: INVITED - }, - { - user, - status: ACCEPTED - } - ); - - // issue tokens - const tokens = await issueTokens({ - userId: user._id.toString() - }); - - token = tokens.token; - refreshToken = tokens.refreshToken; - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to complete account setup' - }); - } - - return res.status(200).send({ - message: 'Successfully set up account', - user, - token, - refreshToken - }); -}; +}; \ No newline at end of file diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index c39939251..60bcef912 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -79,15 +79,11 @@ export const login1 = async (req: Request, res: Response) => { * @returns */ export const login2 = async (req: Request, res: Response) => { - - // check to see if user has MFA enabled; if yes then issue MFA-token - // TODO: may have to figure out a better token system for tokens with varying expirations - // (e.g. for org-invitations vs. auth etc.) try { const { email, clientProof } = req.body; const user = await User.findOne({ email - }).select('+salt +verifier +publicKey +encryptedPrivateKey +iv +tag'); + }).select('+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag'); if (!user) throw new Error('Failed to find user'); @@ -142,6 +138,10 @@ export const login2 = async (req: Request, res: Response) => { // return (access) token in response return res.status(200).send({ mfaEnabled: false, + encryptionVersion: user.encryptionVersion, + protectedKey: user.protectedKey ?? null, + protectedKeyIV: user.protectedKeyIV ?? null, + protectedKeyTag: user.protectedKeyTag ?? null, token: tokens.token, publicKey: user.publicKey, encryptedPrivateKey: user.encryptedPrivateKey, @@ -182,7 +182,7 @@ export const verifyMfaToken = async (req: Request, res: Response) => { const user = await User.findOne({ email - }).select('+salt +verifier +publicKey +encryptedPrivateKey +iv +tag'); + }).select('+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag'); if (!user) throw new Error('Failed to find user'); @@ -200,6 +200,10 @@ export const verifyMfaToken = async (req: Request, res: Response) => { // case: user does not have MFA enabled // return (access) token in response return res.status(200).send({ + encryptionVersion: user.encryptionVersion, + protectedKey: user.protectedKey ?? null, + protectedKeyIV: user.protectedKeyIV ?? null, + protectedKeyTag: user.protectedKeyTag ?? null, token: tokens.token, publicKey: user.publicKey, encryptedPrivateKey: user.encryptedPrivateKey, diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 7c5eaf180..0f79dff87 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -1,4 +1,5 @@ import * as authController from './authController'; +import * as signupController from './signupController'; import * as usersController from './usersController'; import * as organizationsController from './organizationsController'; import * as workspaceController from './workspaceController'; @@ -10,6 +11,7 @@ import * as environmentController from './environmentController'; export { authController, + signupController, usersController, organizationsController, workspaceController, diff --git a/backend/src/controllers/v2/signupController.ts b/backend/src/controllers/v2/signupController.ts new file mode 100644 index 000000000..db9d8a21b --- /dev/null +++ b/backend/src/controllers/v2/signupController.ts @@ -0,0 +1,239 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { User, MembershipOrg } from '../../models'; +import { completeAccount } from '../../helpers/user'; +import { + initializeDefaultOrg +} from '../../helpers/signup'; +import { issueTokens } from '../../helpers/auth'; +import { INVITED, ACCEPTED } from '../../variables'; +import axios from 'axios'; + +// TODO: finish + +/** + * Complete setting up user by adding their personal and auth information as part of the + * signup flow + * @param req + * @param res + * @returns + */ +export const completeAccountSignup = async (req: Request, res: Response) => { + let user, token, refreshToken; + try { + const { + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier, + organizationName + }: { + email: string; + firstName: string; + lastName: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + publicKey: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; + organizationName: string; + } = req.body; + + // get user + user = await User.findOne({ email }); + + if (!user || (user && user?.publicKey)) { + // case 1: user doesn't exist. + // case 2: user has already completed account + return res.status(403).send({ + error: 'Failed to complete account for complete user' + }); + } + + // complete setting up user's account + user = await completeAccount({ + userId: user._id.toString(), + firstName, + lastName, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + }); + + if (!user) + throw new Error('Failed to complete account for non-existent user'); // ensure user is non-null + + // initialize default organization and workspace + await initializeDefaultOrg({ + organizationName, + user + }); + + // update organization membership statuses that are + // invited to completed with user attached + await MembershipOrg.updateMany( + { + inviteEmail: email, + status: INVITED + }, + { + user, + status: ACCEPTED + } + ); + + // issue tokens + const tokens = await issueTokens({ + userId: user._id.toString() + }); + + token = tokens.token; + refreshToken = tokens.refreshToken; + + // sending a welcome email to new users + if (process.env.LOOPS_API_KEY) { + await axios.post("https://app.loops.so/api/v1/events/send", { + "email": email, + "eventName": "Sign Up", + "firstName": firstName, + "lastName": lastName + }, { + headers: { + "Accept": "application/json", + "Authorization": "Bearer " + process.env.LOOPS_API_KEY + }, + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to complete account setup' + }); + } + + return res.status(200).send({ + message: 'Successfully set up account', + user, + token, + refreshToken + }); +}; + +/** + * Complete setting up user by adding their personal and auth information as part of the + * invite flow + * @param req + * @param res + * @returns + */ +export const completeAccountInvite = async (req: Request, res: Response) => { + let user, token, refreshToken; + try { + const { + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + } = req.body; + + // get user + user = await User.findOne({ email }); + + if (!user || (user && user?.publicKey)) { + // case 1: user doesn't exist. + // case 2: user has already completed account + return res.status(403).send({ + error: 'Failed to complete account for complete user' + }); + } + + const membershipOrg = await MembershipOrg.findOne({ + inviteEmail: email, + status: INVITED + }); + + if (!membershipOrg) throw new Error('Failed to find invitations for email'); + + // complete setting up user's account + user = await completeAccount({ + userId: user._id.toString(), + firstName, + lastName, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + }); + + if (!user) + throw new Error('Failed to complete account for non-existent user'); + + // update organization membership statuses that are + // invited to completed with user attached + await MembershipOrg.updateMany( + { + inviteEmail: email, + status: INVITED + }, + { + user, + status: ACCEPTED + } + ); + + // issue tokens + const tokens = await issueTokens({ + userId: user._id.toString() + }); + + token = tokens.token; + refreshToken = tokens.refreshToken; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to complete account setup' + }); + } + + return res.status(200).send({ + message: 'Successfully set up account', + user, + token, + refreshToken + }); +}; \ No newline at end of file diff --git a/backend/src/helpers/user.ts b/backend/src/helpers/user.ts index a89ddc45c..ae0845bc6 100644 --- a/backend/src/helpers/user.ts +++ b/backend/src/helpers/user.ts @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/node'; -import { User, IUser } from '../models'; +import { User } from '../models'; /** * Initialize a user under email [email] @@ -28,10 +28,14 @@ const setupAccount = async ({ email }: { email: string }) => { * @param {String} obj.userId - id of user to finish setting up * @param {String} obj.firstName - first name of user * @param {String} obj.lastName - last name of user + * @param {Number} obj.encryptionVersion - version of auth encryption scheme used + * @param {String} obj.protectedKey - protected key in encryption version 2 + * @param {String} obj.protectedKeyIV - IV of protected key in encryption version 2 + * @param {String} obj.protectedKeyTag - tag of protected key in encryption version 2 * @param {String} obj.publicKey - publickey of user * @param {String} obj.encryptedPrivateKey - (encrypted) private key of user - * @param {String} obj.iv - iv for (encrypted) private key of user - * @param {String} obj.tag - tag for (encrypted) private key of user + * @param {String} obj.encryptedPrivateKeyIV - iv for (encrypted) private key of user + * @param {String} obj.encryptedPrivateKeyTag - tag for (encrypted) private key of user * @param {String} obj.salt - salt for auth SRP * @param {String} obj.verifier - verifier for auth SRP * @returns {Object} user - the completed user @@ -40,20 +44,28 @@ const completeAccount = async ({ userId, firstName, lastName, + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier }: { userId: string; firstName: string; lastName: string; + encryptionVersion: number; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey: string; encryptedPrivateKey: string; - iv: string; - tag: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; salt: string; verifier: string; }) => { @@ -67,10 +79,14 @@ const completeAccount = async ({ { firstName, lastName, + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, - iv, - tag, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, salt, verifier }, diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index fbf3b2791..e709291cb 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -1,11 +1,14 @@ import { Schema, model, Types } from 'mongoose'; -import { MFA_METHOD_EMAIL } from '../variables'; export interface IUser { _id: Types.ObjectId; email: string; firstName?: string; lastName?: string; + encryptionVersion: number; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey?: string; encryptedPrivateKey?: string; iv?: string; @@ -28,6 +31,23 @@ const userSchema = new Schema( lastName: { type: String }, + encryptionVersion: { + type: Number, + select: false, + default: 1 // to resolve backward-compatibility issues + }, + protectedKey: { // introduced as part of encryption version 2 + type: String, + select: false + }, + protectedKeyIV: { // introduced as part of encryption version 2 + type: String, + select: false + }, + protectedKeyTag: { // introduced as part of encryption version 2 + type: String, + select: false + }, publicKey: { type: String, select: false @@ -36,11 +56,11 @@ const userSchema = new Schema( type: String, select: false }, - iv: { + iv: { // iv of [encryptedPrivateKey] type: String, select: false }, - tag: { + tag: { // tag of [encryptedPrivateKey] type: String, select: false }, diff --git a/backend/src/routes/v1/password.ts b/backend/src/routes/v1/password.ts index 784ef1813..bc353cf08 100644 --- a/backend/src/routes/v1/password.ts +++ b/backend/src/routes/v1/password.ts @@ -10,7 +10,7 @@ router.post( requireAuth({ acceptedAuthModes: ['jwt'] }), - body('clientPublicKey').exists().trim().notEmpty(), + body('clientPublicKey').exists().isString().trim().notEmpty(), validateRequest, passwordController.srp1 ); @@ -22,11 +22,14 @@ router.post( acceptedAuthModes: ['jwt'] }), body('clientProof').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty().notEmpty(), // private key encrypted under new pwd - body('iv').exists().trim().notEmpty(), // new iv for private key - body('tag').exists().trim().notEmpty(), // new tag for private key - body('salt').exists().trim().notEmpty(), // part of new pwd - body('verifier').exists().trim().notEmpty(), // part of new pwd + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), // private key encrypted under new pwd + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), // new iv for private key + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), // new tag for private key + body('salt').exists().isString().trim().notEmpty(), // part of new pwd + body('verifier').exists().isString().trim().notEmpty(), // part of new pwd validateRequest, passwordController.changePassword ); @@ -34,7 +37,7 @@ router.post( router.post( '/email/password-reset', passwordLimiter, - body('email').exists().trim().notEmpty(), + body('email').exists().isString().trim().notEmpty().isEmail(), validateRequest, passwordController.emailPasswordReset ); @@ -42,8 +45,8 @@ router.post( router.post( '/email/password-reset-verify', passwordLimiter, - body('email').exists().trim().notEmpty().isEmail(), - body('code').exists().trim().notEmpty(), + body('email').exists().isString().trim().notEmpty().isEmail(), + body('code').exists().isString().trim().notEmpty(), validateRequest, passwordController.emailPasswordResetVerify ); @@ -61,12 +64,12 @@ router.post( requireAuth({ acceptedAuthModes: ['jwt'] }), - body('clientProof').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty(), // (backup) private key encrypted under a strong key - body('iv').exists().trim().notEmpty(), // new iv for (backup) private key - body('tag').exists().trim().notEmpty(), // new tag for (backup) private key - body('salt').exists().trim().notEmpty(), // salt generated from strong key - body('verifier').exists().trim().notEmpty(), // salt generated from strong key + body('clientProof').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), // (backup) private key encrypted under a strong key + body('iv').exists().isString().trim().notEmpty(), // new iv for (backup) private key + body('tag').exists().isString().trim().notEmpty(), // new tag for (backup) private key + body('salt').exists().isString().trim().notEmpty(), // salt generated from strong key + body('verifier').exists().isString().trim().notEmpty(), // salt generated from strong key validateRequest, passwordController.createBackupPrivateKey ); @@ -74,11 +77,14 @@ router.post( router.post( '/password-reset', requireSignupAuth, - body('encryptedPrivateKey').exists().trim().notEmpty(), // private key encrypted under new pwd - body('iv').exists().trim().notEmpty(), // new iv for private key - body('tag').exists().trim().notEmpty(), // new tag for private key - body('salt').exists().trim().notEmpty(), // part of new pwd - body('verifier').exists().trim().notEmpty(), // part of new pwd + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), // private key encrypted under new pwd + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), // new iv for private key + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), // new tag for private key + body('salt').exists().isString().trim().notEmpty(), // part of new pwd + body('verifier').exists().isString().trim().notEmpty(), // part of new pwd validateRequest, passwordController.resetPassword ); diff --git a/backend/src/routes/v1/signup.ts b/backend/src/routes/v1/signup.ts index d1582474c..35a043c8e 100644 --- a/backend/src/routes/v1/signup.ts +++ b/backend/src/routes/v1/signup.ts @@ -1,7 +1,7 @@ import express from 'express'; const router = express.Router(); import { body } from 'express-validator'; -import { requireSignupAuth, validateRequest } from '../../middleware'; +import { validateRequest } from '../../middleware'; import { signupController } from '../../controllers/v1'; import { authLimiter } from '../../helpers/rateLimiter'; @@ -22,39 +22,4 @@ router.post( signupController.verifyEmailSignup ); -router.post( - '/complete-account/signup', - authLimiter, - requireSignupAuth, - body('email').exists().trim().notEmpty().isEmail(), - body('firstName').exists().trim().notEmpty(), - body('lastName').exists().trim().notEmpty(), - body('publicKey').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty(), - body('iv').exists().trim().notEmpty(), - body('tag').exists().trim().notEmpty(), - body('salt').exists().trim().notEmpty(), - body('verifier').exists().trim().notEmpty(), - body('organizationName').exists().trim().notEmpty(), - validateRequest, - signupController.completeAccountSignup -); - -router.post( - '/complete-account/invite', - authLimiter, - requireSignupAuth, - body('email').exists().trim().notEmpty().isEmail(), - body('firstName').exists().trim().notEmpty(), - body('lastName').exists().trim().notEmpty(), - body('publicKey').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty(), - body('iv').exists().trim().notEmpty(), - body('tag').exists().trim().notEmpty(), - body('salt').exists().trim().notEmpty(), - body('verifier').exists().trim().notEmpty(), - validateRequest, - signupController.completeAccountInvite -); - export default router; diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index e51bb7588..cf0f9a22b 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -1,4 +1,5 @@ import auth from './auth'; +import signup from './signup'; import users from './users'; import organizations from './organizations'; import workspace from './workspace'; @@ -10,6 +11,7 @@ import environment from "./environment" export { auth, + signup, users, organizations, workspace, diff --git a/backend/src/routes/v2/signup.ts b/backend/src/routes/v2/signup.ts new file mode 100644 index 000000000..138591879 --- /dev/null +++ b/backend/src/routes/v2/signup.ts @@ -0,0 +1,49 @@ +import express from 'express'; +const router = express.Router(); +import { body } from 'express-validator'; +import { requireSignupAuth, validateRequest } from '../../middleware'; +import { signupController } from '../../controllers/v2'; +import { authLimiter } from '../../helpers/rateLimiter'; + +router.post( + '/complete-account/signup', + authLimiter, + requireSignupAuth, + body('email').exists().isString().trim().notEmpty().isEmail(), + body('firstName').exists().isString().trim().notEmpty(), + body('lastName').exists().isString().trim().notEmpty(), + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('publicKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), + body('salt').exists().isString().trim().notEmpty(), + body('verifier').exists().isString().trim().notEmpty(), + body('organizationName').exists().isString().trim().notEmpty(), + validateRequest, + signupController.completeAccountSignup +); + +router.post( + '/complete-account/invite', + authLimiter, + requireSignupAuth, + body('email').exists().isString().trim().notEmpty().isEmail(), + body('firstName').exists().isString().trim().notEmpty(), + body('lastName').exists().isString().trim().notEmpty(), + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('publicKey').exists().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), + body('salt').exists().isString().trim().notEmpty(), + body('verifier').exists().isString().trim().notEmpty(), + validateRequest, + signupController.completeAccountInvite +); + +export default router; \ No newline at end of file diff --git a/docs/security/data-model.mdx b/docs/security/data-model.mdx index 6c60f9e41..6c07cb479 100644 --- a/docs/security/data-model.mdx +++ b/docs/security/data-model.mdx @@ -6,22 +6,50 @@ Infisical stores a range of data namely user, secrets, keys, organization, proje ## Users -The `User` model includes the fields `email`, `firstName`, `lastName`, `publicKey`, `encryptedPrivateKey`, `iv`, `tag`, `salt`, `verifier`, and `refreshVersion`. +The `User` model includes the fields `email`, `firstName`, `lastName`, `publicKey`, `encryptionVersion`, `protectedKey`, `protectedKeyIV`, `protectedKeyTag`, `encryptedPrivateKey`, `iv`, `tag`, `salt`, `verifier`, and `refreshVersion`. -Infisical makes a usability-security tradeoff to give users convenient access to public-private key pairs across different devices upon login, solving key-storage and transfer challenges across device and browser mediums, in exchange for it storing `encryptedPrivateKey`. In any case, private keys are symmetrically encrypted locally by user passwords which are not sent to the server — this is done with SRP. +Infisical makes a usability-security tradeoff that is to give users convenient access to public-private key pairs across different devices upon login, solving key-storage and transfer challenges across device and browser mediums, in exchange for it storing `encryptedPrivateKey`. + + + `encryptedPrivateKey` is obtained by symmetrically encrypting the user's + private key locally with a protected key which is encrypted by the key derived + from the user's password and salt. Encryption is done via `AES256-GCM` and key + derivation via `argon2id`. The user's password is not sent to the server — + this is done with SRP. + ## Secrets -The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretKeyHash`, `secretValueCiphertext`, `secretValueIV`, `secretValueTag`, and `secretValueHash`. +The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretValueCiphertext`, `secretValueIV`, and `secretValueTag`. Each secret is symmetrically encrypted by the key of the project that it belongs to; that key's encrypted copies are stored in a separate `Key` collection. -## Keys +## Project Keys The `Key` model includes the fields `encryptedKey`, `nonce`, `sender`, `receiver`, and `workspace`. Infisical stores copies of project keys, one for each member of a project, encrypted under each member's public key. +## Bots + +The `Bot` model contains the fields `name`, `workspace`, `isActive`, `publicKey`, `encryptedPrivateKey`, `iv`, and `tag`. + +Each project comes with a bot that has its own public-private key pair; its private key is encrypted by the server's symmetric key. If needed, a user can opt-in to share their project key with the bot (i.e. Infisical) to give the platform access to the project's secrets. + + + Sharing secrets with Infisical so they can be synced to integrations like + Vercel, GitHub, and Netlify is something we make sure users consent to before + opting in. + + ## Organizations and Workspaces The `Organization`, `Workspace`, `MembershipOrg`, and `Membership` models contain enrollment information for organizations and projects; they are used to check if users are authorized to retrieve select secrets. + +## Service Tokens + +The `ServiceTokenData` model contains data for service tokens that enable users to fetch secrets from a particular project and environment; each service token data record includes an (encrypted) copy of the project key that it is bound to as well as a validation hash for `bcrypt`. + +## API Keys + +The `APIKeyData` model contains data for API keys that enable users to interact with [Infisical's Open API](https://infisical.com/docs/api-reference/overview/introduction); each API key data record includes a validation hash for `bcrypt`. diff --git a/docs/security/mechanics.mdx b/docs/security/mechanics.mdx index 814524d73..2be11c6fc 100644 --- a/docs/security/mechanics.mdx +++ b/docs/security/mechanics.mdx @@ -4,7 +4,11 @@ title: "Mechanics" ## Signup -During account signup, a user confirms their email address via OTP, generates a public-private key pair to be stored locally (private keys are symmetrically encrypted by the user's newly-made password), and forwards SRP-related values and user identifier information to the server. This includes `email`, `firstName`, `lastName`, `publicKey`, `encryptedPrivateKey`, `iv`, `tag`, `salt`, `verifier`, and `organizationName`. +During account signup, a user confirms their email address via OTP, generates a public-private key pair to be stored locally, generates a user salt, generates a 256-bit key, and enters their password. + +The 256-bit key is used to encrypt the private key; the 256-bit key itself is then encrypted by a key generated from the user's password and salt with key derivation function `argon2id`. The resulting, 256-bit key the protected key. + +The encrypted private key, protected key, user identifier information, and SRP details are forwarded to the server. Once authenticated via SRP, a user is issued a JWT and refresh token. The JWT token is stored in browser memory under a write-only class `SecurityClient` that appends the token to all future outbound requests requiring authentication. The refresh token is stored in an `HttpOnly` cookie and included in future requests to `/api/token` for JWT token renewal. This design side-steps potential XSS attacks on local storage. diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 7c471fdc6..155e6d6e8 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -1,4 +1,9 @@ module.exports = { + overrides: [ + { + files: ["next.config.js"] + } + ], root: true, env: { browser: true, @@ -87,6 +92,7 @@ module.exports = { } ] }, + ignorePatterns: ['next.config.js'], settings: { 'import/resolver': { typescript: { diff --git a/frontend/next.config.js b/frontend/next.config.js index 06c56dd78..5e7c81270 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,9 +1,11 @@ + // @ts-check /** * @type {import('next').NextConfig} **/ const { i18n } = require("./next-i18next.config.js"); +const path = require('path'); const ContentSecurityPolicy = ` default-src 'self'; @@ -65,7 +67,33 @@ module.exports = { }, ]; }, - webpack: (config, { isServer, webpack }) => { + webpack: (config, { isServer, webpack }) => { // config + config.module.rules.push({ + test: /\.wasm$/, + loader: "base64-loader", + type: "javascript/auto", + }); + + config.module.noParse = /\.wasm$/; + + config.module.rules.forEach((rule) => { + (rule.oneOf || []).forEach((oneOf) => { + if (oneOf.loader && oneOf.loader.indexOf("file-loader") >= 0) { + oneOf.exclude.push(/\.wasm$/); + } + }); + }); + + if (!isServer) { + config.resolve.fallback.fs = false; + } + + // Perform customizations to webpack config + config.plugins.push( + new webpack.IgnorePlugin({ resourceRegExp: /\/__tests__\// }) + ); + + // Important: return the modified config return config; }, i18n, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 44d791bb0..6a6a4e6c8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,9 +25,12 @@ "@reduxjs/toolkit": "^1.8.3", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.46.0", + "@types/argon2-browser": "^1.18.1", "add": "^2.0.6", + "argon2-browser": "^1.18.0", "axios": "^0.27.2", "axios-auth-refresh": "^3.3.3", + "base64-loader": "^1.0.0", "classnames": "^2.3.1", "cookies": "^0.8.0", "fs": "^0.0.1-security", @@ -6618,6 +6621,11 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@types/argon2-browser": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.1.tgz", + "integrity": "sha512-PZffP/CqH9m2kovDSRQMfMMxUC3V98I7i7/caa0RB0/nvsXzYbL9bKyqZpNMFmLFGZslROlG1R60ONt7abrwlA==" + }, "node_modules/@types/aria-query": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", @@ -8027,6 +8035,11 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, + "node_modules/argon2-browser": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", + "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -8621,6 +8634,11 @@ } ] }, + "node_modules/base64-loader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64-loader/-/base64-loader-1.0.0.tgz", + "integrity": "sha512-p32+F8dg+ANGx7s8QsZS74ZPHfIycmC2yZcoerzFgbersIYWitPbbF39G6SBx3gyvzyLH5nt1ooocxr0IHuWKA==" + }, "node_modules/better-opn": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz", @@ -26728,6 +26746,11 @@ "@babel/runtime": "^7.12.5" } }, + "@types/argon2-browser": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.1.tgz", + "integrity": "sha512-PZffP/CqH9m2kovDSRQMfMMxUC3V98I7i7/caa0RB0/nvsXzYbL9bKyqZpNMFmLFGZslROlG1R60ONt7abrwlA==" + }, "@types/aria-query": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", @@ -27860,6 +27883,11 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, + "argon2-browser": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", + "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==" + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -28295,6 +28323,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64-loader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64-loader/-/base64-loader-1.0.0.tgz", + "integrity": "sha512-p32+F8dg+ANGx7s8QsZS74ZPHfIycmC2yZcoerzFgbersIYWitPbbF39G6SBx3gyvzyLH5nt1ooocxr0IHuWKA==" + }, "better-opn": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8c30f749e..25efc0ccb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,9 +32,12 @@ "@reduxjs/toolkit": "^1.8.3", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.46.0", + "@types/argon2-browser": "^1.18.1", "add": "^2.0.6", + "argon2-browser": "^1.18.0", "axios": "^0.27.2", "axios-auth-refresh": "^3.3.3", + "base64-loader": "^1.0.0", "classnames": "^2.3.1", "cookies": "^0.8.0", "fs": "^0.0.1-security", diff --git a/frontend/src/components/signup/UserInfoStep.tsx b/frontend/src/components/signup/UserInfoStep.tsx index bf9fa1edb..1a43d2dc4 100644 --- a/frontend/src/components/signup/UserInfoStep.tsx +++ b/frontend/src/components/signup/UserInfoStep.tsx @@ -1,3 +1,5 @@ +import crypto from 'crypto'; + import React, { useState } from 'react'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; @@ -14,6 +16,8 @@ import InputField from '../basic/InputField'; import attemptLogin from '../utilities/attemptLogin'; import passwordCheck from '../utilities/checks/PasswordCheck'; import Aes256Gcm from '../utilities/cryptography/aes-256-gcm'; +import { deriveArgonKey } from '../utilities/cryptography/crypto'; +import { saveTokenToLocalStorage } from '../utilities/saveTokenToLocalStorage'; // eslint-disable-next-line new-cap const client = new jsrp.client(); @@ -94,17 +98,9 @@ export default function UserInfoStep({ const pair = nacl.box.keyPair(); const secretKeyUint8Array = pair.secretKey; const publicKeyUint8Array = pair.publicKey; - const PRIVATE_KEY = encodeBase64(secretKeyUint8Array); - const PUBLIC_KEY = encodeBase64(publicKeyUint8Array); - - const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ - text: PRIVATE_KEY, - secret: password - .slice(0, 32) - .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') - }) as { ciphertext: string; iv: string; tag: string }; - - localStorage.setItem('PRIVATE_KEY', PRIVATE_KEY); + const privateKey = encodeBase64(secretKeyUint8Array); + const publicKey = encodeBase64(publicKeyUint8Array); + localStorage.setItem('PRIVATE_KEY', privateKey); client.init( { @@ -113,35 +109,81 @@ export default function UserInfoStep({ }, async () => { client.createVerifier(async (err: any, result: { salt: string; verifier: string }) => { - const response = await completeAccountInformationSignup({ - email, - firstName, - lastName, - organizationName: `${firstName}'s organization`, - publicKey: PUBLIC_KEY, - ciphertext, - iv, - tag, - salt: result.salt, - verifier: result.verifier, - token: verificationToken - }); + try { + const derivedKey = await deriveArgonKey({ + password, + salt: result.salt, + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); + + if (!derivedKey) throw new Error('Failed to derive key from password'); - // if everything works, go the main dashboard page. - if (response.status === 200) { - // response = await response.json(); + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = Aes256Gcm.encrypt({ + text: privateKey, + secret: key + }); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = Aes256Gcm.encrypt({ + text: key.toString('hex'), + secret: Buffer.from(derivedKey.hash) + }); + + const response = await completeAccountInformationSignup({ + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt: result.salt, + verifier: result.verifier, + token: verificationToken, + organizationName: `${firstName}'s organization` + }); + + // if everything works, go the main dashboard page. + if (response.status === 200) { + // response = await response.json(); - localStorage.setItem('publicKey', PUBLIC_KEY); - localStorage.setItem('encryptedPrivateKey', ciphertext); - localStorage.setItem('iv', iv); - localStorage.setItem('tag', tag); + saveTokenToLocalStorage({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + privateKey + }); - try { await attemptLogin(email, password, () => {}, router, true, false); incrementStep(); - } catch (error) { - setIsLoading(false); } + + } catch (error) { + setIsLoading(false); + console.error(error); } }); } @@ -258,4 +300,4 @@ export default function UserInfoStep({ ); -} +} \ No newline at end of file diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index acc2b67fe..f7b69751f 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -13,7 +13,7 @@ import getOrganizationUserProjects from '@app/pages/api/organization/GetOrgUserP import getUser from '@app/pages/api/user/getUser'; import uploadKeys from '@app/pages/api/workspace/uploadKeys'; -import { encryptAssymmetric } from './cryptography/crypto'; +import { deriveArgonKey, encryptAssymmetric } from './cryptography/crypto'; import encryptSecrets from './secrets/encryptSecrets'; import Telemetry from './telemetry/Telemetry'; import { saveTokenToLocalStorage } from './saveTokenToLocalStorage'; @@ -59,29 +59,81 @@ const attemptLogin = async ( const clientProof = client.getProof(); // called M1 // if everything works, go the main dashboard page. - const { token, publicKey, encryptedPrivateKey, iv, tag } = await login2( + const { // mfaEnabled + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, + token, + publicKey, + encryptedPrivateKey, + iv, + tag + } = await login2( email, clientProof ); SecurityClient.setToken(token); - const privateKey = Aes256Gcm.decrypt({ - ciphertext: encryptedPrivateKey, - iv, - tag, - secret: password - .slice(0, 32) - .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') - }); + let privateKey; + if (encryptionVersion === 1) { + privateKey = Aes256Gcm.decrypt({ + ciphertext: encryptedPrivateKey, + iv, + tag, + secret: password + .slice(0, 32) + .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') + }); - saveTokenToLocalStorage({ - publicKey, - encryptedPrivateKey, - iv, - tag, - privateKey - }); + saveTokenToLocalStorage({ + publicKey, + encryptedPrivateKey, + iv, + tag, + privateKey + }); + } else if (encryptionVersion === 2 && protectedKey && protectedKeyIV && protectedKeyTag) { + const derivedKey = await deriveArgonKey({ + password, + salt, + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); + + if (!derivedKey) throw new Error('Failed to derive key'); + + const key = Aes256Gcm.decrypt({ + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag, + secret: Buffer.from(derivedKey.hash) + }); + + // decrypt back the private key + privateKey = Aes256Gcm.decrypt({ + ciphertext: encryptedPrivateKey, + iv, + tag, + secret: Buffer.from(key, 'hex') + }); + + saveTokenToLocalStorage({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + iv, + tag, + privateKey + }); + } + + if (!privateKey) throw new Error('Failed to decrypt private key'); const userOrgs = await getOrganizations(); const userOrgsData = userOrgs.map((org: { _id: string }) => org._id); diff --git a/frontend/src/components/utilities/cryptography/aes-256-gcm.ts b/frontend/src/components/utilities/cryptography/aes-256-gcm.ts index 5a2301c25..72ac5a6b2 100644 --- a/frontend/src/components/utilities/cryptography/aes-256-gcm.ts +++ b/frontend/src/components/utilities/cryptography/aes-256-gcm.ts @@ -9,14 +9,14 @@ const BLOCK_SIZE_BYTES = 16; // 128 bit interface EncryptProps { text: string; - secret: string; + secret: string | Buffer; } interface DecryptProps { ciphertext: string; iv: string; tag: string; - secret: string; + secret: string | Buffer; } interface EncryptOutputProps { diff --git a/frontend/src/components/utilities/cryptography/changePassword.ts b/frontend/src/components/utilities/cryptography/changePassword.ts index db72856ed..bdc741b3d 100644 --- a/frontend/src/components/utilities/cryptography/changePassword.ts +++ b/frontend/src/components/utilities/cryptography/changePassword.ts @@ -1,14 +1,20 @@ /* eslint-disable new-cap */ +import crypto from 'crypto'; + import jsrp from 'jsrp'; import changePassword2 from '@app/pages/api/auth/ChangePassword2'; import SRP1 from '@app/pages/api/auth/SRP1'; +import { saveTokenToLocalStorage } from '../saveTokenToLocalStorage'; import Aes256Gcm from './aes-256-gcm'; +import { deriveArgonKey } from './crypto'; const clientOldPassword = new jsrp.client(); const clientNewPassword = new jsrp.client(); +// TODO: modify this function + /** * This function loggs in the user (whether it's right after signup, or a normal login) * @param {*} email @@ -63,43 +69,75 @@ const changePassword = async ( }, async () => { clientNewPassword.createVerifier(async (err, result) => { - // The Blob part here is needed to account for symbols that count as 2+ bytes (e.g., é, å, ø) - const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ + + const derivedKey = await deriveArgonKey({ + password: newPassword, + salt: result.salt, + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); + + if (!derivedKey) throw new Error('Failed to derive key from password'); + + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = Aes256Gcm.encrypt({ text: localStorage.getItem('PRIVATE_KEY') as string, - secret: newPassword - .slice(0, 32) - .padStart( - 32 + (newPassword.slice(0, 32).length - new Blob([newPassword]).size), - '0' - ) + secret: key + }); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = Aes256Gcm.encrypt({ + text: key.toString('hex'), + secret: Buffer.from(derivedKey.hash) }); - if (ciphertext) { - localStorage.setItem('encryptedPrivateKey', ciphertext); - localStorage.setItem('iv', iv); - localStorage.setItem('tag', tag); + let res; + try { + res = await changePassword2({ + clientProof, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt: result.salt, + verifier: result.verifier + }); + + saveTokenToLocalStorage({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + }); - let res; - try { - res = await changePassword2({ - encryptedPrivateKey: ciphertext, - iv, - tag, - salt: result.salt, - verifier: result.verifier, - clientProof - }); - if (res && res.status === 400) { - setCurrentPasswordError(true); - } else if (res && res.status === 200) { - setPasswordChanged(true); - setCurrentPassword(''); - setNewPassword(''); - } - } catch (error) { + if (res && res.status === 400) { setCurrentPasswordError(true); - console.log(error); + } else if (res && res.status === 200) { + setPasswordChanged(true); + setCurrentPassword(''); + setNewPassword(''); } + } catch (error) { + setCurrentPasswordError(true); + console.log(error); } }); } diff --git a/frontend/src/components/utilities/cryptography/crypto.ts b/frontend/src/components/utilities/cryptography/crypto.ts index d1db995ee..961dee83f 100644 --- a/frontend/src/components/utilities/cryptography/crypto.ts +++ b/frontend/src/components/utilities/cryptography/crypto.ts @@ -1,3 +1,5 @@ +import argon2 from 'argon2-browser'; + import aes from './aes-256-gcm'; const nacl = require('tweetnacl'); @@ -9,6 +11,50 @@ type EncryptAsymmetricProps = { privateKey: string; }; +/** + * Derive a key from password [password] and salt [salt] using Argon2id + * @param {Object} obj + * @param {String} obj.password - password to derive key from + * @param {String} obj.salt - salt to derive key from + * @param {Number} obj.mem - used memory, in KiB + * @param {Number} obj.time - number of iterations + * @param {Number} obj.parallelism - desired parallelism + * @param {Number} obj.hashLen - desired hash length (i.e. byte-length of derived key) + * @returns + */ +const deriveArgonKey = async ({ + password, + salt, + mem, + time, + parallelism, + hashLen +}: { + password: string; + salt: string; + mem: number; + time: number; + parallelism: number; + hashLen: number; +}) => { + let derivedKey; + try { + derivedKey = await argon2.hash({ + pass: password, + salt, + type: argon2.ArgonType.Argon2id, + mem, + time, + parallelism, + hashLen + }); + } catch (err) { + console.error(err); + } + + return derivedKey; +} + /** * Return assymmetrically encrypted [plaintext] using [publicKey] where * [publicKey] likely belongs to the recipient. @@ -138,4 +184,10 @@ const decryptSymmetric = ({ ciphertext, iv, tag, key }: DecryptSymmetricProps): return plaintext; }; -export { decryptAssymmetric, decryptSymmetric, encryptAssymmetric, encryptSymmetric }; +export { + decryptAssymmetric, + decryptSymmetric, + deriveArgonKey, + encryptAssymmetric, + encryptSymmetric +}; diff --git a/frontend/src/components/utilities/saveTokenToLocalStorage.ts b/frontend/src/components/utilities/saveTokenToLocalStorage.ts index b624babd6..90cf5bd7d 100644 --- a/frontend/src/components/utilities/saveTokenToLocalStorage.ts +++ b/frontend/src/components/utilities/saveTokenToLocalStorage.ts @@ -1,12 +1,18 @@ interface Props { - publicKey: string; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + publicKey?: string; encryptedPrivateKey: string; iv: string; tag: string; - privateKey: string; + privateKey?: string; } export const saveTokenToLocalStorage = ({ + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, iv, @@ -14,11 +20,38 @@ export const saveTokenToLocalStorage = ({ privateKey, }: Props) => { try { - localStorage.setItem("publicKey", publicKey); + localStorage.removeItem("protectedKey"); + localStorage.removeItem("protectedKeyIV"); + localStorage.removeItem("protectedKeyTag"); + localStorage.removeItem("publicKey"); + localStorage.removeItem("encryptedPrivateKey"); + localStorage.removeItem("iv"); + localStorage.removeItem("tag"); + localStorage.removeItem("PRIVATE_KEY"); + + if (protectedKey) { + localStorage.setItem("protectedKey", protectedKey); + } + + if (protectedKeyIV) { + localStorage.setItem("protectedKeyIV", protectedKeyIV); + } + + if (protectedKeyTag) { + localStorage.setItem("protectedKeyTag", protectedKeyTag); + } + + if (publicKey) { + localStorage.setItem("publicKey", publicKey); + } + + if (privateKey) { + localStorage.setItem("PRIVATE_KEY", privateKey); + } + localStorage.setItem("encryptedPrivateKey", encryptedPrivateKey); localStorage.setItem("iv", iv); localStorage.setItem("tag", tag); - localStorage.setItem("PRIVATE_KEY", privateKey); } catch (err) { if (err instanceof Error) { throw new Error( diff --git a/frontend/src/pages/api/auth/ChangePassword2.ts b/frontend/src/pages/api/auth/ChangePassword2.ts index 71857f8b7..b2ab4906e 100644 --- a/frontend/src/pages/api/auth/ChangePassword2.ts +++ b/frontend/src/pages/api/auth/ChangePassword2.ts @@ -1,12 +1,15 @@ import SecurityClient from '@app/components/utilities/SecurityClient'; interface Props { + clientProof: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; encryptedPrivateKey: string; - iv: string; - tag: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; salt: string; verifier: string; - clientProof: string; } /** @@ -14,7 +17,17 @@ interface Props { * @param {*} clientPublicKey * @returns */ -const changePassword2 = ({ encryptedPrivateKey, iv, tag, salt, verifier, clientProof }: Props) => +const changePassword2 = ({ + clientProof, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier +}: Props) => SecurityClient.fetchCall('/api/v1/password/change-password', { method: 'POST', headers: { @@ -22,9 +35,12 @@ const changePassword2 = ({ encryptedPrivateKey, iv, tag, salt, verifier, clientP }, body: JSON.stringify({ clientProof, + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier }) diff --git a/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts b/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts index 3fe985cab..3236bdabd 100644 --- a/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts +++ b/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts @@ -2,11 +2,14 @@ interface Props { email: string; firstName: string; lastName: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey: string; - ciphertext: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; organizationName: string; - iv: string; - tag: string; salt: string; verifier: string; token: string; @@ -19,6 +22,9 @@ interface Props { * @param {string} obj.email - email of the user completing signup * @param {string} obj.firstName - first name of the user completing signup * @param {string} obj.lastName - last name of the user completing sign up + * @param {string} obj.protectedKey - protected key in encryption version 2 + * @param {string} obj.protectedKeyIV - IV of protected key in encryption version 2 + * @param {string} obj.protectedKeyTag - tag of protected key in encryption version 2 * @param {string} obj.organizationName - organization name for this user (usually, [FIRST_NAME]'s organization) * @param {string} obj.publicKey - public key of the user completing signup * @param {string} obj.ciphertext @@ -33,15 +39,18 @@ const completeAccountInformationSignup = ({ email, firstName, lastName, - organizationName, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, - ciphertext, - iv, - tag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier, - token -}: Props) => fetch('/api/v1/signup/complete-account/signup', { + token, + organizationName +}: Props) => fetch('/api/v2/signup/complete-account/signup', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -51,13 +60,16 @@ const completeAccountInformationSignup = ({ email, firstName, lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, - encryptedPrivateKey: ciphertext, - organizationName, - iv, - tag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, - verifier + verifier, + organizationName }) }); diff --git a/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts b/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts index 6fada48ba..62a5b503f 100644 --- a/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts +++ b/frontend/src/pages/api/auth/CompleteAccountInformationSignupInvite.ts @@ -2,10 +2,13 @@ interface Props { email: string; firstName: string; lastName: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey: string; - ciphertext: string; - iv: string; - tag: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; salt: string; verifier: string; token: string; @@ -31,27 +34,33 @@ const completeAccountInformationSignupInvite = ({ email, firstName, lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, - ciphertext, - iv, - tag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier, token -}: Props) => fetch('/api/v1/signup/complete-account/invite', { +}: Props) => fetch('/api/v2/signup/complete-account/invite', { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${ token}` + Authorization: `Bearer ${token}` }, body: JSON.stringify({ email, firstName, lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, - encryptedPrivateKey: ciphertext, - iv, - tag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier }) diff --git a/frontend/src/pages/api/auth/Login1.ts b/frontend/src/pages/api/auth/Login1.ts index 94a56866d..30f2656f7 100644 --- a/frontend/src/pages/api/auth/Login1.ts +++ b/frontend/src/pages/api/auth/Login1.ts @@ -10,7 +10,7 @@ interface Login1 { * @returns */ const login1 = async (email: string, clientPublicKey: string) => { - const response = await fetch("/api/v1/auth/login1", { + const response = await fetch("/api/v2/auth/login1", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/frontend/src/pages/api/auth/Login2.ts b/frontend/src/pages/api/auth/Login2.ts index 6c431cbb8..7443830f8 100644 --- a/frontend/src/pages/api/auth/Login2.ts +++ b/frontend/src/pages/api/auth/Login2.ts @@ -1,9 +1,14 @@ interface Login2Response { + mfaEnabled: boolean; + encryptionVersion: number; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + token: string; + publicKey: string; encryptedPrivateKey: string; iv: string; - publicKey: string; tag: string; - token: string; } /** @@ -13,7 +18,7 @@ interface Login2Response { * @returns */ const login2 = async (email: string, clientProof: string) => { - const response = await fetch('/api/v1/auth/login2', { + const response = await fetch('/api/v2/auth/login2', { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/frontend/src/pages/api/auth/Logout.ts b/frontend/src/pages/api/auth/Logout.ts index acf04bc1a..71f81b1bd 100644 --- a/frontend/src/pages/api/auth/Logout.ts +++ b/frontend/src/pages/api/auth/Logout.ts @@ -15,11 +15,15 @@ const logout = async () => if (res?.status === 200) { SecurityClient.setToken(''); // Delete the cookie by not setting a value; Alternatively clear the local storage - localStorage.setItem('publicKey', ''); - localStorage.setItem('encryptedPrivateKey', ''); - localStorage.setItem('iv', ''); - localStorage.setItem('tag', ''); - localStorage.setItem('PRIVATE_KEY', ''); + localStorage.removeItem('protectedKey'); + localStorage.removeItem('protectedKeyIV'); + localStorage.removeItem('protectedKeyTag'); + localStorage.removeItem('publicKey'); + localStorage.removeItem('encryptedPrivateKey'); + localStorage.removeItem('iv'); + localStorage.removeItem('tag'); + localStorage.removeItem('PRIVATE_KEY'); + console.log('User logged out', res); return res; } diff --git a/frontend/src/pages/api/auth/resetPasswordOnAccountRecovery.ts b/frontend/src/pages/api/auth/resetPasswordOnAccountRecovery.ts index 77c1a2aa0..2687a372a 100644 --- a/frontend/src/pages/api/auth/resetPasswordOnAccountRecovery.ts +++ b/frontend/src/pages/api/auth/resetPasswordOnAccountRecovery.ts @@ -1,10 +1,13 @@ interface Props { - verificationToken: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; encryptedPrivateKey: string; - iv: string; - tag: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; salt: string; verifier: string; + verificationToken: string; } /** @@ -19,22 +22,28 @@ interface Props { * @returns */ const resetPasswordOnAccountRecovery = ({ - verificationToken, + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, - verifier + verifier, + verificationToken, }: Props) => fetch('/api/v1/password/password-reset', { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${ verificationToken}` + Authorization: `Bearer ${verificationToken}` }, body: JSON.stringify({ + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier }) diff --git a/frontend/src/pages/password-reset.tsx b/frontend/src/pages/password-reset.tsx index 7da1778ea..4c9020a68 100644 --- a/frontend/src/pages/password-reset.tsx +++ b/frontend/src/pages/password-reset.tsx @@ -1,3 +1,5 @@ +import crypto from 'crypto'; + import { useState } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/router'; @@ -12,6 +14,7 @@ import passwordCheck from '@app/components/utilities/checks/PasswordCheck'; import Aes256Gcm from '@app/components/utilities/cryptography/aes-256-gcm'; import { getTranslatedStaticProps } from '@app/components/utilities/withTranslateProps'; +import { deriveArgonKey } from '../components/utilities/cryptography/crypto'; import EmailVerifyOnPasswordReset from './api/auth/EmailVerifyOnPasswordReset'; import getBackupEncryptedPrivateKey from './api/auth/getBackupEncryptedPrivateKey'; import resetPasswordOnAccountRecovery from './api/auth/resetPasswordOnAccountRecovery'; @@ -39,6 +42,7 @@ export default function PasswordReset() { const getEncryptedKeyHandler = async () => { try { const result = await getBackupEncryptedPrivateKey({ verificationToken }); + setPrivateKey( Aes256Gcm.decrypt({ ciphertext: result.encryptedPrivateKey, @@ -64,13 +68,12 @@ export default function PasswordReset() { }); if (!errorCheck) { - // Generate a random pair of a public and a private key - const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ - text: privateKey, - secret: newPassword - .slice(0, 32) - .padStart(32 + (newPassword.slice(0, 32).length - new Blob([newPassword]).size), '0') - }) as { ciphertext: string; iv: string; tag: string }; + // const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ + // text: privateKey, + // secret: newPassword + // .slice(0, 32) + // .padStart(32 + (newPassword.slice(0, 32).length - new Blob([newPassword]).size), '0') + // }) as { ciphertext: string; iv: string; tag: string }; client.init( { @@ -79,13 +82,51 @@ export default function PasswordReset() { }, async () => { client.createVerifier(async (err: any, result: { salt: string; verifier: string }) => { - const response = await resetPasswordOnAccountRecovery({ - verificationToken, - encryptedPrivateKey: ciphertext, - iv, - tag, + const derivedKey = await deriveArgonKey({ + password: newPassword, salt: result.salt, - verifier: result.verifier + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); + + if (!derivedKey) throw new Error('Failed to derive key from password'); + + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = Aes256Gcm.encrypt({ + text: privateKey, + secret: key + }); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = Aes256Gcm.encrypt({ + text: key.toString('hex'), + secret: Buffer.from(derivedKey.hash) + }); + + const response = await resetPasswordOnAccountRecovery({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt: result.salt, + verifier: result.verifier, + verificationToken }); // if everything works, go the main dashboard page. diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index ce8db9afe..6077b017e 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -1,5 +1,7 @@ /* eslint-disable no-nested-ternary */ /* eslint-disable @typescript-eslint/no-unused-vars */ +import crypto from 'crypto'; + import { useState } from 'react'; import Head from 'next/head'; import Image from 'next/image'; @@ -17,6 +19,7 @@ import InputField from '@app/components/basic/InputField'; import attemptLogin from '@app/components/utilities/attemptLogin'; import passwordCheck from '@app/components/utilities/checks/PasswordCheck'; import Aes256Gcm from '@app/components/utilities/cryptography/aes-256-gcm'; +import { deriveArgonKey } from '@app/components/utilities/cryptography/crypto'; import issueBackupKey from '@app/components/utilities/cryptography/issueBackupKey'; import completeAccountInformationSignupInvite from './api/auth/CompleteAccountInformationSignupInvite'; @@ -75,17 +78,17 @@ export default function SignupInvite() { const pair = nacl.box.keyPair(); const secretKeyUint8Array = pair.secretKey; const publicKeyUint8Array = pair.publicKey; - const PRIVATE_KEY = encodeBase64(secretKeyUint8Array); - const PUBLIC_KEY = encodeBase64(publicKeyUint8Array); + const privateKey = encodeBase64(secretKeyUint8Array); + const publicKey = encodeBase64(publicKeyUint8Array); - const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ - text: PRIVATE_KEY, - secret: password - .slice(0, 32) - .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') - }); + // const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ + // text: PRIVATE_KEY, + // secret: password + // .slice(0, 32) + // .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), '0') + // }); - localStorage.setItem('PRIVATE_KEY', PRIVATE_KEY); + localStorage.setItem('PRIVATE_KEY', privateKey); client.init( { @@ -94,35 +97,73 @@ export default function SignupInvite() { }, async () => { client.createVerifier(async (err, result) => { - let response = await completeAccountInformationSignupInvite({ - email, - firstName, - lastName, - publicKey: PUBLIC_KEY, - ciphertext, - iv, - tag, - salt: result.salt, - verifier: result.verifier, - token: verificationToken - }); + try { + const derivedKey = await deriveArgonKey({ + password, + salt: result.salt, + mem: 65536, + time: 3, + parallelism: 1, + hashLen: 32 + }); - // if everything works, go the main dashboard page. - if (!errorCheck && response.status === 200) { - response = await response.json(); + if (!derivedKey) throw new Error('Failed to derive key from password'); - localStorage.setItem('publicKey', PUBLIC_KEY); - localStorage.setItem('encryptedPrivateKey', ciphertext); - localStorage.setItem('iv', iv); - localStorage.setItem('tag', tag); + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = Aes256Gcm.encrypt({ + text: privateKey, + secret: key + }); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = Aes256Gcm.encrypt({ + text: key.toString('hex'), + secret: Buffer.from(derivedKey.hash) + }); + + let response = await completeAccountInformationSignupInvite({ + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt: result.salt, + verifier: result.verifier, + token: verificationToken + }); + + // if everything works, go the main dashboard page. + if (!errorCheck && response.status === 200) { + response = await response.json(); + + localStorage.setItem('publicKey', publicKey); + localStorage.setItem('encryptedPrivateKey', encryptedPrivateKey); + localStorage.setItem('iv', encryptedPrivateKeyIV); + localStorage.setItem('tag', encryptedPrivateKeyTag); - try { await attemptLogin(email, password, setErrorLogin, router, false, false); setStep(3); - } catch (error) { - setIsLoading(false); - console.log('Error', error); } + } catch (error) { + setIsLoading(false); + console.error(error); } }); }