Refactor SRP logic to shared auth service (#1404)

This commit is contained in:
Leendert de Borst
2025-12-08 20:35:54 +01:00
parent a9be9791e7
commit dc78618dd9
4 changed files with 454 additions and 50 deletions

View File

@@ -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,

View File

@@ -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);

View File

@@ -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<LoginResponse> {
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<ValidateLoginResponse> {
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<ValidateLoginResponse> {
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,
};

View File

@@ -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<PreparedCredentials> {
// 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<RegisterRequest> {
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<RegistrationResult> {
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;