From d6a024a7bbe49a19fa61fd49f6003db50a846cc6 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 15 Mar 2026 15:21:05 +0100 Subject: [PATCH] Add AvexCryptoService.cs (#773) --- apps/server/AliasVault.Client/Program.cs | 2 + .../Services/Crypto/AvexCryptoService.cs | 224 ++++++++++++++++++ .../Services/JsInterop/JsInteropService.cs | 42 ++++ .../wwwroot/js/encryption-utils.js | 68 ++++++ .../AliasVault.ImportExport.csproj | 2 + .../Models/Exports/AvexHeader.cs | 92 +++++++ .../VaultEncryptedExportService.cs | 114 +++++++++ .../VaultEncryptedImportService.cs | 194 +++++++++++++++ 8 files changed, 738 insertions(+) create mode 100644 apps/server/AliasVault.Client/Services/Crypto/AvexCryptoService.cs create mode 100644 apps/server/Utilities/AliasVault.ImportExport/Models/Exports/AvexHeader.cs create mode 100644 apps/server/Utilities/AliasVault.ImportExport/VaultEncryptedExportService.cs create mode 100644 apps/server/Utilities/AliasVault.ImportExport/VaultEncryptedImportService.cs diff --git a/apps/server/AliasVault.Client/Program.cs b/apps/server/AliasVault.Client/Program.cs index 46fb2ab38..f1459d5aa 100644 --- a/apps/server/AliasVault.Client/Program.cs +++ b/apps/server/AliasVault.Client/Program.cs @@ -9,6 +9,7 @@ using System.Globalization; using AliasVault.Client; using AliasVault.Client.Main.Services; using AliasVault.Client.Providers; +using AliasVault.Client.Services.Crypto; using AliasVault.Client.Services.JsInterop.RustCore; using AliasVault.RazorComponents.Services; using AliasVault.Shared.Core; @@ -100,6 +101,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddAuthorizationCore(); builder.Services.AddBlazoredLocalStorage(); diff --git a/apps/server/AliasVault.Client/Services/Crypto/AvexCryptoService.cs b/apps/server/AliasVault.Client/Services/Crypto/AvexCryptoService.cs new file mode 100644 index 000000000..7e69dd737 --- /dev/null +++ b/apps/server/AliasVault.Client/Services/Crypto/AvexCryptoService.cs @@ -0,0 +1,224 @@ +//----------------------------------------------------------------------- +// +// 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.Crypto; + +using System.Text; +using System.Text.Json; +using AliasVault.Client.Services.JsInterop; +using AliasVault.ImportExport.Models.Exports; + +/// +/// Provides cryptographic operations for .avex encrypted vault export format. +/// Handles encryption/decryption of .avex files using Argon2id KDF and AES-256-GCM. +/// +public class AvexCryptoService +{ + private const string HeaderDelimiter = "\n--- ENCRYPTED PAYLOAD FOLLOWS ---\n"; + private readonly JsInteropService jsInteropService; + + /// + /// Initializes a new instance of the class. + /// + /// The JS interop service. + public AvexCryptoService(JsInteropService jsInteropService) + { + this.jsInteropService = jsInteropService; + } + + /// + /// Encrypts vault data to .avex format using Web Crypto API. + /// Uses Argon2id for key derivation and AES-256-GCM for encryption. + /// + /// The unencrypted .avux bytes. + /// The password to encrypt with. + /// The username creating the export. + /// The encrypted .avex file bytes. + public async Task EncryptToAvexAsync(byte[] avuxBytes, string exportPassword, string username) + { + // 1. Generate random salt + var salt = await this.jsInteropService.GenerateSalt(32); + + // 2. Derive key using Argon2id (same as existing vault encryption) + var saltBase64 = Convert.ToBase64String(salt); + var key = await AliasVault.Cryptography.Client.Encryption.DeriveKeyFromPasswordAsync( + exportPassword, + saltBase64, + AliasVault.Cryptography.Client.Defaults.EncryptionType, + AliasVault.Cryptography.Client.Defaults.EncryptionSettings); + + // 3. Encrypt the .avux bytes using AES-256-GCM via JavaScript + var encryptedPayload = await this.jsInteropService.SymmetricEncryptBytes(avuxBytes, key); + + // 4. Create header + var header = new AvexHeader + { + Format = "avex", + Version = "1.0.0", + Kdf = new KdfParams + { + Type = AliasVault.Cryptography.Client.Defaults.EncryptionType, // Argon2Id + Salt = saltBase64, + Params = new Dictionary + { + ["DegreeOfParallelism"] = AliasVault.Cryptography.Client.Defaults.Argon2IdDegreeOfParallelism, + ["MemorySize"] = AliasVault.Cryptography.Client.Defaults.Argon2IdMemorySize, + ["Iterations"] = AliasVault.Cryptography.Client.Defaults.Argon2IdIterations, + }, + }, + Encryption = new EncryptionParams + { + Algorithm = "AES-256-GCM", + EncryptedDataOffset = 0, + }, + Metadata = new AvexMetadata + { + ExportedAt = DateTime.UtcNow, + ExportedBy = username, + }, + }; + + // 5. Serialize header + var jsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + var headerJson = JsonSerializer.Serialize(header, jsonOptions); + var headerBytes = Encoding.UTF8.GetBytes(headerJson); + var delimiterBytes = Encoding.UTF8.GetBytes(HeaderDelimiter); + + // 6. Calculate offset + header.Encryption.EncryptedDataOffset = headerBytes.Length + delimiterBytes.Length; + + // Re-serialize with correct offset + headerJson = JsonSerializer.Serialize(header, jsonOptions); + headerBytes = Encoding.UTF8.GetBytes(headerJson); + + // 7. Combine header + delimiter + encrypted payload + var avexFile = new byte[headerBytes.Length + delimiterBytes.Length + encryptedPayload.Length]; + Buffer.BlockCopy(headerBytes, 0, avexFile, 0, headerBytes.Length); + Buffer.BlockCopy(delimiterBytes, 0, avexFile, headerBytes.Length, delimiterBytes.Length); + Buffer.BlockCopy(encryptedPayload, 0, avexFile, headerBytes.Length + delimiterBytes.Length, encryptedPayload.Length); + + return avexFile; + } + + /// + /// Decrypts .avex file to .avux bytes using Web Crypto API. + /// + /// The encrypted .avex file bytes. + /// The password to decrypt with. + /// The decrypted .avux file bytes. + public async Task DecryptAvexAsync(byte[] avexBytes, string exportPassword) + { + // 1. Parse header + var (header, payloadOffset) = ParseAvexHeader(avexBytes); + + // 2. Validate version + if (header.Version != "1.0.0") + { + throw new InvalidOperationException($"Unsupported .avex version: {header.Version}"); + } + + // 3. Extract encrypted payload + var encryptedPayloadLength = avexBytes.Length - (int)payloadOffset; + var encryptedPayload = new byte[encryptedPayloadLength]; + Buffer.BlockCopy(avexBytes, (int)payloadOffset, encryptedPayload, 0, encryptedPayloadLength); + + // 4. Derive key using Argon2id (C# library works in Blazor WASM) + if (header.Kdf.Type != "Argon2Id" && header.Kdf.Type != "Argon2id") + { + throw new InvalidOperationException($"Unsupported KDF type: {header.Kdf.Type}. Only Argon2id is supported."); + } + + var kdfSettings = JsonSerializer.Serialize(header.Kdf.Params); + var key = await AliasVault.Cryptography.Client.Encryption.DeriveKeyFromPasswordAsync( + exportPassword, + header.Kdf.Salt, + header.Kdf.Type, + kdfSettings); + + // 5. Decrypt payload + byte[] avuxBytes; + try + { + avuxBytes = await this.jsInteropService.SymmetricDecryptBytesRaw(encryptedPayload, key); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to decrypt .avex file. The password may be incorrect or the file may be corrupted.", ex); + } + + return avuxBytes; + } + + /// + /// Parses the .avex header. + /// + private static (AvexHeader Header, long PayloadOffset) ParseAvexHeader(byte[] avexBytes) + { + var delimiterBytes = Encoding.UTF8.GetBytes(HeaderDelimiter); + var delimiterIndex = IndexOf(avexBytes, delimiterBytes); + + if (delimiterIndex == -1) + { + throw new InvalidOperationException("Invalid .avex file: header delimiter not found"); + } + + var headerBytes = new byte[delimiterIndex]; + Buffer.BlockCopy(avexBytes, 0, headerBytes, 0, delimiterIndex); + var headerJson = Encoding.UTF8.GetString(headerBytes); + + var jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + + var header = JsonSerializer.Deserialize(headerJson, jsonOptions); + + if (header == null || header.Format != "avex") + { + throw new InvalidOperationException("Invalid .avex file: malformed header"); + } + + var payloadOffset = delimiterIndex + delimiterBytes.Length; + return (header, payloadOffset); + } + + /// + /// Finds the index of a byte pattern within a byte array. + /// + private static int IndexOf(byte[] source, byte[] pattern) + { + if (pattern.Length > source.Length) + { + return -1; + } + + for (int i = 0; i <= source.Length - pattern.Length; i++) + { + bool found = true; + for (int j = 0; j < pattern.Length; j++) + { + if (source[i + j] != pattern[j]) + { + found = false; + break; + } + } + + if (found) + { + return i; + } + } + + return -1; + } +} diff --git a/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs b/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs index 0215e1cbf..53ed88e78 100644 --- a/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs +++ b/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs @@ -388,6 +388,48 @@ public sealed class JsInteropService(IJSRuntime jsRuntime) return await jsRuntime.InvokeAsync("cryptoInterop.decryptBytes", base64Ciphertext, encryptionKey); } + /// + /// Generates a random salt of the specified length. + /// + /// Length of salt to generate in bytes. + /// Random salt bytes. + public async Task GenerateSalt(int length) + { + return await jsRuntime.InvokeAsync("cryptoInterop.generateSalt", length); + } + + /// + /// Symmetrically encrypts a byte array using the provided encryption key. + /// + /// Plain bytes to encrypt. + /// Encryption key bytes. + /// Encrypted bytes. + public async Task SymmetricEncryptBytes(byte[] plainBytes, byte[] encryptionKey) + { + if (plainBytes == null || plainBytes.Length == 0) + { + return []; + } + + return await jsRuntime.InvokeAsync("cryptoInterop.encryptBytes", plainBytes, encryptionKey); + } + + /// + /// Symmetrically decrypts a byte array using the provided encryption key (raw bytes without base64 encoding). + /// + /// Encrypted bytes to decrypt. + /// Encryption key bytes. + /// Decrypted bytes. + public async Task SymmetricDecryptBytesRaw(byte[] encryptedBytes, byte[] encryptionKey) + { + if (encryptedBytes == null || encryptedBytes.Length == 0) + { + return []; + } + + return await jsRuntime.InvokeAsync("cryptoInterop.decryptBytesRaw", encryptedBytes, encryptionKey); + } + /// /// Gets all available languages for identity generation. /// diff --git a/apps/server/AliasVault.Client/wwwroot/js/encryption-utils.js b/apps/server/AliasVault.Client/wwwroot/js/encryption-utils.js index ecdd21190..8b09c9ef0 100644 --- a/apps/server/AliasVault.Client/wwwroot/js/encryption-utils.js +++ b/apps/server/AliasVault.Client/wwwroot/js/encryption-utils.js @@ -112,6 +112,74 @@ window.cryptoInterop = { ); return new Uint8Array(decrypted); + }, + /** + * Encrypts byte array using AES-256-GCM + * @param {Uint8Array} plainBytes - The bytes to encrypt + * @param {Uint8Array} keyBytes - The 32-byte encryption key + * @returns {Promise} The encrypted data (nonce + ciphertext + tag) + */ + encryptBytes: async function(plainBytes, keyBytes) { + checkCryptoAvailable(); + + const key = await window.crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] + ); + + const nonce = window.crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, + key, + plainBytes + ); + + const ciphertextArray = new Uint8Array(ciphertext); + const result = new Uint8Array(12 + ciphertextArray.length); + result.set(nonce, 0); + result.set(ciphertextArray, 12); + + return result; + }, + /** + * Decrypts byte array using AES-256-GCM + * @param {Uint8Array} encryptedBytes - The encrypted data (nonce + ciphertext + tag) + * @param {Uint8Array} keyBytes - The 32-byte encryption key + * @returns {Promise} The decrypted data + */ + decryptBytesRaw: async function(encryptedBytes, keyBytes) { + checkCryptoAvailable(); + + const key = await window.crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ); + + const nonce = encryptedBytes.slice(0, 12); + const ciphertextWithTag = encryptedBytes.slice(12); + + const plaintext = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce }, + key, + ciphertextWithTag + ); + + return new Uint8Array(plaintext); + }, + /** + * Generates random salt + * @param {number} length - The length of the salt in bytes + * @returns {Uint8Array} The random salt + */ + generateSalt: function(length) { + checkCryptoAvailable(); + return window.crypto.getRandomValues(new Uint8Array(length)); } }; diff --git a/apps/server/Utilities/AliasVault.ImportExport/AliasVault.ImportExport.csproj b/apps/server/Utilities/AliasVault.ImportExport/AliasVault.ImportExport.csproj index b4433b1b7..7b01f9d5d 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/AliasVault.ImportExport.csproj +++ b/apps/server/Utilities/AliasVault.ImportExport/AliasVault.ImportExport.csproj @@ -18,6 +18,8 @@ + + diff --git a/apps/server/Utilities/AliasVault.ImportExport/Models/Exports/AvexHeader.cs b/apps/server/Utilities/AliasVault.ImportExport/Models/Exports/AvexHeader.cs new file mode 100644 index 000000000..0724617c5 --- /dev/null +++ b/apps/server/Utilities/AliasVault.ImportExport/Models/Exports/AvexHeader.cs @@ -0,0 +1,92 @@ +//----------------------------------------------------------------------- +// +// 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.ImportExport.Models.Exports; + +/// +/// Represents the header for an .avex (encrypted export) file. +/// +public class AvexHeader +{ + /// + /// Gets or sets the file format identifier. + /// + public string Format { get; set; } = "avex"; + + /// + /// Gets or sets the format version. + /// + public string Version { get; set; } = "1.0.0"; + + /// + /// Gets or sets the key derivation function parameters. + /// + public KdfParams Kdf { get; set; } = new(); + + /// + /// Gets or sets the encryption parameters. + /// + public EncryptionParams Encryption { get; set; } = new(); + + /// + /// Gets or sets the export metadata. + /// + public AvexMetadata Metadata { get; set; } = new(); +} + +/// +/// Key derivation function parameters. +/// +public class KdfParams +{ + /// + /// Gets or sets the KDF algorithm type. + /// + public string Type { get; set; } = "Argon2id"; + + /// + /// Gets or sets the salt value (base64-encoded). + /// + public string Salt { get; set; } = string.Empty; + + /// + /// Gets or sets the algorithm-specific parameters. + /// + public Dictionary Params { get; set; } = new(); +} + +/// +/// Encryption algorithm parameters. +/// +public class EncryptionParams +{ + /// + /// Gets or sets the encryption algorithm name. + /// + public string Algorithm { get; set; } = "AES-256-GCM"; + + /// + /// Gets or sets the byte offset where encrypted data begins. + /// + public long EncryptedDataOffset { get; set; } +} + +/// +/// Metadata about the export. +/// +public class AvexMetadata +{ + /// + /// Gets or sets the timestamp when the export was created. + /// + public DateTime ExportedAt { get; set; } + + /// + /// Gets or sets the username who created the export. + /// + public string ExportedBy { get; set; } = string.Empty; +} diff --git a/apps/server/Utilities/AliasVault.ImportExport/VaultEncryptedExportService.cs b/apps/server/Utilities/AliasVault.ImportExport/VaultEncryptedExportService.cs new file mode 100644 index 000000000..1ad71cbe3 --- /dev/null +++ b/apps/server/Utilities/AliasVault.ImportExport/VaultEncryptedExportService.cs @@ -0,0 +1,114 @@ +//----------------------------------------------------------------------- +// +// 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.ImportExport; + +using System.Text; +using System.Text.Json; +using AliasVault.Cryptography.Client; +using AliasVault.Cryptography.Server; +using AliasVault.ImportExport.Models.Exports; + +/// +/// Service for creating encrypted .avex export files. +/// +public static class VaultEncryptedExportService +{ + private const string HeaderDelimiter = "\n--- ENCRYPTED PAYLOAD FOLLOWS ---\n"; + + /// + /// Exports vault data to .avex (AliasVault Encrypted eXport) format. + /// This wraps an existing .avux file in an encrypted container using a user-provided password. + /// + /// The unencrypted .avux file bytes. + /// The password to encrypt the export with. + /// The username creating the export. + /// A byte array containing the encrypted .avex file. + public static async Task ExportToAvexAsync( + byte[] avuxBytes, + string exportPassword, + string username) + { + if (avuxBytes == null || avuxBytes.Length == 0) + { + throw new ArgumentException("AVUX bytes cannot be null or empty", nameof(avuxBytes)); + } + + if (string.IsNullOrWhiteSpace(exportPassword)) + { + throw new ArgumentException("Export password cannot be null or empty", nameof(exportPassword)); + } + + // 1. Generate random salt for key derivation + var salt = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32); + var saltBase64 = Convert.ToBase64String(salt); + + // 2. Derive encryption key from password using Argon2id + var key = await AliasVault.Cryptography.Client.Encryption.DeriveKeyFromPasswordAsync( + exportPassword, + saltBase64, + Defaults.EncryptionType, + Defaults.EncryptionSettings); + + // 3. Encrypt the .avux bytes using AES-256-GCM + var encryptedPayload = AliasVault.Cryptography.Server.Encryption.SymmetricEncrypt(avuxBytes, key); + + // 4. Create the header + var header = new AvexHeader + { + Format = "avex", + Version = "1.0.0", + Kdf = new KdfParams + { + Type = Defaults.EncryptionType, + Salt = saltBase64, + Params = new Dictionary + { + ["DegreeOfParallelism"] = Defaults.Argon2IdDegreeOfParallelism, + ["MemorySize"] = Defaults.Argon2IdMemorySize, + ["Iterations"] = Defaults.Argon2IdIterations, + }, + }, + Encryption = new EncryptionParams + { + Algorithm = "AES-256-GCM", + EncryptedDataOffset = 0, // Will be calculated below + }, + Metadata = new AvexMetadata + { + ExportedAt = DateTime.UtcNow, + ExportedBy = username, + }, + }; + + // 5. Serialize header to JSON + var jsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + var headerJson = JsonSerializer.Serialize(header, jsonOptions); + var headerBytes = Encoding.UTF8.GetBytes(headerJson); + var delimiterBytes = Encoding.UTF8.GetBytes(HeaderDelimiter); + + // 6. Calculate the offset where encrypted data begins + header.Encryption.EncryptedDataOffset = headerBytes.Length + delimiterBytes.Length; + + // Re-serialize with correct offset + headerJson = JsonSerializer.Serialize(header, jsonOptions); + headerBytes = Encoding.UTF8.GetBytes(headerJson); + + // 7. Combine header + delimiter + encrypted payload + var avexFile = new byte[headerBytes.Length + delimiterBytes.Length + encryptedPayload.Length]; + Buffer.BlockCopy(headerBytes, 0, avexFile, 0, headerBytes.Length); + Buffer.BlockCopy(delimiterBytes, 0, avexFile, headerBytes.Length, delimiterBytes.Length); + Buffer.BlockCopy(encryptedPayload, 0, avexFile, headerBytes.Length + delimiterBytes.Length, encryptedPayload.Length); + + return avexFile; + } +} diff --git a/apps/server/Utilities/AliasVault.ImportExport/VaultEncryptedImportService.cs b/apps/server/Utilities/AliasVault.ImportExport/VaultEncryptedImportService.cs new file mode 100644 index 000000000..464c2a404 --- /dev/null +++ b/apps/server/Utilities/AliasVault.ImportExport/VaultEncryptedImportService.cs @@ -0,0 +1,194 @@ +//----------------------------------------------------------------------- +// +// 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.ImportExport; + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AliasVault.Cryptography.Client; +using AliasVault.Cryptography.Server; +using AliasVault.ImportExport.Models.Exports; + +/// +/// Service for importing encrypted .avex export files. +/// +public static class VaultEncryptedImportService +{ + private const string HeaderDelimiter = "\n--- ENCRYPTED PAYLOAD FOLLOWS ---\n"; + + /// + /// Checks if the provided file bytes represent an .avex encrypted export. + /// + /// The file bytes to check. + /// True if the file is an .avex format, false otherwise. + public static bool IsAvexFormat(byte[] fileBytes) + { + if (fileBytes == null || fileBytes.Length < 50) + { + return false; + } + + try + { + // Read first 500 bytes as string to check for header + var headerLength = Math.Min(500, fileBytes.Length); + var headerText = Encoding.UTF8.GetString(fileBytes, 0, headerLength); + + return headerText.Contains("\"format\": \"avex\"") || + headerText.Contains("\"format\":\"avex\""); + } + catch + { + return false; + } + } + + /// + /// Decrypts an .avex file and returns the unencrypted .avux bytes. + /// + /// The encrypted .avex file bytes. + /// The password to decrypt the export with. + /// The decrypted .avux file bytes. + public static async Task DecryptAvexAsync(byte[] avexBytes, string exportPassword) + { + if (avexBytes == null || avexBytes.Length == 0) + { + throw new ArgumentException("AVEX bytes cannot be null or empty", nameof(avexBytes)); + } + + if (string.IsNullOrWhiteSpace(exportPassword)) + { + throw new ArgumentException("Export password cannot be null or empty", nameof(exportPassword)); + } + + // 1. Parse the header + var (header, payloadOffset) = ParseAvexHeader(avexBytes); + + // 2. Validate version + if (header.Version != "1.0.0") + { + throw new InvalidOperationException($"Unsupported .avex version: {header.Version}. Expected 1.0.0."); + } + + // 3. Validate encryption algorithm + if (header.Encryption.Algorithm != "AES-256-GCM") + { + throw new InvalidOperationException($"Unsupported encryption algorithm: {header.Encryption.Algorithm}"); + } + + // 4. Extract encrypted payload + var encryptedPayloadLength = avexBytes.Length - (int)payloadOffset; + if (encryptedPayloadLength <= 0) + { + throw new InvalidOperationException("Invalid .avex file: no encrypted payload found"); + } + + var encryptedPayload = new byte[encryptedPayloadLength]; + Buffer.BlockCopy(avexBytes, (int)payloadOffset, encryptedPayload, 0, encryptedPayloadLength); + + // 5. Derive decryption key from password using same KDF parameters + var kdfSettings = JsonSerializer.Serialize(header.Kdf.Params); + var key = await AliasVault.Cryptography.Client.Encryption.DeriveKeyFromPasswordAsync( + exportPassword, + header.Kdf.Salt, + header.Kdf.Type, + kdfSettings); + + // 6. Decrypt the payload + byte[] avuxBytes; + try + { + avuxBytes = AliasVault.Cryptography.Server.Encryption.SymmetricDecrypt(encryptedPayload, key); + } + catch (CryptographicException ex) + { + throw new InvalidOperationException("Failed to decrypt .avex file. The password may be incorrect or the file may be corrupted.", ex); + } + + return avuxBytes; + } + + /// + /// Parses the .avex header and returns the header object and payload offset. + /// + /// The .avex file bytes. + /// A tuple containing the header and the offset where encrypted data begins. + private static (AvexHeader Header, long PayloadOffset) ParseAvexHeader(byte[] avexBytes) + { + // Find the delimiter + var delimiterBytes = Encoding.UTF8.GetBytes(HeaderDelimiter); + var delimiterIndex = IndexOf(avexBytes, delimiterBytes); + + if (delimiterIndex == -1) + { + throw new InvalidOperationException("Invalid .avex file: header delimiter not found"); + } + + // Extract header JSON + var headerBytes = new byte[delimiterIndex]; + Buffer.BlockCopy(avexBytes, 0, headerBytes, 0, delimiterIndex); + var headerJson = Encoding.UTF8.GetString(headerBytes); + + // Deserialize header + var jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + + var header = JsonSerializer.Deserialize(headerJson, jsonOptions); + + if (header == null) + { + throw new InvalidOperationException("Invalid .avex file: failed to parse header JSON"); + } + + if (header.Format != "avex") + { + throw new InvalidOperationException($"Invalid .avex file: expected format 'avex', got '{header.Format}'"); + } + + // Calculate payload offset + var payloadOffset = delimiterIndex + delimiterBytes.Length; + + return (header, payloadOffset); + } + + /// + /// Finds the index of a byte pattern within a byte array. + /// + /// The source byte array to search in. + /// The pattern to search for. + /// The index of the pattern, or -1 if not found. + private static int IndexOf(byte[] source, byte[] pattern) + { + if (pattern.Length > source.Length) + { + return -1; + } + + for (int i = 0; i <= source.Length - pattern.Length; i++) + { + bool found = true; + for (int j = 0; j < pattern.Length; j++) + { + if (source[i + j] != pattern[j]) + { + found = false; + break; + } + } + + if (found) + { + return i; + } + } + + return -1; + } +}