Implement SRP logic in Rust Core lib, and implement in browser extension and aliasvault.client (#1404)

This commit is contained in:
Leendert de Borst
2026-01-13 15:31:05 +01:00
parent 8bee44851f
commit 7fbffa2cd2
19 changed files with 2047 additions and 71 deletions

View File

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

View File

@@ -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<void> | 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<void> {
if (this.wasmInitialized) {
return;
}
// Ensure we only initialize once even with concurrent calls
if (this.wasmInitPromise) {
return this.wasmInitPromise;
}
this.wasmInitPromise = (async (): Promise<void> => {
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<string> {
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<string> {
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<string> {
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<SrpEphemeral> {
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<SrpSession> {
await this.initWasm();
return srpDeriveSession(
clientSecretEphemeral,
serverPublicEphemeral,
salt,
SrpAuthService.normalizeUsername(username),
privateKey
);
) as SrpSession;
}
/**
@@ -221,7 +290,7 @@ export class SrpAuthService {
password: string
): Promise<RegisterRequest> {
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,

View File

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

View File

@@ -5,7 +5,6 @@
@using AliasVault.Client.Utilities
@using AliasVault.Cryptography.Client
@using AliasVault.Shared.Models.WebApi.Auth
@using SecureRemotePassword
<div class="w-full mx-auto">
<div class="relative inset-0 mt-10 z-10">

View File

@@ -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
<LayoutPageTitle>@Localizer["PageTitle"]</LayoutPageTitle>
@@ -101,6 +102,7 @@ else
private SrpEphemeral ClientEphemeral = new();
private SrpSession ClientSession = new();
private string PrivateKey = string.Empty;
/// <inheritdoc />
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.

View File

@@ -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
<LayoutPageTitle>@Localizer["PageTitle"]</LayoutPageTitle>
@@ -99,12 +100,17 @@
/// <summary>
/// The ephemeral client for SRP.
/// </summary>
private SrpEphemeral ClientEphemeral { get; set; } = null!;
private SrpEphemeral ClientEphemeral { get; set; } = new();
/// <summary>
/// The session client for SRP.
/// </summary>
private SrpSession ClientSession { get; set; } = null!;
private SrpSession ClientSession { get; set; } = new();
/// <summary>
/// The SRP private key.
/// </summary>
private string PrivateKey { get; set; } = string.Empty;
/// <inheritdoc />
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,

View File

@@ -102,6 +102,7 @@ builder.Services.AddScoped<ConfirmModalService>();
builder.Services.AddScoped<QuickCreateStateService>();
builder.Services.AddScoped<LanguageService>();
builder.Services.AddScoped<RustCoreService>();
builder.Services.AddScoped<SrpService>();
builder.Services.AddAuthorizationCore();
builder.Services.AddBlazoredLocalStorage();

View File

@@ -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;
/// <summary>
/// Service responsible for handling user registration operations.
@@ -24,7 +24,8 @@ using SecureRemotePassword;
/// <param name="authService">The service handling authentication operations.</param>
/// <param name="config">The application configuration.</param>
/// <param name="localizerFactory">The string localizer factory for localization.</param>
public class UserRegistrationService(HttpClient httpClient, AuthenticationStateProvider authStateProvider, AuthService authService, Config config, IStringLocalizerFactory localizerFactory)
/// <param name="srpService">The SRP service for secure authentication.</param>
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();

View File

@@ -0,0 +1,24 @@
//-----------------------------------------------------------------------
// <copyright file="SrpEphemeral.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.JsInterop.RustCore;
/// <summary>
/// SRP Ephemeral keypair with public and secret components.
/// </summary>
public class SrpEphemeral
{
/// <summary>
/// Gets or sets the public ephemeral value (uppercase hex string).
/// </summary>
public string Public { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the secret ephemeral value (uppercase hex string).
/// </summary>
public string Secret { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,180 @@
//-----------------------------------------------------------------------
// <copyright file="SrpService.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.JsInterop.RustCore;
using Microsoft.JSInterop;
/// <summary>
/// JavaScript interop wrapper for the Rust WASM SRP (Secure Remote Password) library.
/// Provides SRP authentication functionality via WASM.
/// </summary>
public class SrpService : IAsyncDisposable
{
private readonly IJSRuntime jsRuntime;
private readonly RustCoreService rustCoreService;
/// <summary>
/// Initializes a new instance of the <see cref="SrpService"/> class.
/// </summary>
/// <param name="jsRuntime">The JS runtime for interop.</param>
/// <param name="rustCoreService">The Rust core service for WASM availability checks.</param>
public SrpService(IJSRuntime jsRuntime, RustCoreService rustCoreService)
{
this.jsRuntime = jsRuntime;
this.rustCoreService = rustCoreService;
}
/// <summary>
/// Generate a random salt for SRP registration.
/// </summary>
/// <returns>64-character uppercase hex string (32 bytes).</returns>
/// <exception cref="InvalidOperationException">Thrown if WASM module is unavailable.</exception>
public async Task<string> GenerateSaltAsync()
{
if (!await rustCoreService.WaitForAvailabilityAsync())
{
throw new InvalidOperationException("Rust WASM module is not available.");
}
return await jsRuntime.InvokeAsync<string>("rustCoreSrpGenerateSalt");
}
/// <summary>
/// Derive a private key from salt, identity, and password hash.
/// </summary>
/// <param name="salt">The salt (hex string).</param>
/// <param name="identity">The SRP identity (username or GUID), will be lowercased.</param>
/// <param name="passwordHash">The password hash (hex string).</param>
/// <returns>64-character uppercase hex string (32 bytes).</returns>
/// <exception cref="InvalidOperationException">Thrown if WASM module is unavailable.</exception>
public async Task<string> 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<string>("rustCoreSrpDerivePrivateKey", salt, identity, passwordHash);
}
/// <summary>
/// Derive a verifier from a private key.
/// </summary>
/// <param name="privateKey">The private key (hex string).</param>
/// <returns>512-character uppercase hex string (256 bytes).</returns>
/// <exception cref="InvalidOperationException">Thrown if WASM module is unavailable.</exception>
public async Task<string> DeriveVerifierAsync(string privateKey)
{
if (!await rustCoreService.WaitForAvailabilityAsync())
{
throw new InvalidOperationException("Rust WASM module is not available.");
}
return await jsRuntime.InvokeAsync<string>("rustCoreSrpDeriveVerifier", privateKey);
}
/// <summary>
/// Generate client ephemeral keypair.
/// </summary>
/// <returns>Ephemeral object with Public and Secret hex strings.</returns>
/// <exception cref="InvalidOperationException">Thrown if WASM module is unavailable.</exception>
public async Task<SrpEphemeral> GenerateEphemeralAsync()
{
if (!await rustCoreService.WaitForAvailabilityAsync())
{
throw new InvalidOperationException("Rust WASM module is not available.");
}
return await jsRuntime.InvokeAsync<SrpEphemeral>("rustCoreSrpGenerateEphemeral");
}
/// <summary>
/// Derive client session from ephemeral values.
/// </summary>
/// <param name="clientSecret">Client ephemeral secret (hex string).</param>
/// <param name="serverPublic">Server ephemeral public (hex string).</param>
/// <param name="salt">The salt (hex string).</param>
/// <param name="identity">The SRP identity, will be lowercased.</param>
/// <param name="privateKey">The private key (hex string).</param>
/// <returns>Session object with Key and Proof hex strings.</returns>
/// <exception cref="InvalidOperationException">Thrown if WASM module is unavailable.</exception>
public async Task<SrpSession> 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<SrpSession>("rustCoreSrpDeriveSession", clientSecret, serverPublic, salt, identity, privateKey);
}
/// <summary>
/// Prepare password change/registration by generating salt and verifier.
/// </summary>
/// <param name="identity">The SRP identity (username or GUID).</param>
/// <param name="passwordHashString">The password hash as hex string.</param>
/// <returns>Tuple with Salt and Verifier.</returns>
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);
}
/// <summary>
/// Derive session client-side (convenience method matching old Srp.DeriveSessionClient signature).
/// </summary>
/// <param name="privateKey">The private key.</param>
/// <param name="clientSecretEphemeral">Client ephemeral secret.</param>
/// <param name="serverEphemeralPublic">Server public ephemeral.</param>
/// <param name="salt">Salt.</param>
/// <param name="identity">Identity.</param>
/// <returns>SrpSession.</returns>
public async Task<SrpSession> DeriveSessionClientAsync(string privateKey, string clientSecretEphemeral, string serverEphemeralPublic, string salt, string identity)
{
return await DeriveSessionAsync(clientSecretEphemeral, serverEphemeralPublic, salt, identity, privateKey);
}
/// <summary>
/// Verify the server's session proof (M2) on the client side.
/// </summary>
/// <param name="clientPublic">Client public ephemeral (A).</param>
/// <param name="clientSession">Client session containing proof (M1) and key (K).</param>
/// <param name="serverProof">Server proof (M2) to verify.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="InvalidOperationException">Thrown if WASM module is unavailable.</exception>
/// <exception cref="System.Security.SecurityException">Thrown if verification fails.</exception>
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<bool>("rustCoreSrpVerifySession", clientPublic, clientSession.Proof, clientSession.Key, serverProof);
if (!result)
{
throw new System.Security.SecurityException("Server session proof verification failed.");
}
}
/// <inheritdoc/>
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,24 @@
//-----------------------------------------------------------------------
// <copyright file="SrpSession.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.JsInterop.RustCore;
/// <summary>
/// SRP Session with key and proof.
/// </summary>
public class SrpSession
{
/// <summary>
/// Gets or sets the session key (uppercase hex string).
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the session proof (uppercase hex string).
/// </summary>
public string Proof { get; set; } = string.Empty;
}

View File

@@ -230,3 +230,130 @@ window.rustCorePruneVault = async function(inputJson) {
});
}
};
// ============================================================================
// SRP (Secure Remote Password) Functions
// ============================================================================
/**
* Generate a random salt for SRP registration.
* @returns {Promise<string>} 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<string>} 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<string>} 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<boolean>} 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;
}
};

206
core/rust/Cargo.lock generated
View File

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

View File

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

View File

@@ -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::*;

View File

@@ -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")]

768
core/rust/src/srp/mod.rs Normal file
View File

@@ -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::<String>()
}
/// Convert hex string to bytes.
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, 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<u8> {
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<u8>, target_len: usize) -> Vec<u8> {
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<String, SrpError> {
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<String, SrpError> {
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::<Sha256>::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<SrpSession, SrpError> {
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::<Sha256>::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<SrpEphemeral, SrpError> {
let v_bytes = hex_to_bytes(verifier)?;
let server = SrpServer::<Sha256>::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<Option<SrpSession>, 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<u8> {
// 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<u8> = 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<u8> {
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<bool, SrpError> {
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);
}
}

View File

@@ -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<String, SrpError> {
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<String, SrpError> {
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<SrpSession, SrpError> {
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<SrpEphemeral, SrpError> {
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<Option<SrpSession>, SrpError> {
crate::srp::srp_derive_session_server(
&server_secret,
&client_public,
&salt,
&identity,
&verifier,
&client_proof,
)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -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<String, JsValue> {
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<String, JsValue> {
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<JsValue, JsValue> {
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<JsValue, JsValue> {
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<JsValue, JsValue> {
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<JsValue, JsValue> {
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<bool, JsValue> {
crate::srp::srp_verify_session(client_public, client_proof, session_key, server_proof)
.map_err(|e| JsValue::from_str(&format!("SRP error: {}", e)))
}