From dc78618dd9b2f84769c105d6b968dbbe12ae2ef9 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 8 Dec 2025 20:35:54 +0100 Subject: [PATCH] Refactor SRP logic to shared auth service (#1404) --- .../entrypoints/popup/pages/auth/Login.tsx | 33 +- .../entrypoints/popup/pages/auth/Unlock.tsx | 15 +- .../src/entrypoints/popup/utils/SrpUtility.ts | 53 ++- .../src/utils/auth/SrpAuthService.ts | 403 ++++++++++++++++++ 4 files changed, 454 insertions(+), 50 deletions(-) create mode 100644 apps/browser-extension/src/utils/auth/SrpAuthService.ts diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx index afe748458..338cad209 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx @@ -1,5 +1,3 @@ -import { Buffer } from 'buffer'; - import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -14,13 +12,12 @@ import { useDb } from '@/entrypoints/popup/context/DbContext'; import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext'; import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; import { useWebApi } from '@/entrypoints/popup/context/WebApiContext'; -import ConversionUtility from '@/entrypoints/popup/utils/ConversionUtility'; import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility'; import SrpUtility from '@/entrypoints/popup/utils/SrpUtility'; import { AppInfo } from '@/utils/AppInfo'; +import { SrpAuthService } from '@/utils/auth/SrpAuthService'; import type { VaultResponse, LoginResponse } from '@/utils/dist/shared/models/webapi'; -import EncryptionUtility from '@/utils/EncryptionUtility'; import { ApiAuthError } from '@/utils/types/errors/ApiAuthError'; import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult'; @@ -152,32 +149,27 @@ const Login: React.FC = () => { // Clear global message if set with every login attempt. app.clearGlobalMessage(); - // Use the srpUtil instance instead of the imported singleton - const loginResponse = await srpUtil.initiateLogin(ConversionUtility.normalizeUsername(credentials.username)); + // Initiate login with server + const normalizedUsername = SrpAuthService.normalizeUsername(credentials.username); + const loginResponse = await srpUtil.initiateLogin(normalizedUsername); - // 1. Derive key from password using Argon2id - const passwordHash = await EncryptionUtility.deriveKeyFromPassword( + // Derive key from password using Argon2id and prepare credentials + const { passwordHashString, passwordHashBase64 } = await SrpAuthService.prepareCredentials( credentials.password, loginResponse.salt, loginResponse.encryptionType, loginResponse.encryptionSettings ); - // Convert uint8 array to uppercase hex string which is expected by the server. - const passwordHashString = Buffer.from(passwordHash).toString('hex').toUpperCase(); - - // Get the derived key as base64 string required for decryption. - const passwordHashBase64 = Buffer.from(passwordHash).toString('base64'); - - // 2. Validate login with SRP protocol + // Validate login with SRP protocol const validationResponse = await srpUtil.validateLogin( - ConversionUtility.normalizeUsername(credentials.username), + normalizedUsername, passwordHashString, rememberMe, loginResponse ); - // 3. Handle 2FA if required + // Handle 2FA if required if (validationResponse.requiresTwoFactor) { // Store login response as we need it for 2FA validation setLoginResponse(loginResponse); @@ -198,7 +190,7 @@ const Login: React.FC = () => { // Handle successful authentication await handleSuccessfulAuth( - ConversionUtility.normalizeUsername(credentials.username), + normalizedUsername, validationResponse.token.token, validationResponse.token.refreshToken, passwordHashBase64, @@ -235,8 +227,9 @@ const Login: React.FC = () => { throw new Error(t('auth.errors.invalidCode')); } + const twoFaUsername = SrpAuthService.normalizeUsername(credentials.username); const validationResponse = await srpUtil.validateLogin2Fa( - ConversionUtility.normalizeUsername(credentials.username), + twoFaUsername, passwordHashString, rememberMe, loginResponse, @@ -250,7 +243,7 @@ const Login: React.FC = () => { // Handle successful authentication await handleSuccessfulAuth( - ConversionUtility.normalizeUsername(credentials.username), + twoFaUsername, validationResponse.token.token, validationResponse.token.refreshToken, passwordHashBase64, diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx index 8ebe5c0f9..5d9cecd17 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx @@ -1,5 +1,3 @@ -import { Buffer } from 'buffer'; - import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -20,9 +18,9 @@ import { useWebApi } from '@/entrypoints/popup/context/WebApiContext'; import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility'; import SrpUtility from '@/entrypoints/popup/utils/SrpUtility'; +import { SrpAuthService } from '@/utils/auth/SrpAuthService'; import { VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants'; import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata'; -import EncryptionUtility from '@/utils/EncryptionUtility'; import { getPinLength, isPinEnabled, @@ -222,15 +220,13 @@ const Unlock: React.FC = () => { const loginResponse = await srpUtil.initiateLogin(authContext.username!); // Derive key from password using user's encryption settings - const passwordHash = await EncryptionUtility.deriveKeyFromPassword( + const credentials = await SrpAuthService.prepareCredentials( password, loginResponse.salt, loginResponse.encryptionType, loginResponse.encryptionSettings ); - - // Get the derived key as base64 string required for decryption. - passwordHashBase64 = Buffer.from(passwordHash).toString('base64'); + passwordHashBase64 = credentials.passwordHashBase64; // Store encryption params for future offline unlock await dbContext.storeEncryptionKeyDerivationParams({ @@ -250,14 +246,13 @@ const Unlock: React.FC = () => { } // Derive key from password using stored encryption settings - const passwordHash = await EncryptionUtility.deriveKeyFromPassword( + const credentials = await SrpAuthService.prepareCredentials( password, storedParams.salt, storedParams.encryptionType, storedParams.encryptionSettings ); - - passwordHashBase64 = Buffer.from(passwordHash).toString('base64'); + passwordHashBase64 = credentials.passwordHashBase64; // Set offline mode await dbContext.setIsOffline(true); diff --git a/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts b/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts index 5db814ff0..bff675451 100644 --- a/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts +++ b/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts @@ -1,11 +1,13 @@ -import srp from 'secure-remote-password/client' - -import type { LoginRequest, LoginResponse, ValidateLoginRequest, ValidateLoginRequest2Fa, ValidateLoginResponse, BadRequestResponse } from '@/utils/dist/shared/models/webapi'; +import type { LoginResponse, ValidateLoginResponse, ValidateLoginRequest, ValidateLoginRequest2Fa, BadRequestResponse } from '@/utils/dist/shared/models/webapi'; import { ApiAuthError } from '@/utils/types/errors/ApiAuthError'; import { WebApiService } from '@/utils/WebApiService'; +import { SrpAuthService } from '@/utils/auth/SrpAuthService'; /** * Utility class for SRP authentication operations. + * + * This class wraps the SrpAuthService to provide WebApiService-aware + * authentication methods for the browser extension popup. */ class SrpUtility { private readonly webApiService: WebApiService; @@ -13,7 +15,7 @@ class SrpUtility { /** * Constructor for the SrpUtility class. * - * @param {WebApiService} webApiService - The WebApiService instance. + * @param webApiService - The WebApiService instance. */ public constructor(webApiService: WebApiService) { this.webApiService = webApiService; @@ -23,16 +25,14 @@ class SrpUtility { * Initiate login with server. */ public async initiateLogin(username: string): Promise { - const model: LoginRequest = { - username: username.toLowerCase().trim(), - }; + const normalizedUsername = SrpAuthService.normalizeUsername(username); const response = await this.webApiService.rawFetch('Auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(model), + body: JSON.stringify({ username: normalizedUsername }), }); // Check if response is a bad request (400) @@ -55,26 +55,32 @@ class SrpUtility { rememberMe: boolean, loginResponse: LoginResponse ): Promise { + const normalizedUsername = SrpAuthService.normalizeUsername(username); + // Generate client ephemeral - const clientEphemeral = srp.generateEphemeral() + const clientEphemeral = SrpAuthService.generateEphemeral(); // Derive private key - const privateKey = srp.derivePrivateKey(loginResponse.salt, username, passwordHashString); + const privateKey = SrpAuthService.derivePrivateKey( + loginResponse.salt, + normalizedUsername, + passwordHashString + ); // Derive session - const sessionProof = srp.deriveSession( + const session = SrpAuthService.deriveSession( clientEphemeral.secret, loginResponse.serverEphemeral, loginResponse.salt, - username, + normalizedUsername, privateKey ); const model: ValidateLoginRequest = { - username: username.toLowerCase().trim(), + username: normalizedUsername, rememberMe: rememberMe, clientPublicEphemeral: clientEphemeral.public, - clientSessionProof: sessionProof.proof, + clientSessionProof: session.proof, }; const response = await this.webApiService.rawFetch('Auth/validate', { @@ -106,25 +112,32 @@ class SrpUtility { loginResponse: LoginResponse, code2Fa: number ): Promise { + const normalizedUsername = SrpAuthService.normalizeUsername(username); + // Generate client ephemeral - const clientEphemeral = srp.generateEphemeral() + const clientEphemeral = SrpAuthService.generateEphemeral(); // Derive private key - const privateKey = srp.derivePrivateKey(loginResponse.salt, username, passwordHashString); + const privateKey = SrpAuthService.derivePrivateKey( + loginResponse.salt, + normalizedUsername, + passwordHashString + ); // Derive session - const sessionProof = srp.deriveSession( + const session = SrpAuthService.deriveSession( clientEphemeral.secret, loginResponse.serverEphemeral, loginResponse.salt, - username, + normalizedUsername, privateKey ); + const model: ValidateLoginRequest2Fa = { - username: username.toLowerCase().trim(), + username: normalizedUsername, rememberMe, clientPublicEphemeral: clientEphemeral.public, - clientSessionProof: sessionProof.proof, + clientSessionProof: session.proof, code2Fa, }; diff --git a/apps/browser-extension/src/utils/auth/SrpAuthService.ts b/apps/browser-extension/src/utils/auth/SrpAuthService.ts new file mode 100644 index 000000000..1a39ffc14 --- /dev/null +++ b/apps/browser-extension/src/utils/auth/SrpAuthService.ts @@ -0,0 +1,403 @@ +import srp from 'secure-remote-password/client'; + +import type { TokenModel, LoginResponse, BadRequestResponse } from '@/utils/dist/shared/models/webapi'; + +import { EncryptionUtility } from '../EncryptionUtility'; + +/** + * Register request type for creating a new user. + */ +export type RegisterRequest = { + username: string; + salt: string; + verifier: string; + encryptionType: string; + encryptionSettings: string; +}; + +/** + * Registration result type. + */ +export type RegistrationResult = { + success: boolean; + token?: TokenModel; + error?: string; +}; + +/** + * Login credentials prepared from password derivation. + */ +export type PreparedCredentials = { + /** Password hash as uppercase hex string for SRP */ + passwordHashString: string; + /** Password hash as base64 string for encryption/decryption */ + passwordHashBase64: string; +}; + +/** + * Default encryption settings for Argon2Id. + * These match the server defaults in AliasVault.Cryptography.Client/Defaults.cs + */ +export const DEFAULT_ENCRYPTION = { + type: 'Argon2Id', + settings: JSON.stringify({ + DegreeOfParallelism: 1, + MemorySize: 19456, + Iterations: 2, + }), +} as const; + +/** + * SrpAuthService provides SRP-based authentication utilities. + * + * This service handles: + * - User registration with SRP protocol + * - Password hashing and key derivation + * - SRP verifier generation + * + * It is designed to be used by both the browser extension UI and E2E tests. + */ +export class SrpAuthService { + /** + * Normalizes a username by converting to lowercase and trimming whitespace. + * + * @param username - The username to normalize + * @returns The normalized username + */ + public static normalizeUsername(username: string): string { + return username.toLowerCase().trim(); + } + + /** + * Generates a cryptographically secure SRP salt. + * + * @returns A random salt string + */ + public static generateSalt(): string { + return srp.generateSalt(); + } + + /** + * Converts a Uint8Array to an uppercase hex string. + * + * @param bytes - The byte array to convert + * @returns Uppercase hex string + */ + public static bytesToHexString(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .toUpperCase(); + } + + /** + * Converts a Uint8Array to a base64 string. + * + * @param bytes - The byte array to convert + * @returns Base64 string + */ + public static bytesToBase64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); + } + + /** + * Derives an SRP private key from credentials. + * + * @param salt - The SRP salt + * @param username - The normalized username + * @param passwordHashString - The password hash as uppercase hex string + * @returns The SRP private key + */ + public static derivePrivateKey( + salt: string, + username: string, + passwordHashString: string + ): string { + return srp.derivePrivateKey(salt, SrpAuthService.normalizeUsername(username), passwordHashString); + } + + /** + * Derives an SRP verifier from a private key. + * + * @param privateKey - The SRP private key + * @returns The SRP verifier + */ + public static deriveVerifier(privateKey: string): string { + return srp.deriveVerifier(privateKey); + } + + /** + * Generates an SRP ephemeral key pair for client-side authentication. + * + * @returns Object containing public and secret ephemeral values + */ + public static generateEphemeral(): { public: string; secret: string } { + return srp.generateEphemeral(); + } + + /** + * Derives an SRP session from the authentication exchange. + * + * @param clientSecretEphemeral - Client's secret ephemeral value + * @param serverPublicEphemeral - Server's public ephemeral value + * @param salt - The SRP salt + * @param username - The normalized username + * @param privateKey - The SRP private key + * @returns The SRP session containing proof and key + */ + public static deriveSession( + clientSecretEphemeral: string, + serverPublicEphemeral: string, + salt: string, + username: string, + privateKey: string + ): { proof: string; key: string } { + return srp.deriveSession( + clientSecretEphemeral, + serverPublicEphemeral, + salt, + SrpAuthService.normalizeUsername(username), + privateKey + ); + } + + /** + * Prepares login credentials by deriving the password hash. + * + * This method derives the encryption key from the password using the + * encryption parameters from the login initiate response. + * + * @param password - The user's password + * @param salt - The salt from login initiate response + * @param encryptionType - The encryption type (e.g., 'Argon2Id') + * @param encryptionSettings - The encryption settings JSON string + * @returns Prepared credentials with hash in both hex and base64 formats + */ + public static async prepareCredentials( + password: string, + salt: string, + encryptionType: string, + encryptionSettings: string + ): Promise { + // Derive key from password using Argon2Id + const passwordHash = await EncryptionUtility.deriveKeyFromPassword( + password, + salt, + encryptionType, + encryptionSettings + ); + + return { + passwordHashString: SrpAuthService.bytesToHexString(passwordHash), + passwordHashBase64: SrpAuthService.bytesToBase64(passwordHash), + }; + } + + /** + * Prepares SRP registration data for a new user. + * + * This generates all the cryptographic values needed to register a user: + * - Salt for key derivation + * - Verifier for SRP authentication + * + * @param username - The username for registration + * @param password - The password for registration + * @returns Registration request data ready to send to the API + */ + public static async prepareRegistration( + username: string, + password: string + ): Promise { + const normalizedUsername = SrpAuthService.normalizeUsername(username); + const salt = SrpAuthService.generateSalt(); + + // Derive key from password using default Argon2Id settings + const credentials = await SrpAuthService.prepareCredentials( + password, + salt, + DEFAULT_ENCRYPTION.type, + DEFAULT_ENCRYPTION.settings + ); + + // Generate SRP private key and verifier + const privateKey = SrpAuthService.derivePrivateKey(salt, normalizedUsername, credentials.passwordHashString); + const verifier = SrpAuthService.deriveVerifier(privateKey); + + return { + username: normalizedUsername, + salt, + verifier, + encryptionType: DEFAULT_ENCRYPTION.type, + encryptionSettings: DEFAULT_ENCRYPTION.settings, + }; + } + + /** + * Registers a new user via the API. + * + * @param apiBaseUrl - The base URL of the API (e.g., 'http://localhost:5092') + * @param username - The username for the new account + * @param password - The password for the new account + * @returns Registration result with token on success + */ + public static async registerUser( + apiBaseUrl: string, + username: string, + password: string + ): Promise { + try { + // Prepare registration data + const registerRequest = await SrpAuthService.prepareRegistration(username, password); + + // Normalize the API URL + const baseUrl = apiBaseUrl.replace(/\/$/, '') + '/v1/'; + + // Send registration request to API + const response = await fetch(`${baseUrl}Auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(registerRequest), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `Registration failed with status ${response.status}`; + try { + const errorJson = JSON.parse(errorText) as BadRequestResponse; + errorMessage = errorJson.title || errorMessage; + } catch { + errorMessage = errorText || errorMessage; + } + return { success: false, error: errorMessage }; + } + + const tokenModel = (await response.json()) as TokenModel; + return { success: true, token: tokenModel }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Performs the full SRP login flow. + * + * @param apiBaseUrl - The base URL of the API + * @param username - The username + * @param password - The password + * @param rememberMe - Whether to request extended token lifetime + * @returns Login result with tokens and encryption key + */ + public static async login( + apiBaseUrl: string, + username: string, + password: string, + rememberMe: boolean = false + ): Promise<{ + success: boolean; + token?: TokenModel; + passwordHashBase64?: string; + loginResponse?: LoginResponse; + requiresTwoFactor?: boolean; + error?: string; + }> { + try { + const baseUrl = apiBaseUrl.replace(/\/$/, '') + '/v1/'; + const normalizedUsername = SrpAuthService.normalizeUsername(username); + + // Step 1: Initiate login + const initiateResponse = await fetch(`${baseUrl}Auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: normalizedUsername }), + }); + + if (!initiateResponse.ok) { + const errorText = await initiateResponse.text(); + try { + const errorJson = JSON.parse(errorText) as BadRequestResponse; + return { success: false, error: errorJson.title }; + } catch { + return { success: false, error: errorText || 'Login initiation failed' }; + } + } + + const loginResponse = (await initiateResponse.json()) as LoginResponse; + + // Step 2: Prepare credentials + const credentials = await SrpAuthService.prepareCredentials( + password, + loginResponse.salt, + loginResponse.encryptionType, + loginResponse.encryptionSettings + ); + + // Step 3: Generate SRP session + const clientEphemeral = SrpAuthService.generateEphemeral(); + const privateKey = SrpAuthService.derivePrivateKey( + loginResponse.salt, + normalizedUsername, + credentials.passwordHashString + ); + const session = SrpAuthService.deriveSession( + clientEphemeral.secret, + loginResponse.serverEphemeral, + loginResponse.salt, + normalizedUsername, + privateKey + ); + + // Step 4: Validate login + const validateResponse = await fetch(`${baseUrl}Auth/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: normalizedUsername, + rememberMe, + clientPublicEphemeral: clientEphemeral.public, + clientSessionProof: session.proof, + }), + }); + + if (!validateResponse.ok) { + const errorText = await validateResponse.text(); + try { + const errorJson = JSON.parse(errorText) as BadRequestResponse; + return { success: false, error: errorJson.title }; + } catch { + return { success: false, error: errorText || 'Login validation failed' }; + } + } + + const validateResult = await validateResponse.json(); + + // Check for 2FA requirement + if (validateResult.requiresTwoFactor) { + return { + success: false, + requiresTwoFactor: true, + loginResponse, + passwordHashBase64: credentials.passwordHashBase64, + }; + } + + return { + success: true, + token: validateResult.token, + passwordHashBase64: credentials.passwordHashBase64, + loginResponse, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } +} + +export default SrpAuthService;