//----------------------------------------------------------------------- // // 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; using System.Security.Cryptography; using System.Text.Json; using AliasVault.Client.Services.JsInterop.Models; using AliasVault.Shared.Core; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; /// /// JavaScript interop service for calling JavaScript functions from C#. /// /// IJSRuntime. public sealed class JsInteropService(IJSRuntime jsRuntime) { private const string _DEFAULT_VERSION = "0.0.0"; private const string _VAULT_SQL_GENERATOR_FACTORY_FUNCTION = "CreateVaultSqlGenerator"; private readonly string _cacheBuster = AppInfo.GetFullVersion(); private IJSObjectReference? _identityGeneratorModule; private IJSObjectReference? _passwordGeneratorModule; private IJSObjectReference? _vaultSqlInteropModule; /// /// Initialize the identity generator module. /// /// A task representing the asynchronous operation. public async Task InitializeAsync() { _identityGeneratorModule = await jsRuntime.InvokeAsync("import", $"./js/dist/core/identity-generator/index.mjs?v={_cacheBuster}"); if (_identityGeneratorModule == null) { throw new InvalidOperationException("Failed to initialize identity generator module"); } _passwordGeneratorModule = await jsRuntime.InvokeAsync("import", $"./js/dist/core/password-generator/index.mjs?v={_cacheBuster}"); if (_passwordGeneratorModule == null) { throw new InvalidOperationException("Failed to initialize password generator module"); } _vaultSqlInteropModule = await jsRuntime.InvokeAsync("import", $"./js/dist/core/vault/index.mjs?v={_cacheBuster}"); if (_vaultSqlInteropModule == null) { throw new InvalidOperationException("Failed to initialize vault SQL generator module"); } } /// /// Symmetrically encrypts a string using the provided encryption key. /// /// Plain text to encrypt. /// Encryption key to use. /// Encrypted ciphertext. public async Task SymmetricEncrypt(string plaintext, string encryptionKey) { if (string.IsNullOrEmpty(plaintext)) { return plaintext; } return await jsRuntime.InvokeAsync("cryptoInterop.encrypt", plaintext, encryptionKey); } /// /// Symmetrically decrypts a string using the provided encryption key. /// /// Cipher text to decrypt. /// Encryption key to use. /// Encrypted ciphertext. public async Task SymmetricDecrypt(string ciphertext, string encryptionKey) { if (string.IsNullOrEmpty(ciphertext)) { return ciphertext; } return await jsRuntime.InvokeAsync("cryptoInterop.decrypt", ciphertext, encryptionKey); } /// /// Downloads a file from a stream. /// /// Filename of the download. /// Blob byte array to download. /// Task. public async Task DownloadFileFromStream(string filename, byte[] blob) => await jsRuntime.InvokeVoidAsync("downloadFileFromStream", filename, blob); /// /// Focus an element by its ID. /// /// The element ID to focus. /// Task. public async Task FocusElementById(string elementId) => await jsRuntime.InvokeVoidAsync("focusElement", elementId); /// /// Focus an element by its ID and select all its text. /// /// The element ID to focus and select. /// Task. public async Task FocusAndSelectElementById(string elementId) => await jsRuntime.InvokeVoidAsync("focusAndSelectElement", elementId); /// /// Blur (defocus) an element by its ID. /// /// The element ID to focus. /// Task. public async Task BlurElementById(string elementId) => await jsRuntime.InvokeVoidAsync("blurElement", elementId); /// /// Copy a string to the browser's clipboard. /// /// Value to copy to clipboard. /// Task. public async Task CopyToClipboard(string value) => await jsRuntime.InvokeVoidAsync("navigator.clipboard.writeText", value); /// /// Copy a string to the clipboard and schedule automatic clearing after specified seconds. /// /// Value to copy to clipboard. /// Number of seconds after which to clear the clipboard. /// True if copy was successful, false otherwise. public async Task CopyToClipboardWithClear(string value, int clearAfterSeconds) => await jsRuntime.InvokeAsync("copyToClipboardWithClear", value, clearAfterSeconds); /// /// Clear the clipboard safely, handling document focus issues. /// /// True if clipboard was cleared successfully, false otherwise. public async Task SafeClearClipboard() => await jsRuntime.InvokeAsync("safeClearClipboard"); /// /// Clear the clipboard by setting it to an empty string. /// /// Task. public async Task ClearClipboard() => await jsRuntime.InvokeVoidAsync("navigator.clipboard.writeText", string.Empty); /// /// Register a callback for clipboard status changes. /// /// Component type. /// DotNetObjectReference. /// Task. public async Task RegisterClipboardStatusCallback(DotNetObjectReference objRef) where TComponent : class => await jsRuntime.InvokeVoidAsync("registerClipboardStatusCallback", objRef); /// /// Unregister the clipboard status callback. /// /// Task. public async Task UnregisterClipboardStatusCallback() => await jsRuntime.InvokeVoidAsync("unregisterClipboardStatusCallback"); /// /// Initializes the top menu. /// /// Task. public async Task InitTopMenu() => await jsRuntime.InvokeVoidAsync("window.initTopMenu"); /// /// Registers a click outside handler for the top menu. /// /// Component type. /// DotNetObjectReference. /// Task. public async Task TopMenuClickOutsideHandler(DotNetObjectReference objRef) where TComponent : class => await jsRuntime.InvokeVoidAsync("window.topMenuClickOutsideHandler", objRef); /// /// Registers a callback for dark mode theme changes. /// /// Component type. /// DotNetObjectReference. /// Task. public async Task RegisterDarkModeCallback(DotNetObjectReference objRef) where TComponent : class => await jsRuntime.InvokeVoidAsync("window.registerDarkModeCallback", objRef); /// /// Generates a new RSA key pair. /// /// Tuple with public and private key. public async Task<(string PublicKey, string PrivateKey)> GenerateRsaKeyPair() { var result = await jsRuntime.InvokeAsync("rsaInterop.generateRsaKeyPair"); return (result.GetProperty("publicKey").GetString()!, result.GetProperty("privateKey").GetString()!); } /// /// Encrypts a plaintext with a public key. /// /// Plain text to encrypt. /// Public key to use for encryption. /// Encrypted ciphertext. public async Task EncryptWithPublicKey(string plaintext, string publicKey) => await jsRuntime.InvokeAsync("rsaInterop.encryptWithPublicKey", plaintext, publicKey); /// /// Decrypts a ciphertext with a private key. /// /// Ciphertext to decrypt. /// Private key to use for decryption. /// Decrypted bytes as base64 string. public async Task DecryptWithPrivateKey(string base64Ciphertext, string privateKey) { try { // Invoke the JavaScript function and get the result as a base64 string string result = await jsRuntime.InvokeAsync("rsaInterop.decryptWithPrivateKey", base64Ciphertext, privateKey); return result; } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript decryption error: {ex.Message}"); throw new CryptographicException("Decryption failed", ex); } } /// /// Generates a QR code. /// /// Element ID that contains data-url attribute which to generate QR code for. /// Task. public async Task GenerateQrCode(string elementId) { try { // Invoke the JavaScript function and get the result as a byte array await jsRuntime.InvokeVoidAsync("generateQrCode", elementId); } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error: {ex.Message}"); } } /// /// Gets the WebAuthn credential derived key used to encrypt or decrypt the persisted vault encryption key. /// /// The credential ID to use. /// The salt to use. /// Derived key as base64 string. /// Thrown when the authenticator does not support the PRF extension. /// Thrown when the PRF key derivation fails. /// Thrown when there's an error getting the WebAuthn credential or when decryption fails. public async Task GetWebAuthnCredentialDerivedKey(string credentialId, string salt) { try { var result = await jsRuntime.InvokeAsync("getWebAuthnCredentialAndDeriveKey", credentialId, salt); if (result.Error is null) { return result.DerivedKey ?? throw new CryptographicException("Derived key is null"); } throw result.Error switch { "PRF_NOT_SUPPORTED" => new NotSupportedException("Authenticator does not support the PRF extension."), "PRF_DERIVATION_FAILED" => new InvalidOperationException("Failed to derive key using PRF extension."), "WEBAUTHN_CREATE_ERROR" => new CryptographicException($"Failed to create WebAuthn credential: {result.Message}"), _ => new CryptographicException($"Unknown error occurred: {result.Error ?? "No error specified"}"), }; } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error: {ex.Message}"); throw new CryptographicException("Decryption failed", ex); } } /// /// Gets or creates the WebAuthn credential derived key used to encrypt or decrypt the persisted vault encryption key. /// /// The username for the credential. /// A tuple containing the credential ID, salt and the derived key. /// Thrown when decryption fails due to a JavaScript error. /// Thrown when the authenticator does not support the PRF extension. /// Thrown when the PRF key derivation fails. public async Task<(string CredentialId, string Salt, string DerivedKey)> CreateWebAuthnCredentialDerivedKey(string username) { try { var result = await jsRuntime.InvokeAsync("createWebAuthnCredentialAndDeriveKey", username); if (result.CredentialId is not null && result.Salt is not null && result.DerivedKey is not null) { return (result.CredentialId, result.Salt, result.DerivedKey); } throw result.Error switch { "PRF_NOT_SUPPORTED" => new NotSupportedException("Authenticator does not support the PRF extension."), "PRF_DERIVATION_FAILED" => new InvalidOperationException("Failed to derive key using PRF extension."), "WEBAUTHN_CREATE_ERROR" => new CryptographicException($"Failed to create WebAuthn credential: {result.Message}"), _ => new CryptographicException($"Unknown error occurred: {result.Error ?? "No error specified"}"), }; } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error: {ex.Message}"); throw new CryptographicException("Decryption failed", ex); } } /// /// Scrolls to the top of the page. /// /// Task. public async Task ScrollToTop() { await jsRuntime.InvokeVoidAsync("window.scrollTo", 0, 0); } /// /// Sets up an IntersectionObserver for infinite scrolling. /// /// Component type. /// The sentinel element to observe. /// DotNetObjectReference. /// Task. public async Task SetupInfiniteScroll(ElementReference element, DotNetObjectReference objRef) where TComponent : class => await jsRuntime.InvokeVoidAsync("window.setupInfiniteScroll", element, objRef); /// /// Tears down the IntersectionObserver for infinite scrolling. /// /// The sentinel element that was observed. /// Task. public async Task TeardownInfiniteScroll(ElementReference element) => await jsRuntime.InvokeVoidAsync("window.teardownInfiniteScroll", element); /// /// Registers a visibility callback which is invoked when the visibility of component changes in client. /// /// Component type. /// DotNetObjectReference. /// Task. public async Task RegisterVisibilityCallback(DotNetObjectReference objRef) where TComponent : class => await jsRuntime.InvokeVoidAsync("window.registerVisibilityCallback", objRef); /// /// Unregisters the visibility callback to prevent memory leaks. /// /// Component type. /// DotNetObjectReference. /// Task. public async Task UnregisterVisibilityCallback(DotNetObjectReference objRef) where TComponent : class => await jsRuntime.InvokeVoidAsync("window.unregisterVisibilityCallback", objRef); /// /// Symmetrically decrypts a byte array using the provided encryption key. /// /// Cipher bytes to decrypt. /// Encryption key to use. /// Decrypted bytes. public async Task SymmetricDecryptBytes(byte[] cipherBytes, string encryptionKey) { if (cipherBytes == null || cipherBytes.Length == 0) { return []; } var base64Ciphertext = Convert.ToBase64String(cipherBytes); return await jsRuntime.InvokeAsync("cryptoInterop.decryptBytes", base64Ciphertext, encryptionKey); } /// /// Gets all available languages for identity generation. /// /// Array of language options. public async Task> GetAvailableIdentityGeneratorLanguagesAsync() { try { if (_identityGeneratorModule == null) { await InitializeAsync(); } var result = await _identityGeneratorModule!.InvokeAsync>("getAvailableLanguages"); return result ?? new List(); } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error getting available languages: {ex.Message}"); return new List(); } } /// /// Gets all available age range options from the shared JavaScript utility. /// /// Array of age range options. public async Task> GetAvailableIdentityGeneratorAgeRangesAsync() { try { if (_identityGeneratorModule == null) { await InitializeAsync(); } var result = await _identityGeneratorModule!.InvokeAsync>("getAvailableAgeRanges"); return result ?? new List(); } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error getting age ranges: {ex.Message}"); return new List(); } } /// /// Maps a UI language code to an identity generator language code. /// If no explicit match is found, returns null to indicate no preference. /// /// The UI language code (e.g., "en", "en-US", "nl-NL", "de-DE", "fr"). /// The matching identity generator language code or null if no match. public async Task MapUiLanguageToIdentityLanguageAsync(string? uiLanguageCode) { try { if (string.IsNullOrEmpty(uiLanguageCode)) { return null; } if (_identityGeneratorModule == null) { await InitializeAsync(); } var result = await _identityGeneratorModule!.InvokeAsync("mapUiLanguageToIdentityLanguage", uiLanguageCode); return result; } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error mapping UI language to identity language: {ex.Message}"); return null; } } /// /// Converts an age range string to birthdate options using the shared JavaScript utility. /// /// Age range string (e.g., "21-25", "30-35", or "random"). /// Birthdate options object or null if random. public async Task ConvertAgeRangeToBirthdateOptionsAsync(string ageRange) { try { if (_identityGeneratorModule == null) { await InitializeAsync(); } var result = await _identityGeneratorModule!.InvokeAsync("convertAgeRangeToBirthdateOptions", ageRange); return result; } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error converting age range: {ex.Message}"); return null; } } /// /// Generates a random identity using the specified language. /// /// The language to use for generating the identity (e.g. "en", "nl", "de"). /// The gender preference for generating the identity (defaults to "random"). /// Optional birthdate options (targetYear and yearDeviation). /// An AliasVaultIdentity containing the generated identity information. public async Task GenerateRandomIdentityAsync(string language, string? gender = null, object? birthdateOptions = null) { try { if (_identityGeneratorModule == null) { await InitializeAsync(); } var generatorInstance = await _identityGeneratorModule!.InvokeAsync("CreateIdentityGenerator", language); // Use "random" as default if gender is null or empty var genderValue = "random"; if (!string.IsNullOrEmpty(gender)) { genderValue = gender; } var result = await generatorInstance.InvokeAsync("generateRandomIdentity", genderValue, birthdateOptions); return result; } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error generating identity: {ex.Message}"); throw new InvalidOperationException("Failed to generate random identity", ex); } } /// /// Generates a random username. /// /// The identity to use for generating the username. /// The generated username. public async Task GenerateRandomUsernameAsync(AliasVaultIdentity identity) { try { if (_identityGeneratorModule == null) { await InitializeAsync(); } var generatorInstance = await _identityGeneratorModule!.InvokeAsync("CreateUsernameEmailGenerator"); var result = await generatorInstance.InvokeAsync("generateUsername", identity); return result; } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error generating username: {ex.Message}"); throw new InvalidOperationException("Failed to generate random username", ex); } } /// /// Generates a random string email prefix (not based on any identity). /// Uses random alphanumeric characters, suitable for login-type credentials /// where no persona fields are available to base the email on. /// /// The generated random email prefix. public async Task GenerateRandomStringEmailPrefixAsync() { try { if (_identityGeneratorModule == null) { await InitializeAsync(); } var generatorInstance = await _identityGeneratorModule!.InvokeAsync("CreateUsernameEmailGenerator"); var result = await generatorInstance.InvokeAsync("generateRandomEmailPrefix"); return result; } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error generating random string email prefix: {ex.Message}"); throw new InvalidOperationException("Failed to generate random string email prefix", ex); } } /// /// Generates a random email prefix based on an identity. /// /// The identity to use for generating the email prefix. /// The generated email prefix. public async Task GenerateRandomEmailPrefixAsync(AliasVaultIdentity identity) { try { if (_identityGeneratorModule == null) { await InitializeAsync(); } var generatorInstance = await _identityGeneratorModule!.InvokeAsync("CreateUsernameEmailGenerator"); var result = await generatorInstance.InvokeAsync("generateEmailPrefix", identity); return result; } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error generating email prefix: {ex.Message}"); throw new InvalidOperationException("Failed to generate random email prefix", ex); } } /// /// Generates a random password using the specified settings. /// /// The password settings to use. /// The generated password. public async Task GenerateRandomPasswordAsync(PasswordSettings settings) { try { if (_passwordGeneratorModule == null) { await InitializeAsync(); } var generatorInstance = await _passwordGeneratorModule!.InvokeAsync("CreatePasswordGenerator", settings); var result = await generatorInstance.InvokeAsync("generateRandomPassword"); return result; } catch (JSException ex) { await Console.Error.WriteLineAsync($"JavaScript error generating password: {ex.Message}"); throw new InvalidOperationException("Failed to generate random password", ex); } } /// /// Gets SQL commands to create a new vault with the latest schema. /// /// SQL generation result with commands to execute. public async Task GetCreateVaultSqlAsync() { try { if (_vaultSqlInteropModule == null) { await InitializeAsync(); } var vaultGenerator = await _vaultSqlInteropModule!.InvokeAsync(_VAULT_SQL_GENERATOR_FACTORY_FUNCTION); var result = await vaultGenerator.InvokeAsync("getCreateVaultSql"); return new SqlGenerationResult { Success = result.GetProperty("success").GetBoolean(), SqlCommands = [.. result.GetProperty("sqlCommands").EnumerateArray() .Select(x => x.GetString() ?? string.Empty) .Where(x => !string.IsNullOrEmpty(x))], Version = result.GetProperty("version").GetString() ?? _DEFAULT_VERSION, MigrationNumber = result.GetProperty("migrationNumber").GetInt32(), Error = result.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null, }; } catch (JSException ex) { return new SqlGenerationResult { Success = false, SqlCommands = [], Version = _DEFAULT_VERSION, MigrationNumber = 0, Error = $"JavaScript error: {ex.Message}", }; } } /// /// Gets all available vault versions. /// /// List of vault versions. public async Task> GetAllVaultVersionsAsync() { if (_vaultSqlInteropModule == null) { await InitializeAsync(); } var vaultGenerator = await _vaultSqlInteropModule!.InvokeAsync(_VAULT_SQL_GENERATOR_FACTORY_FUNCTION); var result = await vaultGenerator.InvokeAsync("getAllVersions"); return result.EnumerateArray().Select(x => new SqlVaultVersion { Revision = x.GetProperty("revision").GetInt32(), Version = x.GetProperty("version").GetString() ?? string.Empty, Description = x.GetProperty("description").GetString() ?? string.Empty, ReleaseVersion = x.GetProperty("releaseVersion").GetString() ?? string.Empty, CompatibleUpToVersion = x.GetProperty("compatibleUpToVersion").GetString() ?? string.Empty, }).ToList(); } /// /// Gets SQL commands to check current vault version. /// /// Array of SQL commands to execute. public async Task GetLatestVaultVersionAsync() { if (_vaultSqlInteropModule == null) { await InitializeAsync(); } var vaultGenerator = await _vaultSqlInteropModule!.InvokeAsync(_VAULT_SQL_GENERATOR_FACTORY_FUNCTION); var result = await vaultGenerator.InvokeAsync("getLatestVersion"); return new SqlVaultVersion { Revision = result.GetProperty("revision").GetInt32(), Version = result.GetProperty("version").GetString() ?? string.Empty, Description = result.GetProperty("description").GetString() ?? string.Empty, ReleaseVersion = result.GetProperty("releaseVersion").GetString() ?? string.Empty, }; } /// /// Checks if a database version is compatible with the current client using semantic versioning. /// Returns true if the version is compatible (known version or same major version). /// Returns false if the version is incompatible (different major version). /// /// The database version to check (e.g., "1.6.0"). /// True if compatible, false otherwise. public async Task IsVersionCompatibleAsync(string databaseVersion) { try { if (_vaultSqlInteropModule == null) { await InitializeAsync(); } var result = await _vaultSqlInteropModule!.InvokeAsync("checkVersionCompatibility", databaseVersion); return result.GetProperty("isCompatible").GetBoolean(); } catch (JSException) { return false; } } /// /// Gets SQL commands to upgrade vault from current to target migration. /// /// Current migration number. /// Target migration number (optional, defaults to latest). /// SQL generation result with commands to execute. public async Task GetUpgradeVaultSqlAsync(int currentMigrationNumber, int? targetMigrationNumber = null) { try { if (_vaultSqlInteropModule == null) { await InitializeAsync(); } var vaultGenerator = await _vaultSqlInteropModule!.InvokeAsync(_VAULT_SQL_GENERATOR_FACTORY_FUNCTION); var result = targetMigrationNumber.HasValue ? await vaultGenerator.InvokeAsync("getUpgradeVaultSql", currentMigrationNumber, targetMigrationNumber.Value) : await vaultGenerator.InvokeAsync("getUpgradeToLatestSql", currentMigrationNumber); return new SqlGenerationResult { Success = result.GetProperty("success").GetBoolean(), SqlCommands = [.. result.GetProperty("sqlCommands").EnumerateArray() .Select(x => x.GetString() ?? string.Empty) .Where(x => !string.IsNullOrEmpty(x))], Version = result.GetProperty("version").GetString() ?? _DEFAULT_VERSION, MigrationNumber = result.GetProperty("migrationNumber").GetInt32(), Error = result.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null, }; } catch (JSException ex) { return new SqlGenerationResult { Success = false, SqlCommands = [], Version = _DEFAULT_VERSION, MigrationNumber = 0, Error = $"JavaScript error: {ex.Message}", }; } } /// /// Validates vault structure from table names. /// /// List of table names found in database. /// True if vault structure is valid. public async Task ValidateVaultStructureAsync(string[] tableNames) { try { if (_vaultSqlInteropModule == null) { await InitializeAsync(); } var vaultGenerator = await _vaultSqlInteropModule!.InvokeAsync(_VAULT_SQL_GENERATOR_FACTORY_FUNCTION); return await vaultGenerator.InvokeAsync("validateVaultStructure", tableNames); } catch (JSException) { return false; } } /// /// Represents the result of a WebAuthn get credential operation. /// private sealed class WebAuthnGetCredentialResult { /// /// Gets the derived key. /// Note: init is required for JSON deserialization. /// public string? DerivedKey { get; init; } /// /// Gets the optional error message. /// Note: init is required for JSON deserialization. /// public string? Error { get; init; } /// /// Gets the optional additional error details. /// Note: init is required for JSON deserialization. /// public string? Message { get; init; } } /// /// Represents the result of a WebAuthn credential operation. /// private sealed class WebAuthnCreateCredentialResult { /// /// Gets the credential ID as a base64 string. /// Note: init is required for JSON deserialization. /// public string? CredentialId { get; init; } /// /// Gets the salt as a base64 string. /// Note: init is required for JSON deserialization. /// public string? Salt { get; init; } /// /// Gets the derived key as a base64 string. /// Note: init is required for JSON deserialization. /// public string? DerivedKey { get; init; } /// /// Gets the optional error message. /// Note: init is required for JSON deserialization. /// public string? Error { get; init; } /// /// Gets the optional additional error details. /// Note: init is required for JSON deserialization. /// public string? Message { get; init; } } }