diff --git a/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts b/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts index 7217ae998..d0ed55cf8 100644 --- a/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts +++ b/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts @@ -64,17 +64,17 @@ class SrpUtility { const srpIdentity = loginResponse.srpIdentity ?? normalizedUsername; // Generate client ephemeral - const clientEphemeral = SrpAuthService.generateEphemeral(); + const clientEphemeral = await SrpAuthService.generateEphemeral(); // Derive private key using srpIdentity (not the typed username) - const privateKey = SrpAuthService.derivePrivateKey( + const privateKey = await SrpAuthService.derivePrivateKey( loginResponse.salt, srpIdentity, passwordHashString ); // Derive session using srpIdentity (not the typed username) - const session = SrpAuthService.deriveSession( + const session = await SrpAuthService.deriveSession( clientEphemeral.secret, loginResponse.serverEphemeral, loginResponse.salt, @@ -127,17 +127,17 @@ class SrpUtility { const srpIdentity = loginResponse.srpIdentity ?? normalizedUsername; // Generate client ephemeral - const clientEphemeral = SrpAuthService.generateEphemeral(); + const clientEphemeral = await SrpAuthService.generateEphemeral(); // Derive private key using srpIdentity (not the typed username) - const privateKey = SrpAuthService.derivePrivateKey( + const privateKey = await SrpAuthService.derivePrivateKey( loginResponse.salt, srpIdentity, passwordHashString ); // Derive session using srpIdentity (not the typed username) - const session = SrpAuthService.deriveSession( + const session = await SrpAuthService.deriveSession( clientEphemeral.secret, loginResponse.serverEphemeral, loginResponse.salt, diff --git a/apps/browser-extension/src/utils/auth/SrpAuthService.ts b/apps/browser-extension/src/utils/auth/SrpAuthService.ts index d5ee0f83b..abafc26ed 100644 --- a/apps/browser-extension/src/utils/auth/SrpAuthService.ts +++ b/apps/browser-extension/src/utils/auth/SrpAuthService.ts @@ -1,4 +1,12 @@ -import srp from 'secure-remote-password/client'; +import { browser } from 'wxt/browser'; + +import initWasm, { + srpGenerateSalt, + srpDerivePrivateKey, + srpDeriveVerifier, + srpGenerateEphemeral, + srpDeriveSession, +} from '../dist/core/rust/aliasvault_core.js'; import type { TokenModel, LoginResponse, BadRequestResponse } from '@/utils/dist/core/models/webapi'; @@ -50,16 +58,72 @@ export const DEFAULT_ENCRYPTION = { } as const; /** - * SrpAuthService provides SRP-based authentication utilities. + * SRP ephemeral key pair type. + */ +type SrpEphemeral = { + public: string; + secret: string; +}; + +/** + * SRP session type. + */ +type SrpSession = { + proof: string; + key: string; +}; + +/** + * SrpAuthService provides SRP-based authentication utilities using Rust WASM. * * 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. + * It uses the Rust core library compiled to WASM for cross-platform consistency. + * The WASM module must be initialized before use (handled automatically). */ export class SrpAuthService { + private static wasmInitialized = false; + private static wasmInitPromise: Promise | null = null; + + /** + * Initialize the Rust WASM module. + * Called automatically by methods that require WASM. + * Safe to call multiple times - only initializes once. + */ + private static async initWasm(): Promise { + if (this.wasmInitialized) { + return; + } + + // Ensure we only initialize once even with concurrent calls + if (this.wasmInitPromise) { + return this.wasmInitPromise; + } + + this.wasmInitPromise = (async (): Promise => { + try { + /* + * Fetch WASM bytes using browser.runtime.getURL for correct extension path. + * Cast to string to bypass WXT's strict PublicPath typing. + */ + const wasmUrl = (browser.runtime.getURL as (path: string) => string)('src/aliasvault_core_bg.wasm'); + const wasmResponse = await fetch(wasmUrl); + const wasmBytes = await wasmResponse.arrayBuffer(); + // Pass as object to avoid deprecation warning from wasm-bindgen + await initWasm({ module_or_path: wasmBytes }); + this.wasmInitialized = true; + } catch (error) { + this.wasmInitPromise = null; + throw error; + } + })(); + + return this.wasmInitPromise; + } + /** * Normalizes a username by converting to lowercase and trimming whitespace. * @@ -71,12 +135,13 @@ export class SrpAuthService { } /** - * Generates a cryptographically secure SRP salt. + * Generates a cryptographically secure SRP salt using Rust WASM. * - * @returns A random salt string + * @returns A random salt string (uppercase hex) */ - public static generateSalt(): string { - return srp.generateSalt(); + public static async generateSalt(): Promise { + await this.initWasm(); + return srpGenerateSalt(); } /** @@ -103,64 +168,68 @@ export class SrpAuthService { } /** - * Derives an SRP private key from credentials. + * Derives an SRP private key from credentials using Rust WASM. * * @param salt - The SRP salt - * @param username - The normalized username + * @param username - The normalized username or SRP identity * @param passwordHashString - The password hash as uppercase hex string - * @returns The SRP private key + * @returns The SRP private key (uppercase hex) */ - public static derivePrivateKey( + public static async derivePrivateKey( salt: string, username: string, passwordHashString: string - ): string { - return srp.derivePrivateKey(salt, SrpAuthService.normalizeUsername(username), passwordHashString); + ): Promise { + await this.initWasm(); + return srpDerivePrivateKey(salt, SrpAuthService.normalizeUsername(username), passwordHashString); } /** - * Derives an SRP verifier from a private key. + * Derives an SRP verifier from a private key using Rust WASM. * * @param privateKey - The SRP private key - * @returns The SRP verifier + * @returns The SRP verifier (uppercase hex) */ - public static deriveVerifier(privateKey: string): string { - return srp.deriveVerifier(privateKey); + public static async deriveVerifier(privateKey: string): Promise { + await this.initWasm(); + return srpDeriveVerifier(privateKey); } /** - * Generates an SRP ephemeral key pair for client-side authentication. + * Generates an SRP ephemeral key pair for client-side authentication using Rust WASM. * - * @returns Object containing public and secret ephemeral values + * @returns Object containing public and secret ephemeral values (uppercase hex) */ - public static generateEphemeral(): { public: string; secret: string } { - return srp.generateEphemeral(); + public static async generateEphemeral(): Promise { + await this.initWasm(); + return srpGenerateEphemeral() as SrpEphemeral; } /** - * Derives an SRP session from the authentication exchange. + * Derives an SRP session from the authentication exchange using Rust WASM. * * @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 username - The normalized username or SRP identity * @param privateKey - The SRP private key - * @returns The SRP session containing proof and key + * @returns The SRP session containing proof and key (uppercase hex) */ - public static deriveSession( + public static async deriveSession( clientSecretEphemeral: string, serverPublicEphemeral: string, salt: string, username: string, privateKey: string - ): { proof: string; key: string } { - return srp.deriveSession( + ): Promise { + await this.initWasm(); + return srpDeriveSession( clientSecretEphemeral, serverPublicEphemeral, salt, SrpAuthService.normalizeUsername(username), privateKey - ); + ) as SrpSession; } /** @@ -221,7 +290,7 @@ export class SrpAuthService { password: string ): Promise { const normalizedUsername = SrpAuthService.normalizeUsername(username); - const salt = SrpAuthService.generateSalt(); + const salt = await SrpAuthService.generateSalt(); /** * Generate a random GUID for SRP identity. This is used for all SRP operations, @@ -238,8 +307,8 @@ export class SrpAuthService { ); // Generate SRP private key and verifier using srpIdentity (not username) - const privateKey = SrpAuthService.derivePrivateKey(salt, srpIdentity, credentials.passwordHashString); - const verifier = SrpAuthService.deriveVerifier(privateKey); + const privateKey = await SrpAuthService.derivePrivateKey(salt, srpIdentity, credentials.passwordHashString); + const verifier = await SrpAuthService.deriveVerifier(privateKey); return { username: normalizedUsername, @@ -362,13 +431,13 @@ export class SrpAuthService { ); // Step 3: Generate SRP session using srpIdentity (not the typed username) - const clientEphemeral = SrpAuthService.generateEphemeral(); - const privateKey = SrpAuthService.derivePrivateKey( + const clientEphemeral = await SrpAuthService.generateEphemeral(); + const privateKey = await SrpAuthService.derivePrivateKey( loginResponse.salt, srpIdentity, credentials.passwordHashString ); - const session = SrpAuthService.deriveSession( + const session = await SrpAuthService.deriveSession( clientEphemeral.secret, loginResponse.serverEphemeral, loginResponse.salt, diff --git a/apps/server/AliasVault.Client/Auth/Pages/Login.razor b/apps/server/AliasVault.Client/Auth/Pages/Login.razor index d977d6d8f..f4356c6d8 100644 --- a/apps/server/AliasVault.Client/Auth/Pages/Login.razor +++ b/apps/server/AliasVault.Client/Auth/Pages/Login.razor @@ -9,8 +9,9 @@ @using AliasVault.Client.Auth.Models @using AliasVault.Client.Utilities @using AliasVault.Cryptography.Client -@using SecureRemotePassword +@using AliasVault.Client.Services.JsInterop.RustCore @using Microsoft.Extensions.Localization +@inject SrpService SrpService @if (_showTwoFactorAuthStep) { @@ -271,9 +272,9 @@ else _passwordHash = await Encryption.DeriveKeyFromPasswordAsync(_loginModel.Password, loginResponse.Salt, loginResponse.EncryptionType, loginResponse.EncryptionSettings); var passwordHashString = BitConverter.ToString(_passwordHash).Replace("-", string.Empty); - _clientEphemeral = Srp.GenerateEphemeralClient(); - var privateKey = Srp.DerivePrivateKey(loginResponse.Salt, srpIdentity, passwordHashString); - _clientSession = Srp.DeriveSessionClient( + _clientEphemeral = await SrpService.GenerateEphemeralAsync(); + var privateKey = await SrpService.DerivePrivateKeyAsync(loginResponse.Salt, srpIdentity, passwordHashString); + _clientSession = await SrpService.DeriveSessionClientAsync( privateKey, _clientEphemeral.Secret, loginResponse.ServerEphemeral, @@ -431,7 +432,7 @@ else private async Task> ProcessLoginVerify(ValidateLoginResponse validateLoginResponse) { // 5. Client verifies proof. - Srp.VerifySession(_clientEphemeral.Public, _clientSession, validateLoginResponse.ServerSessionProof); + await SrpService.VerifySessionAsync(_clientEphemeral.Public, _clientSession, validateLoginResponse.ServerSessionProof); // Store the tokens in local storage. await AuthService.StoreAccessTokenAsync(validateLoginResponse.Token!.Token); diff --git a/apps/server/AliasVault.Client/Auth/Pages/Setup/Components/CreatingStep.razor b/apps/server/AliasVault.Client/Auth/Pages/Setup/Components/CreatingStep.razor index 21d7891a7..0d7488a48 100644 --- a/apps/server/AliasVault.Client/Auth/Pages/Setup/Components/CreatingStep.razor +++ b/apps/server/AliasVault.Client/Auth/Pages/Setup/Components/CreatingStep.razor @@ -5,7 +5,6 @@ @using AliasVault.Client.Utilities @using AliasVault.Cryptography.Client @using AliasVault.Shared.Models.WebApi.Auth -@using SecureRemotePassword
diff --git a/apps/server/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor b/apps/server/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor index 72463daf4..311164a09 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor @@ -4,10 +4,11 @@ @using AliasVault.Shared.Models.WebApi.PasswordChange @using AliasVault.Shared.Models.WebApi.Vault; @using AliasVault.Cryptography.Client -@using SecureRemotePassword +@using AliasVault.Client.Services.JsInterop.RustCore @using Microsoft.Extensions.Localization @inherits MainBase @inject HttpClient Http +@inject SrpService SrpService @Localizer["PageTitle"] @@ -101,6 +102,7 @@ else private SrpEphemeral ClientEphemeral = new(); private SrpSession ClientSession = new(); + private string PrivateKey = string.Empty; /// protected override async Task OnInitializedAsync() @@ -163,24 +165,23 @@ else var currentPasswordHash = await Encryption.DeriveKeyFromPasswordAsync(PasswordChangeFormModel.CurrentPassword, CurrentSalt, CurrentEncryptionType, CurrentEncryptionSettings); var currentPasswordHashString = BitConverter.ToString(currentPasswordHash).Replace("-", string.Empty); - ClientEphemeral = Srp.GenerateEphemeralClient(); + ClientEphemeral = await SrpService.GenerateEphemeralAsync(); var username = await GetUsernameAsync(); // Use srpIdentity from server response if available, otherwise fall back to username. // Note: the fallback can be removed in the future after 0.26.0+ is deployed. var srpIdentity = CurrentSrpIdentity ?? username.ToLowerInvariant(); - var privateKey = Srp.DerivePrivateKey(CurrentSalt, srpIdentity, currentPasswordHashString); - ClientSession = Srp.DeriveSessionClient( - privateKey, + PrivateKey = await SrpService.DerivePrivateKeyAsync(CurrentSalt, srpIdentity, currentPasswordHashString); + ClientSession = await SrpService.DeriveSessionClientAsync( + PrivateKey, ClientEphemeral.Secret, CurrentServerEphemeral, CurrentSalt, srpIdentity); - // Generate salt and verifier for new password. - var client = new SrpClient(); - var newSalt = client.GenerateSalt(); + // Generate salt and verifier for new password using Rust WASM. + var newSalt = await SrpService.GenerateSaltAsync(); byte[] newPasswordHash = await Encryption.DeriveKeyFromPasswordAsync(PasswordChangeFormModel.NewPassword, newSalt); var newPasswordHashString = BitConverter.ToString(newPasswordHash).Replace("-", string.Empty); @@ -193,7 +194,7 @@ else await AuthService.StoreEncryptionKeyAsync(newPasswordHash); // Use srpIdentity for generating the new verifier to maintain consistency. - var srpPasswordChange = Srp.PasswordChangeAsync(client, newSalt, srpIdentity, newPasswordHashString); + var (srpSalt, srpVerifier) = await SrpService.PreparePasswordChangeAsync(srpIdentity, newPasswordHashString); // Prepare new vault model to update to. var encryptedBase64String = await DbService.GetEncryptedDatabaseBase64String(); @@ -215,8 +216,8 @@ else UpdatedAt = vault.UpdatedAt, CurrentClientPublicEphemeral = ClientEphemeral.Public, CurrentClientSessionProof = ClientSession.Proof, - NewPasswordSalt = srpPasswordChange.Salt, - NewPasswordVerifier = srpPasswordChange.Verifier + NewPasswordSalt = srpSalt, + NewPasswordVerifier = srpVerifier }; // Clear form. diff --git a/apps/server/AliasVault.Client/Main/Pages/Settings/Security/DeleteAccount.razor b/apps/server/AliasVault.Client/Main/Pages/Settings/Security/DeleteAccount.razor index 6372a8a40..ed2c1f356 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Settings/Security/DeleteAccount.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/Security/DeleteAccount.razor @@ -4,11 +4,12 @@ @using AliasVault.Client.Utilities @using AliasVault.Shared.Models.WebApi.Auth @using AliasVault.Cryptography.Client -@using SecureRemotePassword +@using AliasVault.Client.Services.JsInterop.RustCore @using System.ComponentModel.DataAnnotations @using Microsoft.Extensions.Localization @using AliasVault.Client.Resources @inject HttpClient Http +@inject SrpService SrpService @Localizer["PageTitle"] @@ -99,12 +100,17 @@ /// /// The ephemeral client for SRP. /// - private SrpEphemeral ClientEphemeral { get; set; } = null!; + private SrpEphemeral ClientEphemeral { get; set; } = new(); /// /// The session client for SRP. /// - private SrpSession ClientSession { get; set; } = null!; + private SrpSession ClientSession { get; set; } = new(); + + /// + /// The SRP private key. + /// + private string PrivateKey { get; set; } = string.Empty; /// protected override async Task OnInitializedAsync() @@ -179,10 +185,10 @@ var passwordHash = await Encryption.DeriveKeyFromPasswordAsync(_passwordModel.Password, loginResponse.Salt, loginResponse.EncryptionType, loginResponse.EncryptionSettings); var passwordHashString = BitConverter.ToString(passwordHash).Replace("-", string.Empty); - ClientEphemeral = Srp.GenerateEphemeralClient(); - var privateKey = Srp.DerivePrivateKey(loginResponse.Salt, srpIdentity, passwordHashString); - ClientSession = Srp.DeriveSessionClient( - privateKey, + ClientEphemeral = await SrpService.GenerateEphemeralAsync(); + PrivateKey = await SrpService.DerivePrivateKeyAsync(loginResponse.Salt, srpIdentity, passwordHashString); + ClientSession = await SrpService.DeriveSessionClientAsync( + PrivateKey, ClientEphemeral.Secret, loginResponse.ServerEphemeral, loginResponse.Salt, diff --git a/apps/server/AliasVault.Client/Program.cs b/apps/server/AliasVault.Client/Program.cs index 79aea0829..8ae18c127 100644 --- a/apps/server/AliasVault.Client/Program.cs +++ b/apps/server/AliasVault.Client/Program.cs @@ -102,6 +102,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddAuthorizationCore(); builder.Services.AddBlazoredLocalStorage(); diff --git a/apps/server/AliasVault.Client/Services/Auth/UserRegistrationService.cs b/apps/server/AliasVault.Client/Services/Auth/UserRegistrationService.cs index 2c851f5a5..0691a9442 100644 --- a/apps/server/AliasVault.Client/Services/Auth/UserRegistrationService.cs +++ b/apps/server/AliasVault.Client/Services/Auth/UserRegistrationService.cs @@ -9,12 +9,12 @@ namespace AliasVault.Client.Services.Auth; using System.Net.Http.Json; using System.Text.Json; +using AliasVault.Client.Services.JsInterop.RustCore; using AliasVault.Client.Utilities; using AliasVault.Cryptography.Client; using AliasVault.Shared.Models.WebApi.Auth; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.Localization; -using SecureRemotePassword; /// /// Service responsible for handling user registration operations. @@ -24,7 +24,8 @@ using SecureRemotePassword; /// The service handling authentication operations. /// The application configuration. /// The string localizer factory for localization. -public class UserRegistrationService(HttpClient httpClient, AuthenticationStateProvider authStateProvider, AuthService authService, Config config, IStringLocalizerFactory localizerFactory) +/// The SRP service for secure authentication. +public class UserRegistrationService(HttpClient httpClient, AuthenticationStateProvider authStateProvider, AuthService authService, Config config, IStringLocalizerFactory localizerFactory, SrpService srpService) { private readonly IStringLocalizer _apiErrorLocalizer = localizerFactory.Create("ApiErrors", "AliasVault.Client"); @@ -38,9 +39,6 @@ public class UserRegistrationService(HttpClient httpClient, AuthenticationStateP { try { - var client = new SrpClient(); - var salt = client.GenerateSalt(); - // Generate a random GUID for SRP identity. This is used for all SRP operations, // is set during registration, and never changes. var srpIdentity = Guid.NewGuid().ToString(); @@ -53,11 +51,16 @@ public class UserRegistrationService(HttpClient httpClient, AuthenticationStateP encryptionSettings = config.CryptographyOverrideSettings; } + // Generate salt using Rust WASM + var salt = await srpService.GenerateSaltAsync(); + var passwordHash = await Encryption.DeriveKeyFromPasswordAsync(password, salt, encryptionType, encryptionSettings); var passwordHashString = BitConverter.ToString(passwordHash).Replace("-", string.Empty); - var srpSignup = Srp.PasswordChangeAsync(client, salt, srpIdentity, passwordHashString); - var registerRequest = new RegisterRequest(username, srpSignup.Salt, srpSignup.Verifier, encryptionType, encryptionSettings, srpIdentity); + // Generate verifier using Rust WASM + var (srpSalt, srpVerifier) = await srpService.PreparePasswordChangeAsync(srpIdentity, passwordHashString); + + var registerRequest = new RegisterRequest(username, srpSalt, srpVerifier, encryptionType, encryptionSettings, srpIdentity); var result = await httpClient.PostAsJsonAsync("v1/Auth/register", registerRequest); var responseContent = await result.Content.ReadAsStringAsync(); diff --git a/apps/server/AliasVault.Client/Services/JsInterop/RustCore/SrpEphemeral.cs b/apps/server/AliasVault.Client/Services/JsInterop/RustCore/SrpEphemeral.cs new file mode 100644 index 000000000..99e801e80 --- /dev/null +++ b/apps/server/AliasVault.Client/Services/JsInterop/RustCore/SrpEphemeral.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Services.JsInterop.RustCore; + +/// +/// SRP Ephemeral keypair with public and secret components. +/// +public class SrpEphemeral +{ + /// + /// Gets or sets the public ephemeral value (uppercase hex string). + /// + public string Public { get; set; } = string.Empty; + + /// + /// Gets or sets the secret ephemeral value (uppercase hex string). + /// + public string Secret { get; set; } = string.Empty; +} diff --git a/apps/server/AliasVault.Client/Services/JsInterop/RustCore/SrpService.cs b/apps/server/AliasVault.Client/Services/JsInterop/RustCore/SrpService.cs new file mode 100644 index 000000000..54c3c0576 --- /dev/null +++ b/apps/server/AliasVault.Client/Services/JsInterop/RustCore/SrpService.cs @@ -0,0 +1,180 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Services.JsInterop.RustCore; + +using Microsoft.JSInterop; + +/// +/// JavaScript interop wrapper for the Rust WASM SRP (Secure Remote Password) library. +/// Provides SRP authentication functionality via WASM. +/// +public class SrpService : IAsyncDisposable +{ + private readonly IJSRuntime jsRuntime; + private readonly RustCoreService rustCoreService; + + /// + /// Initializes a new instance of the class. + /// + /// The JS runtime for interop. + /// The Rust core service for WASM availability checks. + public SrpService(IJSRuntime jsRuntime, RustCoreService rustCoreService) + { + this.jsRuntime = jsRuntime; + this.rustCoreService = rustCoreService; + } + + /// + /// Generate a random salt for SRP registration. + /// + /// 64-character uppercase hex string (32 bytes). + /// Thrown if WASM module is unavailable. + public async Task GenerateSaltAsync() + { + if (!await rustCoreService.WaitForAvailabilityAsync()) + { + throw new InvalidOperationException("Rust WASM module is not available."); + } + + return await jsRuntime.InvokeAsync("rustCoreSrpGenerateSalt"); + } + + /// + /// Derive a private key from salt, identity, and password hash. + /// + /// The salt (hex string). + /// The SRP identity (username or GUID), will be lowercased. + /// The password hash (hex string). + /// 64-character uppercase hex string (32 bytes). + /// Thrown if WASM module is unavailable. + public async Task DerivePrivateKeyAsync(string salt, string identity, string passwordHash) + { + if (!await rustCoreService.WaitForAvailabilityAsync()) + { + throw new InvalidOperationException("Rust WASM module is not available."); + } + + // Make sure the identity is lowercase as the SRP protocol is case sensitive. + identity = identity.ToLowerInvariant(); + + return await jsRuntime.InvokeAsync("rustCoreSrpDerivePrivateKey", salt, identity, passwordHash); + } + + /// + /// Derive a verifier from a private key. + /// + /// The private key (hex string). + /// 512-character uppercase hex string (256 bytes). + /// Thrown if WASM module is unavailable. + public async Task DeriveVerifierAsync(string privateKey) + { + if (!await rustCoreService.WaitForAvailabilityAsync()) + { + throw new InvalidOperationException("Rust WASM module is not available."); + } + + return await jsRuntime.InvokeAsync("rustCoreSrpDeriveVerifier", privateKey); + } + + /// + /// Generate client ephemeral keypair. + /// + /// Ephemeral object with Public and Secret hex strings. + /// Thrown if WASM module is unavailable. + public async Task GenerateEphemeralAsync() + { + if (!await rustCoreService.WaitForAvailabilityAsync()) + { + throw new InvalidOperationException("Rust WASM module is not available."); + } + + return await jsRuntime.InvokeAsync("rustCoreSrpGenerateEphemeral"); + } + + /// + /// Derive client session from ephemeral values. + /// + /// Client ephemeral secret (hex string). + /// Server ephemeral public (hex string). + /// The salt (hex string). + /// The SRP identity, will be lowercased. + /// The private key (hex string). + /// Session object with Key and Proof hex strings. + /// Thrown if WASM module is unavailable. + public async Task DeriveSessionAsync(string clientSecret, string serverPublic, string salt, string identity, string privateKey) + { + if (!await rustCoreService.WaitForAvailabilityAsync()) + { + throw new InvalidOperationException("Rust WASM module is not available."); + } + + // Make sure the identity is lowercase as the SRP protocol is case sensitive. + identity = identity.ToLowerInvariant(); + + return await jsRuntime.InvokeAsync("rustCoreSrpDeriveSession", clientSecret, serverPublic, salt, identity, privateKey); + } + + /// + /// Prepare password change/registration by generating salt and verifier. + /// + /// The SRP identity (username or GUID). + /// The password hash as hex string. + /// Tuple with Salt and Verifier. + public async Task<(string Salt, string Verifier)> PreparePasswordChangeAsync(string identity, string passwordHashString) + { + var salt = await GenerateSaltAsync(); + var privateKey = await DerivePrivateKeyAsync(salt, identity, passwordHashString); + var verifier = await DeriveVerifierAsync(privateKey); + + return (salt, verifier); + } + + /// + /// Derive session client-side (convenience method matching old Srp.DeriveSessionClient signature). + /// + /// The private key. + /// Client ephemeral secret. + /// Server public ephemeral. + /// Salt. + /// Identity. + /// SrpSession. + public async Task DeriveSessionClientAsync(string privateKey, string clientSecretEphemeral, string serverEphemeralPublic, string salt, string identity) + { + return await DeriveSessionAsync(clientSecretEphemeral, serverEphemeralPublic, salt, identity, privateKey); + } + + /// + /// Verify the server's session proof (M2) on the client side. + /// + /// Client public ephemeral (A). + /// Client session containing proof (M1) and key (K). + /// Server proof (M2) to verify. + /// A task representing the asynchronous operation. + /// Thrown if WASM module is unavailable. + /// Thrown if verification fails. + public async Task VerifySessionAsync(string clientPublic, SrpSession clientSession, string serverProof) + { + if (!await rustCoreService.WaitForAvailabilityAsync()) + { + throw new InvalidOperationException("Rust WASM module is not available."); + } + + var result = await jsRuntime.InvokeAsync("rustCoreSrpVerifySession", clientPublic, clientSession.Proof, clientSession.Key, serverProof); + + if (!result) + { + throw new System.Security.SecurityException("Server session proof verification failed."); + } + } + + /// + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } +} diff --git a/apps/server/AliasVault.Client/Services/JsInterop/RustCore/SrpSession.cs b/apps/server/AliasVault.Client/Services/JsInterop/RustCore/SrpSession.cs new file mode 100644 index 000000000..63db99e9a --- /dev/null +++ b/apps/server/AliasVault.Client/Services/JsInterop/RustCore/SrpSession.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Services.JsInterop.RustCore; + +/// +/// SRP Session with key and proof. +/// +public class SrpSession +{ + /// + /// Gets or sets the session key (uppercase hex string). + /// + public string Key { get; set; } = string.Empty; + + /// + /// Gets or sets the session proof (uppercase hex string). + /// + public string Proof { get; set; } = string.Empty; +} diff --git a/apps/server/AliasVault.Client/wwwroot/js/rustCoreInterop.js b/apps/server/AliasVault.Client/wwwroot/js/rustCoreInterop.js index 00fdecb4b..17d39fd3c 100644 --- a/apps/server/AliasVault.Client/wwwroot/js/rustCoreInterop.js +++ b/apps/server/AliasVault.Client/wwwroot/js/rustCoreInterop.js @@ -230,3 +230,130 @@ window.rustCorePruneVault = async function(inputJson) { }); } }; + +// ============================================================================ +// SRP (Secure Remote Password) Functions +// ============================================================================ + +/** + * Generate a random salt for SRP registration. + * @returns {Promise} 64-character uppercase hex string (32 bytes). + */ +window.rustCoreSrpGenerateSalt = async function() { + if (!await initRustCore()) { + throw new Error('Rust WASM module not available'); + } + + try { + return wasmModule.srpGenerateSalt(); + } catch (error) { + console.error('[RustCore] SRP generate salt failed:', error); + throw error; + } +}; + +/** + * Derive a private key from salt, identity, and password hash. + * @param {string} salt - The salt (hex string). + * @param {string} identity - The SRP identity (username or GUID). + * @param {string} passwordHash - The password hash (hex string). + * @returns {Promise} 64-character uppercase hex string (32 bytes). + */ +window.rustCoreSrpDerivePrivateKey = async function(salt, identity, passwordHash) { + if (!await initRustCore()) { + throw new Error('Rust WASM module not available'); + } + + try { + return wasmModule.srpDerivePrivateKey(salt, identity, passwordHash); + } catch (error) { + console.error('[RustCore] SRP derive private key failed:', error); + throw error; + } +}; + +/** + * Derive a verifier from a private key. + * @param {string} privateKey - The private key (hex string). + * @returns {Promise} 512-character uppercase hex string (256 bytes). + */ +window.rustCoreSrpDeriveVerifier = async function(privateKey) { + if (!await initRustCore()) { + throw new Error('Rust WASM module not available'); + } + + try { + return wasmModule.srpDeriveVerifier(privateKey); + } catch (error) { + console.error('[RustCore] SRP derive verifier failed:', error); + throw error; + } +}; + +/** + * Generate client ephemeral keypair. + * @returns {Promise<{public: string, secret: string}>} Object with public and secret hex strings. + */ +window.rustCoreSrpGenerateEphemeral = async function() { + if (!await initRustCore()) { + throw new Error('Rust WASM module not available'); + } + + try { + const result = wasmModule.srpGenerateEphemeral(); + return { + public: result.public, + secret: result.secret + }; + } catch (error) { + console.error('[RustCore] SRP generate ephemeral failed:', error); + throw error; + } +}; + +/** + * Derive client session from ephemeral values. + * @param {string} clientSecret - Client ephemeral secret (hex string). + * @param {string} serverPublic - Server ephemeral public (hex string). + * @param {string} salt - The salt (hex string). + * @param {string} identity - The SRP identity. + * @param {string} privateKey - The private key (hex string). + * @returns {Promise<{key: string, proof: string}>} Object with session key and proof hex strings. + */ +window.rustCoreSrpDeriveSession = async function(clientSecret, serverPublic, salt, identity, privateKey) { + if (!await initRustCore()) { + throw new Error('Rust WASM module not available'); + } + + try { + const result = wasmModule.srpDeriveSession(clientSecret, serverPublic, salt, identity, privateKey); + return { + key: result.key, + proof: result.proof + }; + } catch (error) { + console.error('[RustCore] SRP derive session failed:', error); + throw error; + } +}; + +/** + * Verify the server's session proof (M2) on the client side. + * @param {string} clientPublic - Client public ephemeral (A) as hex string. + * @param {string} clientProof - Client proof (M1) as hex string. + * @param {string} sessionKey - Session key (K) as hex string. + * @param {string} serverProof - Server proof (M2) to verify as hex string. + * @returns {Promise} True if verification succeeds. + */ +window.rustCoreSrpVerifySession = async function(clientPublic, clientProof, sessionKey, serverProof) { + if (!await initRustCore()) { + throw new Error('Rust WASM module not available'); + } + + try { + return wasmModule.srpVerifySession(clientPublic, clientProof, sessionKey, serverProof); + } catch (error) { + console.error('[RustCore] SRP verify session failed:', error); + throw error; + } +}; diff --git a/core/rust/Cargo.lock b/core/rust/Cargo.lock index 2fc5c1a7c..9b23655bb 100644 --- a/core/rust/Cargo.lock +++ b/core/rust/Cargo.lock @@ -8,9 +8,16 @@ version = "0.1.0" dependencies = [ "chrono", "console_error_panic_hook", + "digest", + "getrandom", + "num-bigint", + "rand", "serde", "serde-wasm-bindgen", "serde_json", + "sha2", + "srp", + "subtle", "thiserror", "uniffi", "wasm-bindgen", @@ -137,6 +144,15 @@ dependencies = [ "serde", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -253,6 +269,35 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "fs-err" version = "2.11.0" @@ -262,6 +307,29 @@ dependencies = [ "autocfg", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "glob" version = "0.3.3" @@ -307,6 +375,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + [[package]] name = "log" version = "0.4.29" @@ -351,6 +431,25 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -384,6 +483,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -402,6 +510,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -498,6 +636,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -510,6 +659,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "srp" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cdd40049b29ab1315ccba7ffcf1eece8162982eab14edfac3cf2c2ffab18193" +dependencies = [ + "digest", + "generic-array", + "lazy_static", + "num-bigint", + "subtle", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -522,6 +684,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.111" @@ -571,6 +739,12 @@ dependencies = [ "serde", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicase" version = "2.8.1" @@ -719,6 +893,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.106" @@ -787,3 +973,23 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/core/rust/Cargo.toml b/core/rust/Cargo.toml index 99ef3d90e..cad845d33 100644 --- a/core/rust/Cargo.toml +++ b/core/rust/Cargo.toml @@ -36,6 +36,15 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "now" # Error handling thiserror = "1.0" +# SRP (Secure Remote Password) protocol +srp = "0.6" +sha2 = "0.10" +rand = { version = "0.8", features = ["std", "std_rng"] } +num-bigint = "0.4" +digest = "0.10" +subtle = "2.5" +getrandom = { version = "0.2", features = ["js"] } + # UniFFI for Swift/Kotlin bindings (optional) # Note: Don't add "cli" feature here - it pulls in heavy bindgen dependencies # The uniffi-cli feature enables CLI separately for the uniffi-bindgen binary only diff --git a/core/rust/src/ffi.rs b/core/rust/src/ffi.rs index 2a757090c..174816375 100644 --- a/core/rust/src/ffi.rs +++ b/core/rust/src/ffi.rs @@ -179,6 +179,283 @@ fn create_error_response(message: &str) -> *mut c_char { string_to_c_char(error_json) } +// ═══════════════════════════════════════════════════════════════════════════════ +// SRP (Secure Remote Password) FFI Functions +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Generate a cryptographic salt for SRP. +/// +/// # Safety +/// +/// - The returned pointer must be freed by calling `free_string` +/// +/// # Returns +/// +/// A null-terminated C string containing the salt as uppercase hex. +#[no_mangle] +pub extern "C" fn srp_generate_salt_ffi() -> *mut c_char { + string_to_c_char(crate::srp::srp_generate_salt()) +} + +/// Derive the SRP private key from credentials. +/// +/// # Safety +/// +/// - All input pointers must be valid null-terminated C strings +/// - The returned pointer must be freed by calling `free_string` +/// +/// # Returns +/// +/// A null-terminated C string containing the private key as uppercase hex, +/// or an error JSON if the inputs are invalid. +#[no_mangle] +pub unsafe extern "C" fn srp_derive_private_key_ffi( + salt: *const c_char, + identity: *const c_char, + password_hash: *const c_char, +) -> *mut c_char { + if salt.is_null() || identity.is_null() || password_hash.is_null() { + return create_error_response("Null pointer argument"); + } + + let salt_str = match CStr::from_ptr(salt).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in salt"), + }; + + let identity_str = match CStr::from_ptr(identity).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in identity"), + }; + + let password_hash_str = match CStr::from_ptr(password_hash).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in password_hash"), + }; + + match crate::srp::srp_derive_private_key(salt_str, identity_str, password_hash_str) { + Ok(key) => string_to_c_char(key), + Err(e) => create_error_response(&format!("SRP error: {}", e)), + } +} + +/// Derive the SRP verifier from a private key. +/// +/// # Safety +/// +/// - `private_key` must be a valid null-terminated C string +/// - The returned pointer must be freed by calling `free_string` +/// +/// # Returns +/// +/// A null-terminated C string containing the verifier as uppercase hex, +/// or an error JSON if the input is invalid. +#[no_mangle] +pub unsafe extern "C" fn srp_derive_verifier_ffi(private_key: *const c_char) -> *mut c_char { + if private_key.is_null() { + return create_error_response("Null pointer argument"); + } + + let private_key_str = match CStr::from_ptr(private_key).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in private_key"), + }; + + match crate::srp::srp_derive_verifier(private_key_str) { + Ok(verifier) => string_to_c_char(verifier), + Err(e) => create_error_response(&format!("SRP error: {}", e)), + } +} + +/// Generate a client ephemeral key pair. +/// +/// # Safety +/// +/// - The returned pointer must be freed by calling `free_string` +/// +/// # Returns +/// +/// A null-terminated C string containing JSON: {"public": "...", "secret": "..."} +#[no_mangle] +pub extern "C" fn srp_generate_ephemeral_ffi() -> *mut c_char { + let ephemeral = crate::srp::srp_generate_ephemeral(); + match serde_json::to_string(&ephemeral) { + Ok(json) => string_to_c_char(json), + Err(e) => create_error_response(&format!("Failed to serialize ephemeral: {}", e)), + } +} + +/// Derive the client session from server response. +/// +/// # Safety +/// +/// - All input pointers must be valid null-terminated C strings +/// - The returned pointer must be freed by calling `free_string` +/// +/// # Returns +/// +/// A null-terminated C string containing JSON: {"proof": "...", "key": "..."} +/// or an error JSON if inputs are invalid. +#[no_mangle] +pub unsafe extern "C" fn srp_derive_session_ffi( + client_secret: *const c_char, + server_public: *const c_char, + salt: *const c_char, + identity: *const c_char, + private_key: *const c_char, +) -> *mut c_char { + if client_secret.is_null() || server_public.is_null() || salt.is_null() + || identity.is_null() || private_key.is_null() + { + return create_error_response("Null pointer argument"); + } + + let client_secret_str = match CStr::from_ptr(client_secret).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in client_secret"), + }; + + let server_public_str = match CStr::from_ptr(server_public).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in server_public"), + }; + + let salt_str = match CStr::from_ptr(salt).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in salt"), + }; + + let identity_str = match CStr::from_ptr(identity).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in identity"), + }; + + let private_key_str = match CStr::from_ptr(private_key).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in private_key"), + }; + + match crate::srp::srp_derive_session( + client_secret_str, + server_public_str, + salt_str, + identity_str, + private_key_str, + ) { + Ok(session) => match serde_json::to_string(&session) { + Ok(json) => string_to_c_char(json), + Err(e) => create_error_response(&format!("Failed to serialize session: {}", e)), + }, + Err(e) => create_error_response(&format!("SRP error: {}", e)), + } +} + +/// Generate a server ephemeral key pair. +/// +/// # Safety +/// +/// - `verifier` must be a valid null-terminated C string +/// - The returned pointer must be freed by calling `free_string` +/// +/// # Returns +/// +/// A null-terminated C string containing JSON: {"public": "...", "secret": "..."} +/// or an error JSON if the input is invalid. +#[no_mangle] +pub unsafe extern "C" fn srp_generate_ephemeral_server_ffi(verifier: *const c_char) -> *mut c_char { + if verifier.is_null() { + return create_error_response("Null pointer argument"); + } + + let verifier_str = match CStr::from_ptr(verifier).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in verifier"), + }; + + match crate::srp::srp_generate_ephemeral_server(verifier_str) { + Ok(ephemeral) => match serde_json::to_string(&ephemeral) { + Ok(json) => string_to_c_char(json), + Err(e) => create_error_response(&format!("Failed to serialize ephemeral: {}", e)), + }, + Err(e) => create_error_response(&format!("SRP error: {}", e)), + } +} + +/// Derive and verify the server session from client response. +/// +/// # Safety +/// +/// - All input pointers must be valid null-terminated C strings +/// - The returned pointer must be freed by calling `free_string` +/// +/// # Returns +/// +/// A null-terminated C string containing JSON: +/// - {"proof": "...", "key": "..."} if client proof is valid +/// - "null" if client proof is invalid (authentication failed) +/// - Error JSON if inputs are invalid +#[no_mangle] +pub unsafe extern "C" fn srp_derive_session_server_ffi( + server_secret: *const c_char, + client_public: *const c_char, + salt: *const c_char, + identity: *const c_char, + verifier: *const c_char, + client_proof: *const c_char, +) -> *mut c_char { + if server_secret.is_null() || client_public.is_null() || salt.is_null() + || identity.is_null() || verifier.is_null() || client_proof.is_null() + { + return create_error_response("Null pointer argument"); + } + + let server_secret_str = match CStr::from_ptr(server_secret).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in server_secret"), + }; + + let client_public_str = match CStr::from_ptr(client_public).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in client_public"), + }; + + let salt_str = match CStr::from_ptr(salt).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in salt"), + }; + + let identity_str = match CStr::from_ptr(identity).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in identity"), + }; + + let verifier_str = match CStr::from_ptr(verifier).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in verifier"), + }; + + let client_proof_str = match CStr::from_ptr(client_proof).to_str() { + Ok(s) => s, + Err(_) => return create_error_response("Invalid UTF-8 in client_proof"), + }; + + match crate::srp::srp_derive_session_server( + server_secret_str, + client_public_str, + salt_str, + identity_str, + verifier_str, + client_proof_str, + ) { + Ok(Some(session)) => match serde_json::to_string(&session) { + Ok(json) => string_to_c_char(json), + Err(e) => create_error_response(&format!("Failed to serialize session: {}", e)), + }, + Ok(None) => string_to_c_char("null".to_string()), + Err(e) => create_error_response(&format!("SRP error: {}", e)), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/rust/src/lib.rs b/core/rust/src/lib.rs index 59b99734c..0ba167259 100644 --- a/core/rust/src/lib.rs +++ b/core/rust/src/lib.rs @@ -4,6 +4,7 @@ //! - **vault_merge**: Vault merge using Last-Write-Wins (LWW) strategy //! - **vault_pruner**: Prunes expired items from trash (30-day retention) //! - **credential_matcher**: Cross-platform credential filtering for autofill +//! - **srp**: Secure Remote Password (SRP-6a) protocol for authentication //! //! This library accepts data as JSON and returns results as JSON. //! Each platform (browser, iOS, Android, .NET) handles its own I/O @@ -23,12 +24,18 @@ //! // Credential matching example //! let credentials = get_credentials_json(); //! let matches = filter_credentials(credentials, "https://github.com", "GitHub"); +//! +//! // SRP authentication example +//! let salt = srp::srp_generate_salt(); +//! let private_key = srp::srp_derive_private_key(&salt, "user", &password_hash); +//! let verifier = srp::srp_derive_verifier(&private_key); //! ``` pub mod error; pub mod vault_merge; pub mod vault_pruner; pub mod credential_matcher; +pub mod srp; pub use error::VaultError; pub use vault_merge::{ @@ -42,6 +49,12 @@ pub use credential_matcher::{ filter_credentials, extract_domain, extract_root_domain, AutofillMatchingMode, CredentialMatcherInput, CredentialMatcherOutput, }; +pub use srp::{ + srp_generate_salt, srp_derive_private_key, srp_derive_verifier, + srp_generate_ephemeral, srp_derive_session, + srp_generate_ephemeral_server, srp_derive_session_server, + SrpEphemeral, SrpSession, SrpError, +}; // WASM bindings #[cfg(feature = "wasm")] diff --git a/core/rust/src/srp/mod.rs b/core/rust/src/srp/mod.rs new file mode 100644 index 000000000..8a30343e5 --- /dev/null +++ b/core/rust/src/srp/mod.rs @@ -0,0 +1,768 @@ +//! SRP (Secure Remote Password) protocol implementation. +//! +//! # Protocol Parameters +//! - Group: RFC 5054 2048-bit +//! - Hash: SHA-256 +//! - Multiplier k: Computed as `k = H(N, PAD(g))` +//! - All values: Uppercase hex strings +//! +//! # Client Operations +//! - `srp_generate_salt()` - Generate a 32-byte cryptographic salt +//! - `srp_derive_private_key()` - Derive private key x = H(salt | H(identity | ":" | password_hash)) +//! - `srp_derive_verifier()` - Derive verifier v = g^x mod N +//! - `srp_generate_ephemeral()` - Generate client ephemeral key pair (A, a) +//! - `srp_derive_session()` - Derive session key and proof from server response +//! +//! # Server Operations +//! - `srp_generate_ephemeral_server()` - Generate server ephemeral key pair (B, b) +//! - `srp_derive_session_server()` - Verify client proof and derive session + +use digest::Digest; +use num_bigint::BigUint; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use srp::client::SrpClient; +use srp::groups::G_2048; +use srp::server::SrpServer; +use thiserror::Error; + +/// SRP ephemeral key pair (public and secret values). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct SrpEphemeral { + /// Public ephemeral value (uppercase hex) + pub public: String, + /// Secret ephemeral value (uppercase hex) + pub secret: String, +} + +/// SRP session containing proof and shared key. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct SrpSession { + /// Session proof (uppercase hex) + pub proof: String, + /// Shared session key (uppercase hex) + pub key: String, +} + +/// SRP-related errors. +#[derive(Error, Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] +#[cfg_attr(feature = "uniffi", uniffi(flat_error))] +pub enum SrpError { + #[error("Invalid hex string: {0}")] + InvalidHex(String), + #[error("Invalid parameter: {0}")] + InvalidParameter(String), + #[error("Authentication failed: {0}")] + AuthenticationFailed(String), +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Helper Functions +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Convert bytes to uppercase hex string. +fn bytes_to_hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{:02X}", b)) + .collect::() +} + +/// Convert hex string to bytes. +fn hex_to_bytes(hex: &str) -> Result, SrpError> { + // Handle both uppercase and lowercase hex + let hex = hex.trim(); + if hex.is_empty() { + return Err(SrpError::InvalidHex("empty hex string".to_string())); + } + + // Remove 0x prefix if present + let hex = hex.strip_prefix("0x").unwrap_or(hex); + let hex = hex.strip_prefix("0X").unwrap_or(hex); + + if hex.len() % 2 != 0 { + return Err(SrpError::InvalidHex(format!( + "odd length hex string: {}", + hex.len() + ))); + } + + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&hex[i..i + 2], 16) + .map_err(|e| SrpError::InvalidHex(format!("invalid hex at position {}: {}", i, e))) + }) + .collect() +} + +/// Generate cryptographically secure random bytes. +fn generate_random_bytes(len: usize) -> Vec { + let mut bytes = vec![0u8; len]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes +} + +/// Pad a BigUint to a specific length (for SRP compatibility). +fn pad_to_length(bytes: Vec, target_len: usize) -> Vec { + if bytes.len() >= target_len { + bytes + } else { + let mut padded = vec![0u8; target_len - bytes.len()]; + padded.extend(bytes); + padded + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Client Operations +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Generate a cryptographic salt for SRP. +/// +/// Returns a 32-byte random salt as an uppercase hex string. +pub fn srp_generate_salt() -> String { + let salt = generate_random_bytes(32); + bytes_to_hex(&salt) +} + +/// Derive the SRP private key (x) from credentials. +/// +/// Formula: x = H(salt | H(identity | ":" | password_hash)) +/// +/// # Arguments +/// * `salt` - Salt as hex string +/// * `identity` - User identity +/// * `password_hash` - Pre-hashed password as hex string +/// +/// # Returns +/// Private key as uppercase hex string +pub fn srp_derive_private_key( + salt: &str, + identity: &str, + password_hash: &str, +) -> Result { + let salt_bytes = hex_to_bytes(salt)?; + + // Compute identity hash: H(identity | ":" | password_hash) + let mut identity_hasher = Sha256::new(); + identity_hasher.update(identity.as_bytes()); + identity_hasher.update(b":"); + identity_hasher.update(password_hash.as_bytes()); + let identity_hash = identity_hasher.finalize(); + + // Compute x = H(salt | identity_hash) + let mut x_hasher = Sha256::new(); + x_hasher.update(&salt_bytes); + x_hasher.update(&identity_hash); + let x = x_hasher.finalize(); + + Ok(bytes_to_hex(&x)) +} + +/// Derive the SRP verifier (v) from a private key. +/// +/// Formula: v = g^x mod N +/// +/// # Arguments +/// * `private_key` - Private key as hex string +/// +/// # Returns +/// Verifier as uppercase hex string (256 bytes) +pub fn srp_derive_verifier(private_key: &str) -> Result { + let x_bytes = hex_to_bytes(private_key)?; + let x = BigUint::from_bytes_be(&x_bytes); + + // v = g^x mod N + let v = G_2048.g.modpow(&x, &G_2048.n); + + // Pad to N length (256 bytes for 2048-bit group) + let v_bytes = pad_to_length(v.to_bytes_be(), 256); + Ok(bytes_to_hex(&v_bytes)) +} + +/// Generate a client ephemeral key pair. +/// +/// Computes A = g^a mod N where a is a random 64-byte secret. +pub fn srp_generate_ephemeral() -> SrpEphemeral { + let client = SrpClient::::new(&G_2048); + + // Generate 64 bytes of random data for the secret + let a = generate_random_bytes(64); + + // Compute public ephemeral A = g^a mod N + let a_pub = client.compute_public_ephemeral(&a); + + // Pad to N length (256 bytes for 2048-bit group) + let a_pub_padded = pad_to_length(a_pub, 256); + + SrpEphemeral { + public: bytes_to_hex(&a_pub_padded), + secret: bytes_to_hex(&a), + } +} + +/// Derive the client session from server response. +/// +/// Computes the shared session key K and client proof M1. +/// +/// # Arguments +/// * `client_secret` - Client secret ephemeral (a) as hex string +/// * `server_public` - Server public ephemeral (B) as hex string +/// * `salt` - Salt as hex string +/// * `identity` - User identity +/// * `private_key` - Private key (x) as hex string +/// +/// # Returns +/// Session with proof (M1) and key (K), or error if B is invalid +pub fn srp_derive_session( + client_secret: &str, + server_public: &str, + salt: &str, + identity: &str, + private_key: &str, +) -> Result { + let a = hex_to_bytes(client_secret)?; + let b_pub = hex_to_bytes(server_public)?; + let salt_bytes = hex_to_bytes(salt)?; + let x_bytes = hex_to_bytes(private_key)?; + + let client = SrpClient::::new(&G_2048); + + // Convert to BigUint for calculations + let a_big = BigUint::from_bytes_be(&a); + let a_pub = client.compute_a_pub(&a_big); + let b_pub_big = BigUint::from_bytes_be(&b_pub); + + // Check for malicious B (B mod N must not be 0) + if &b_pub_big % &G_2048.n == BigUint::default() { + return Err(SrpError::InvalidParameter( + "server public ephemeral is invalid".to_string(), + )); + } + + // Pad A and B to N length for hashing + let a_pub_bytes = pad_to_length(a_pub.to_bytes_be(), 256); + let b_pub_bytes = pad_to_length(b_pub, 256); + + // Compute u = H(A | B) + let u = compute_u(&a_pub_bytes, &b_pub_bytes); + + // Compute k = H(N | g) + let k = compute_k(); + + // x as BigUint + let x = BigUint::from_bytes_be(&x_bytes); + + // S = (B - k*g^x)^(a + u*x) mod N + let kg_x = (&k * G_2048.g.modpow(&x, &G_2048.n)) % &G_2048.n; + let base = ((&G_2048.n + &b_pub_big) - &kg_x) % &G_2048.n; + let exp = (&u * &x) + &a_big; + let s = base.modpow(&exp, &G_2048.n); + + // K = H(S) + let s_bytes = pad_to_length(s.to_bytes_be(), 256); + let mut key_hasher = Sha256::new(); + key_hasher.update(&s_bytes); + let key = key_hasher.finalize(); + + // M1 = H(H(N) XOR H(g) | H(I) | s | A | B | K) + let m1 = compute_m1(&a_pub_bytes, &b_pub_bytes, &salt_bytes, identity, &key); + + Ok(SrpSession { + proof: bytes_to_hex(&m1), + key: bytes_to_hex(&key), + }) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Server Operations +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Generate a server ephemeral key pair. +/// +/// Computes B = k*v + g^b mod N where b is a random 64-byte secret. +/// +/// # Arguments +/// * `verifier` - Password verifier (v) as hex string +pub fn srp_generate_ephemeral_server(verifier: &str) -> Result { + let v_bytes = hex_to_bytes(verifier)?; + + let server = SrpServer::::new(&G_2048); + + // Generate 64 bytes of random data for the secret + let b = generate_random_bytes(64); + + // Compute public ephemeral B = k*v + g^b mod N + let b_pub = server.compute_public_ephemeral(&b, &v_bytes); + + // Pad to N length (256 bytes for 2048-bit group) + let b_pub_padded = pad_to_length(b_pub, 256); + + Ok(SrpEphemeral { + public: bytes_to_hex(&b_pub_padded), + secret: bytes_to_hex(&b), + }) +} + +/// Derive and verify the server session from client response. +/// +/// Verifies client proof M1 and computes server proof M2. +/// +/// # Arguments +/// * `server_secret` - Server secret ephemeral (b) as hex string +/// * `client_public` - Client public ephemeral (A) as hex string +/// * `salt` - Salt as hex string +/// * `identity` - User identity +/// * `verifier` - Password verifier (v) as hex string +/// * `client_proof` - Client proof (M1) as hex string +/// +/// # Returns +/// Session with proof (M2) and key (K) if verification succeeds, None if M1 is invalid +pub fn srp_derive_session_server( + server_secret: &str, + client_public: &str, + salt: &str, + identity: &str, + verifier: &str, + client_proof: &str, +) -> Result, SrpError> { + let b = hex_to_bytes(server_secret)?; + let a_pub = hex_to_bytes(client_public)?; + let salt_bytes = hex_to_bytes(salt)?; + let v_bytes = hex_to_bytes(verifier)?; + let client_m1 = hex_to_bytes(client_proof)?; + + // Convert to BigUint for calculations + let b_big = BigUint::from_bytes_be(&b); + let a_pub_big = BigUint::from_bytes_be(&a_pub); + let v = BigUint::from_bytes_be(&v_bytes); + + // Check for malicious A (A mod N must not be 0) + if &a_pub_big % &G_2048.n == BigUint::default() { + return Err(SrpError::InvalidParameter( + "client public ephemeral is invalid".to_string(), + )); + } + + // Compute k = H(N | g) + let k = compute_k(); + + // B = k*v + g^b mod N + let kv = (&k * &v) % &G_2048.n; + let b_pub = (&kv + G_2048.g.modpow(&b_big, &G_2048.n)) % &G_2048.n; + + // Pad A and B to N length + let a_pub_bytes = pad_to_length(a_pub.clone(), 256); + let b_pub_bytes = pad_to_length(b_pub.to_bytes_be(), 256); + + // Compute u = H(A | B) + let u = compute_u(&a_pub_bytes, &b_pub_bytes); + + // S = (A * v^u)^b mod N + let v_u = v.modpow(&u, &G_2048.n); + let base = (&a_pub_big * &v_u) % &G_2048.n; + let s = base.modpow(&b_big, &G_2048.n); + + // K = H(S) + let s_bytes = pad_to_length(s.to_bytes_be(), 256); + let mut key_hasher = Sha256::new(); + key_hasher.update(&s_bytes); + let key = key_hasher.finalize(); + + // M1 = H(H(N) XOR H(g) | H(I) | s | A | B | K) + let expected_m1 = compute_m1(&a_pub_bytes, &b_pub_bytes, &salt_bytes, identity, &key); + + // Verify client proof using constant-time comparison + use subtle::ConstantTimeEq; + if expected_m1.ct_eq(&client_m1).unwrap_u8() != 1 { + return Ok(None); + } + + // M2 = H(A | M1 | K) + let m2 = compute_m2(&a_pub_bytes, &expected_m1, &key); + + Ok(Some(SrpSession { + proof: bytes_to_hex(&m2), + key: bytes_to_hex(&key), + })) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Internal Helper Functions +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Compute u = H(A | B) +fn compute_u(a_pub: &[u8], b_pub: &[u8]) -> BigUint { + let mut hasher = Sha256::new(); + hasher.update(a_pub); + hasher.update(b_pub); + BigUint::from_bytes_be(&hasher.finalize()) +} + +/// Compute k = H(N | PAD(g)) +fn compute_k() -> BigUint { + let mut hasher = Sha256::new(); + hasher.update(&G_2048.n.to_bytes_be()); + // Pad g to the same length as N + let g_padded = pad_to_length(G_2048.g.to_bytes_be(), 256); + hasher.update(&g_padded); + BigUint::from_bytes_be(&hasher.finalize()) +} + +/// Compute M1 = H(H(N) XOR H(g) | H(I) | s | A | B | K) +/// +/// Note: H(g) uses g without padding, unlike k = H(N, PAD(g)) +fn compute_m1(a_pub: &[u8], b_pub: &[u8], salt: &[u8], identity: &str, key: &[u8]) -> Vec { + // H(N) + let mut n_hasher = Sha256::new(); + n_hasher.update(&G_2048.n.to_bytes_be()); + let h_n = n_hasher.finalize(); + + // H(g) - NOT padded + let mut g_hasher = Sha256::new(); + g_hasher.update(&G_2048.g.to_bytes_be()); + let h_g = g_hasher.finalize(); + + // H(N) XOR H(g) + let h_n_xor_h_g: Vec = h_n.iter().zip(h_g.iter()).map(|(a, b)| a ^ b).collect(); + + // H(I) + let mut i_hasher = Sha256::new(); + i_hasher.update(identity.as_bytes()); + let h_i = i_hasher.finalize(); + + // M1 = H(H(N) XOR H(g) | H(I) | s | A | B | K) + let mut m1_hasher = Sha256::new(); + m1_hasher.update(&h_n_xor_h_g); + m1_hasher.update(&h_i); + m1_hasher.update(salt); + m1_hasher.update(a_pub); + m1_hasher.update(b_pub); + m1_hasher.update(key); + + m1_hasher.finalize().to_vec() +} + +/// Compute M2 = H(A | M1 | K) +fn compute_m2(a_pub: &[u8], m1: &[u8], key: &[u8]) -> Vec { + let mut m2_hasher = Sha256::new(); + m2_hasher.update(a_pub); + m2_hasher.update(m1); + m2_hasher.update(key); + m2_hasher.finalize().to_vec() +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Client Verification +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Verify the server's session proof (M2) on the client side. +/// +/// This confirms that the server successfully derived the same session key. +/// +/// # Arguments +/// * `client_public` - Client public ephemeral (A) as hex string +/// * `client_proof` - Client proof (M1) as hex string +/// * `session_key` - Session key (K) as hex string +/// * `server_proof` - Server proof (M2) as hex string to verify +/// +/// # Returns +/// True if verification succeeds, false otherwise +pub fn srp_verify_session( + client_public: &str, + client_proof: &str, + session_key: &str, + server_proof: &str, +) -> Result { + let a_pub_bytes = hex_to_bytes(client_public)?; + let m1_bytes = hex_to_bytes(client_proof)?; + let key_bytes = hex_to_bytes(session_key)?; + let server_m2_bytes = hex_to_bytes(server_proof)?; + + // Compute expected M2 = H(A | M1 | K) + let expected_m2 = compute_m2(&a_pub_bytes, &m1_bytes, &key_bytes); + + // Constant-time comparison for security + use subtle::ConstantTimeEq; + Ok(expected_m2.ct_eq(&server_m2_bytes).unwrap_u8() == 1) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_salt() { + let salt = srp_generate_salt(); + assert_eq!(salt.len(), 64); // 32 bytes = 64 hex chars + // Should be valid hex + assert!(hex_to_bytes(&salt).is_ok()); + } + + #[test] + fn test_derive_private_key() { + let salt = "0A0B0C0D0E0F10111213141516171819"; + let identity = "testuser"; + let password_hash = "AABBCCDD"; + + let private_key = srp_derive_private_key(salt, identity, password_hash).unwrap(); + eprintln!("Rust Private Key: {}", private_key); + + let expected = "ACD81DF26882B20336CF2A8CDE3CABA35BA359805FDFC4567EA7BD74E8302473"; + + // Should be 32 bytes = 64 hex chars + assert_eq!(private_key.len(), 64); + assert!(hex_to_bytes(&private_key).is_ok()); + + // Same inputs should produce same output + let private_key2 = srp_derive_private_key(salt, identity, password_hash).unwrap(); + assert_eq!(private_key, private_key2); + + assert_eq!(private_key.to_uppercase(), expected); + } + + #[test] + fn test_derive_verifier() { + let salt = "0A0B0C0D0E0F10111213141516171819"; + let identity = "testuser"; + let password_hash = "AABBCCDD"; + + let private_key = srp_derive_private_key(salt, identity, password_hash).unwrap(); + let verifier = srp_derive_verifier(&private_key).unwrap(); + eprintln!("Rust Verifier: {}", verifier); + + let expected = "378FAC69B16F469FB21294F7C74429CD288F47E331E8BA02FFD7C36F2914472A9F2A8C69FFEA434C9F78FCA7E7E41CBBF591FFA589460F023EF3A6F7F6B84366458893C52F8A3304E2247C50BDAE13F4463281B8CDCC519DD563A926C93D9A33E08C1DE2EFB6102BD4BFFE97D9DA9A20354393FA041C8C0459D9D11907E11B75DE4F74990CD0364BA3884C697CF548E31707162D033576B96756A9C8B622332AC9631F62D170445CF33A5EF7E1BE82EC949A5F1FD4AAF1767EE861C729E348FD4209F552BEA5A2F059C64985F4DD2495896AE33315F54329192715AB27EA32B0AF56AC8991C9F708260EF3B5D263FA55B6380CDD294F272FFD1DD86116F0C06C"; + + // Should be 256 bytes = 512 hex chars (padded to 2048-bit group size) + assert_eq!(verifier.len(), 512); + assert!(hex_to_bytes(&verifier).is_ok()); + + assert_eq!(verifier.to_uppercase(), expected); + } + + #[test] + fn test_generate_ephemeral() { + let ephemeral = srp_generate_ephemeral(); + + // Public should be 256 bytes = 512 hex chars + assert_eq!(ephemeral.public.len(), 512); + // Secret should be 64 bytes = 128 hex chars + assert_eq!(ephemeral.secret.len(), 128); + + // Both should be valid hex + assert!(hex_to_bytes(&ephemeral.public).is_ok()); + assert!(hex_to_bytes(&ephemeral.secret).is_ok()); + } + + #[test] + fn test_generate_ephemeral_server() { + // First derive a verifier + let salt = srp_generate_salt(); + let private_key = srp_derive_private_key(&salt, "testuser", "PASSWORDHASH").unwrap(); + let verifier = srp_derive_verifier(&private_key).unwrap(); + + // Generate server ephemeral + let ephemeral = srp_generate_ephemeral_server(&verifier).unwrap(); + + // Public should be 256 bytes = 512 hex chars + assert_eq!(ephemeral.public.len(), 512); + // Secret should be 64 bytes = 128 hex chars + assert_eq!(ephemeral.secret.len(), 128); + } + + /// Test with fixed values for deterministic verification. + #[test] + fn test_fixed_values() { + let salt = "0A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F"; + let identity = "testuser"; + let password_hash = "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899"; + + let expected_private_key = "37D921B103087DDBCFEE50E240DBF5904BBC021BD07391F206CA74BE5430D79B"; + let expected_verifier = "603ABD0F6C5494976B140BBF29D988989FD88654438994959D851C83FC891FA22C81B7CD3B1BBC5472651473183789A4DB5454D530BDEF328DCBA19C112ED266584D8750AEFDCFC0076FD40B3E16773672994C7CB56B4F6CD5FCA47927F9688483937890054D208DDBDD5117F18461B6AD7A279495583B7D99CDC1EB678E9402171F43DC7732549B5A5A3A4A2BF586686887E09D1DED55A7945C20F4DB62915DCF7FD4D7ECED87758B3E19E25CFC668FDB92FCE15E9452DE7F78BDB9BC80DE25882769870E156B2860A169F33045298CEC7700975E3EF4AAE5B41CE6086E2593EDCF2BEA8F3B613258259197C4AE8A67055ED5546C83F6EF035BA788EC63A1AE"; + + let private_key = srp_derive_private_key(salt, identity, password_hash).unwrap(); + eprintln!("Rust Private Key: {}", private_key); + assert_eq!(private_key.to_uppercase(), expected_private_key); + + let verifier = srp_derive_verifier(&private_key).unwrap(); + eprintln!("Rust Verifier: {}", verifier); + assert_eq!(verifier.to_uppercase(), expected_verifier); + } + + /// Test session derivation with fixed ephemeral values. + #[test] + fn test_session_fixed_values() { + let salt = "0A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F"; + let identity = "testuser"; + let password_hash = "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899"; + + let private_key = srp_derive_private_key(salt, identity, password_hash).unwrap(); + + let client_secret = "89697cc13c1cea1f44c5f6b3f8f0cb7ce28246c80de10ca5d4976575dbcb0318"; + let server_public = "523d0e314fccaace5ad5007357b07bb2fb2c5f566be0b812cbe4ffa65adc5bdd5cd59d9ca921b7491481d2963733513968e7bea637a733665f8e9fb7a18ba613a03740eed9ea3795489659a486cd87352054ed49f0636bb2605b8d836a459151cb670d35e8377202d9e1569bf88d0c86bd83d303d8775a65867b68fc7f9a9d5d59c76c413cb1b4d33f1d5eb784d1d18a5705800729a5d566548297c3b84ec1077c4546ab3c9b159a6d6c7265cdc784f36f731fa371e14bc506a544713591579d0a6952c2539746963434f0e97a024c0e93701008e4c54b620a9259d071b88c0a4cf102eaa22732ecfcd1fd23a81ee180074db1b5cee1b3e9172f76153f8d46bc"; + + let expected_session_key = "AD713F5D8F520B7B9413CDD9EF6D9B5FE37F23A9B62C5E2B90D2291F8C3A9E6F"; + let expected_session_proof = "698D0DA7137A0FC4A55B49525C1312ADCD07788E8CD5FFF5BD195B3C17B6B3DF"; + + let session = srp_derive_session( + client_secret, + server_public, + salt, + identity, + &private_key, + ).unwrap(); + + eprintln!("Rust Session Key: {}", session.key); + eprintln!("Rust Session Proof: {}", session.proof); + + assert_eq!(session.key.to_uppercase(), expected_session_key); + assert_eq!(session.proof.to_uppercase(), expected_session_proof); + } + + /// Test with realistic 32-byte salt. + #[test] + fn test_realistic_salt() { + let salt = "7c9d6615bfeb06c552c7fbcbfbe7030035a09f058ed7cf7755ca6d3bfa56393c"; + let username = "testuser"; + let password_hash = "ABCD1234567890ABCD1234567890ABCD1234567890ABCD1234567890ABCD1234"; + + let expected_private_key = "352C41C945185EDC02EBA1087A02D06A686A194D3542AE174B4F75F340E4E02E"; + let expected_verifier = "8612168CF700A1CBAE568175B1BDD9B93874A9029B2EA34126910EABFE7DCEA57345560AD96754E1C5A5A2272F1C794D7C6A7D5A756FD37EF78170A3162051035D115AA376F85330701586A714C97413F84BAE12A87497357C0483E443B7D3B75B3C19BCF845ABD38956D2EAEFE733DC696D88277245DC7E25C9013D77053F82E9400F6918BF58176D536EB7D90572A645790E6F5660FD0FB8D5673B584F1F33F06C824CA1CF246BED84E228745CD4ABC1184E5057D03191AB9253F86A407970A4578DC6763D7D42AF2CB71C79F60BB71CA16CF98A17E4F3D62BE8396593427487115163B668A8E0069487C763342B58EFAF9499EBB87DE07E52836B3DF4F28C"; + + let private_key = srp_derive_private_key(salt, username, password_hash).unwrap(); + eprintln!("Rust Private Key: {}", private_key); + assert_eq!(private_key.to_uppercase(), expected_private_key); + + let verifier = srp_derive_verifier(&private_key).unwrap(); + eprintln!("Rust Verifier: {}", verifier); + assert_eq!(verifier.to_uppercase(), expected_verifier); + + // Test session derivation + let client_secret = "d21695287e680db505882ba699bb1a417fe064cc817ead8f2e872fb4b8612273"; + let server_public = "02ea98a39b29fee876b183124e9dd8f4e5dedf429a1bb0e74dafd67a6a855f8e43a317edb17b93fc6c42c7ed5a2d5cc166fe9dabc66e71475a3a947aec440c23e5c8b347ee4352a84a2fb94d683d1545ef2ac7571e5032d68a0bdfe8cc16d8cf852851dc9a74690d35439a722dc22eaa682ee50eb354131445fd414d4e30dd7653560a4342ffccf392f4b658b37f939a179f01be15aa4364f7d720eebb850a5cad023ce07ed09f47da00ba00ac31df2bb251c2e910a8d50044b9dc926711b648718357da4b233078a17862e5ad57df0cb13325ef39acd42625fd858f0073e073bd61eee07a89be4c2d4b52d868324fea7b68acf3dce94733973469fdc1cc8d32"; + + let expected_session_key = "7564C550D5BF148D17B33C251B71EA2E0CD96D70E207B58622D9FF78BEE609A4"; + let expected_session_proof = "87BF2829F780EF88C1BFB63F39547DAA3CC787B40978C27CDC50FDEBFD324470"; + + let session = srp_derive_session( + client_secret, + server_public, + salt, + username, + &private_key, + ).unwrap(); + + eprintln!("Rust Session Key: {}", session.key); + eprintln!("Rust Session Proof: {}", session.proof); + + assert_eq!(session.key.to_uppercase(), expected_session_key); + assert_eq!(session.proof.to_uppercase(), expected_session_proof); + } + + #[test] + fn test_full_srp_flow() { + // 1. Registration: Generate salt and verifier + let salt = srp_generate_salt(); + let identity = "testuser@example.com"; + let password_hash = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + + let private_key = srp_derive_private_key(&salt, identity, password_hash).unwrap(); + let verifier = srp_derive_verifier(&private_key).unwrap(); + + // 2. Login: Client generates ephemeral + let client_ephemeral = srp_generate_ephemeral(); + + // 3. Server generates ephemeral and sends to client + let server_ephemeral = srp_generate_ephemeral_server(&verifier).unwrap(); + + // 4. Client derives session + let client_session = srp_derive_session( + &client_ephemeral.secret, + &server_ephemeral.public, + &salt, + identity, + &private_key, + ).unwrap(); + + // 5. Server verifies client proof and derives session + let server_session = srp_derive_session_server( + &server_ephemeral.secret, + &client_ephemeral.public, + &salt, + identity, + &verifier, + &client_session.proof, + ).unwrap(); + + // Server should successfully verify and return a session + assert!(server_session.is_some()); + let server_session = server_session.unwrap(); + + // Both should have the same session key + assert_eq!(client_session.key, server_session.key); + } + + #[test] + fn test_wrong_password_fails() { + // Setup with correct credentials + let salt = srp_generate_salt(); + let identity = "testuser"; + let correct_password_hash = "CORRECT_PASSWORD_HASH_0123456789"; + let wrong_password_hash = "WRONG_PASSWORD_HASH_0123456789AB"; + + let correct_private_key = srp_derive_private_key(&salt, identity, correct_password_hash).unwrap(); + let verifier = srp_derive_verifier(&correct_private_key).unwrap(); + + // Client uses wrong password + let wrong_private_key = srp_derive_private_key(&salt, identity, wrong_password_hash).unwrap(); + + let client_ephemeral = srp_generate_ephemeral(); + let server_ephemeral = srp_generate_ephemeral_server(&verifier).unwrap(); + + // Client derives session with wrong password + let client_session = srp_derive_session( + &client_ephemeral.secret, + &server_ephemeral.public, + &salt, + identity, + &wrong_private_key, + ).unwrap(); + + // Server should reject the client proof + let server_session = srp_derive_session_server( + &server_ephemeral.secret, + &client_ephemeral.public, + &salt, + identity, + &verifier, + &client_session.proof, + ).unwrap(); + + // Server should return None (authentication failed) + assert!(server_session.is_none()); + } + + #[test] + fn test_hex_conversion() { + // Test round-trip + let original = vec![0x00, 0x01, 0x0A, 0xFF, 0x10]; + let hex = bytes_to_hex(&original); + assert_eq!(hex, "00010AFF10"); + + let decoded = hex_to_bytes(&hex).unwrap(); + assert_eq!(decoded, original); + + // Test lowercase input + let decoded_lower = hex_to_bytes("00010aff10").unwrap(); + assert_eq!(decoded_lower, original); + } +} diff --git a/core/rust/src/uniffi_api.rs b/core/rust/src/uniffi_api.rs index 37fc17bb9..1f634bf98 100644 --- a/core/rust/src/uniffi_api.rs +++ b/core/rust/src/uniffi_api.rs @@ -108,6 +108,121 @@ pub fn extract_root_domain(domain: String) -> String { crate::credential_matcher::extract_root_domain(&domain) } +// ═══════════════════════════════════════════════════════════════════════════════ +// SRP (Secure Remote Password) Functions +// ═══════════════════════════════════════════════════════════════════════════════ + +pub use crate::srp::{SrpEphemeral, SrpSession, SrpError}; + +/// Generate a cryptographic salt for SRP. +/// Returns a 32-byte random salt as an uppercase hex string. +#[uniffi::export] +pub fn srp_generate_salt() -> String { + crate::srp::srp_generate_salt() +} + +/// Derive the SRP private key (x) from credentials. +/// +/// # Arguments +/// * `salt` - Salt as uppercase hex string +/// * `identity` - User identity (username or SRP identity GUID) +/// * `password_hash` - Pre-hashed password as uppercase hex string (from Argon2id) +/// +/// # Returns +/// Private key as uppercase hex string +#[uniffi::export] +pub fn srp_derive_private_key( + salt: String, + identity: String, + password_hash: String, +) -> Result { + crate::srp::srp_derive_private_key(&salt, &identity, &password_hash) +} + +/// Derive the SRP verifier (v) from a private key. +/// +/// # Arguments +/// * `private_key` - Private key as uppercase hex string +/// +/// # Returns +/// Verifier as uppercase hex string (for registration) +#[uniffi::export] +pub fn srp_derive_verifier(private_key: String) -> Result { + crate::srp::srp_derive_verifier(&private_key) +} + +/// Generate a client ephemeral key pair. +/// Returns a pair of public (A) and secret (a) values as uppercase hex strings. +#[uniffi::export] +pub fn srp_generate_ephemeral() -> SrpEphemeral { + crate::srp::srp_generate_ephemeral() +} + +/// Derive the client session from server response. +/// +/// # Arguments +/// * `client_secret` - Client secret ephemeral (a) as hex string +/// * `server_public` - Server public ephemeral (B) as hex string +/// * `salt` - Salt as hex string +/// * `identity` - User identity (username or SRP identity GUID) +/// * `private_key` - Private key (x) as hex string +/// +/// # Returns +/// Session containing proof and key as uppercase hex strings +#[uniffi::export] +pub fn srp_derive_session( + client_secret: String, + server_public: String, + salt: String, + identity: String, + private_key: String, +) -> Result { + crate::srp::srp_derive_session(&client_secret, &server_public, &salt, &identity, &private_key) +} + +/// Generate a server ephemeral key pair. +/// +/// # Arguments +/// * `verifier` - Password verifier (v) as hex string +/// +/// # Returns +/// Ephemeral containing public (B) and secret (b) as uppercase hex strings +#[uniffi::export] +pub fn srp_generate_ephemeral_server(verifier: String) -> Result { + crate::srp::srp_generate_ephemeral_server(&verifier) +} + +/// Derive and verify the server session from client response. +/// +/// # Arguments +/// * `server_secret` - Server secret ephemeral (b) as hex string +/// * `client_public` - Client public ephemeral (A) as hex string +/// * `salt` - Salt as hex string (not used in calculation, for API compatibility) +/// * `identity` - User identity (not used in calculation, for API compatibility) +/// * `verifier` - Password verifier (v) as hex string +/// * `client_proof` - Client proof (M1) as hex string +/// +/// # Returns +/// Session with server proof and key if client proof is valid, None otherwise +#[uniffi::export] +pub fn srp_derive_session_server( + server_secret: String, + client_public: String, + salt: String, + identity: String, + verifier: String, + client_proof: String, +) -> Result, SrpError> { + crate::srp::srp_derive_session_server( + &server_secret, + &client_public, + &salt, + &identity, + &verifier, + &client_proof, + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/rust/src/wasm.rs b/core/rust/src/wasm.rs index 27be05e04..46c2c5daa 100644 --- a/core/rust/src/wasm.rs +++ b/core/rust/src/wasm.rs @@ -132,3 +132,156 @@ pub fn extract_domain_js(url: &str) -> String { pub fn extract_root_domain_js(domain: &str) -> String { crate::credential_matcher::extract_root_domain(domain) } + +// ═══════════════════════════════════════════════════════════════════════════════ +// SRP (Secure Remote Password) WASM Bindings +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Generate a cryptographic salt for SRP. +/// Returns a 32-byte random salt as an uppercase hex string. +#[wasm_bindgen(js_name = srpGenerateSalt)] +pub fn srp_generate_salt_js() -> String { + crate::srp::srp_generate_salt() +} + +/// Derive the SRP private key (x) from credentials. +/// +/// # Arguments +/// * `salt` - Salt as uppercase hex string +/// * `identity` - User identity (username or SRP identity GUID) +/// * `password_hash` - Pre-hashed password as uppercase hex string (from Argon2id) +/// +/// # Returns +/// Private key as uppercase hex string +#[wasm_bindgen(js_name = srpDerivePrivateKey)] +pub fn srp_derive_private_key_js( + salt: &str, + identity: &str, + password_hash: &str, +) -> Result { + crate::srp::srp_derive_private_key(salt, identity, password_hash) + .map_err(|e| JsValue::from_str(&format!("SRP error: {}", e))) +} + +/// Derive the SRP verifier (v) from a private key. +/// +/// # Arguments +/// * `private_key` - Private key as uppercase hex string +/// +/// # Returns +/// Verifier as uppercase hex string (for registration) +#[wasm_bindgen(js_name = srpDeriveVerifier)] +pub fn srp_derive_verifier_js(private_key: &str) -> Result { + crate::srp::srp_derive_verifier(private_key) + .map_err(|e| JsValue::from_str(&format!("SRP error: {}", e))) +} + +/// Generate a client ephemeral key pair. +/// Returns a JsValue object with `public` and `secret` properties (uppercase hex strings). +#[wasm_bindgen(js_name = srpGenerateEphemeral)] +pub fn srp_generate_ephemeral_js() -> Result { + let ephemeral = crate::srp::srp_generate_ephemeral(); + serde_wasm_bindgen::to_value(&ephemeral) + .map_err(|e| JsValue::from_str(&format!("Failed to serialize ephemeral: {}", e))) +} + +/// Derive the client session from server response. +/// +/// # Arguments +/// * `client_secret` - Client secret ephemeral (a) as hex string +/// * `server_public` - Server public ephemeral (B) as hex string +/// * `salt` - Salt as hex string +/// * `identity` - User identity (username or SRP identity GUID) +/// * `private_key` - Private key (x) as hex string +/// +/// # Returns +/// JsValue object with `proof` and `key` properties (uppercase hex strings) +#[wasm_bindgen(js_name = srpDeriveSession)] +pub fn srp_derive_session_js( + client_secret: &str, + server_public: &str, + salt: &str, + identity: &str, + private_key: &str, +) -> Result { + let session = crate::srp::srp_derive_session(client_secret, server_public, salt, identity, private_key) + .map_err(|e| JsValue::from_str(&format!("SRP error: {}", e)))?; + serde_wasm_bindgen::to_value(&session) + .map_err(|e| JsValue::from_str(&format!("Failed to serialize session: {}", e))) +} + +/// Generate a server ephemeral key pair. +/// +/// # Arguments +/// * `verifier` - Password verifier (v) as hex string +/// +/// # Returns +/// JsValue object with `public` and `secret` properties (uppercase hex strings) +#[wasm_bindgen(js_name = srpGenerateEphemeralServer)] +pub fn srp_generate_ephemeral_server_js(verifier: &str) -> Result { + let ephemeral = crate::srp::srp_generate_ephemeral_server(verifier) + .map_err(|e| JsValue::from_str(&format!("SRP error: {}", e)))?; + serde_wasm_bindgen::to_value(&ephemeral) + .map_err(|e| JsValue::from_str(&format!("Failed to serialize ephemeral: {}", e))) +} + +/// Derive and verify the server session from client response. +/// +/// # Arguments +/// * `server_secret` - Server secret ephemeral (b) as hex string +/// * `client_public` - Client public ephemeral (A) as hex string +/// * `salt` - Salt as hex string +/// * `identity` - User identity (username or SRP identity GUID) +/// * `verifier` - Password verifier (v) as hex string +/// * `client_proof` - Client proof (M1) as hex string +/// +/// # Returns +/// JsValue: object with `proof` and `key` if valid, null if client proof is invalid +#[wasm_bindgen(js_name = srpDeriveSessionServer)] +pub fn srp_derive_session_server_js( + server_secret: &str, + client_public: &str, + salt: &str, + identity: &str, + verifier: &str, + client_proof: &str, +) -> Result { + let session = crate::srp::srp_derive_session_server( + server_secret, + client_public, + salt, + identity, + verifier, + client_proof, + ) + .map_err(|e| JsValue::from_str(&format!("SRP error: {}", e)))?; + + match session { + Some(s) => serde_wasm_bindgen::to_value(&s) + .map_err(|e| JsValue::from_str(&format!("Failed to serialize session: {}", e))), + None => Ok(JsValue::NULL), + } +} + +/// Verify the server's session proof (M2) on the client side. +/// +/// This confirms that the server successfully derived the same session key. +/// +/// # Arguments +/// * `client_public` - Client public ephemeral (A) as hex string +/// * `client_proof` - Client proof (M1) as hex string +/// * `session_key` - Session key (K) as hex string +/// * `server_proof` - Server proof (M2) as hex string to verify +/// +/// # Returns +/// True if verification succeeds, false otherwise +#[wasm_bindgen(js_name = srpVerifySession)] +pub fn srp_verify_session_wasm( + client_public: &str, + client_proof: &str, + session_key: &str, + server_proof: &str, +) -> Result { + crate::srp::srp_verify_session(client_public, client_proof, session_key, server_proof) + .map_err(|e| JsValue::from_str(&format!("SRP error: {}", e))) +}