mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-25 01:52:12 -04:00
Add AvexCryptoService.cs (#773)
This commit is contained in:
@@ -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<ConfirmModalService>();
|
||||
builder.Services.AddScoped<QuickCreateStateService>();
|
||||
builder.Services.AddScoped<LanguageService>();
|
||||
builder.Services.AddScoped<RustCoreService>();
|
||||
builder.Services.AddScoped<AvexCryptoService>();
|
||||
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddBlazoredLocalStorage();
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="AvexCryptoService.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.Crypto;
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AliasVault.Client.Services.JsInterop;
|
||||
using AliasVault.ImportExport.Models.Exports;
|
||||
|
||||
/// <summary>
|
||||
/// Provides cryptographic operations for .avex encrypted vault export format.
|
||||
/// Handles encryption/decryption of .avex files using Argon2id KDF and AES-256-GCM.
|
||||
/// </summary>
|
||||
public class AvexCryptoService
|
||||
{
|
||||
private const string HeaderDelimiter = "\n--- ENCRYPTED PAYLOAD FOLLOWS ---\n";
|
||||
private readonly JsInteropService jsInteropService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AvexCryptoService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsInteropService">The JS interop service.</param>
|
||||
public AvexCryptoService(JsInteropService jsInteropService)
|
||||
{
|
||||
this.jsInteropService = jsInteropService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts vault data to .avex format using Web Crypto API.
|
||||
/// Uses Argon2id for key derivation and AES-256-GCM for encryption.
|
||||
/// </summary>
|
||||
/// <param name="avuxBytes">The unencrypted .avux bytes.</param>
|
||||
/// <param name="exportPassword">The password to encrypt with.</param>
|
||||
/// <param name="username">The username creating the export.</param>
|
||||
/// <returns>The encrypted .avex file bytes.</returns>
|
||||
public async Task<byte[]> 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<string, int>
|
||||
{
|
||||
["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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts .avex file to .avux bytes using Web Crypto API.
|
||||
/// </summary>
|
||||
/// <param name="avexBytes">The encrypted .avex file bytes.</param>
|
||||
/// <param name="exportPassword">The password to decrypt with.</param>
|
||||
/// <returns>The decrypted .avux file bytes.</returns>
|
||||
public async Task<byte[]> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the .avex header.
|
||||
/// </summary>
|
||||
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<AvexHeader>(headerJson, jsonOptions);
|
||||
|
||||
if (header == null || header.Format != "avex")
|
||||
{
|
||||
throw new InvalidOperationException("Invalid .avex file: malformed header");
|
||||
}
|
||||
|
||||
var payloadOffset = delimiterIndex + delimiterBytes.Length;
|
||||
return (header, payloadOffset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the index of a byte pattern within a byte array.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -388,6 +388,48 @@ public sealed class JsInteropService(IJSRuntime jsRuntime)
|
||||
return await jsRuntime.InvokeAsync<byte[]>("cryptoInterop.decryptBytes", base64Ciphertext, encryptionKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a random salt of the specified length.
|
||||
/// </summary>
|
||||
/// <param name="length">Length of salt to generate in bytes.</param>
|
||||
/// <returns>Random salt bytes.</returns>
|
||||
public async Task<byte[]> GenerateSalt(int length)
|
||||
{
|
||||
return await jsRuntime.InvokeAsync<byte[]>("cryptoInterop.generateSalt", length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symmetrically encrypts a byte array using the provided encryption key.
|
||||
/// </summary>
|
||||
/// <param name="plainBytes">Plain bytes to encrypt.</param>
|
||||
/// <param name="encryptionKey">Encryption key bytes.</param>
|
||||
/// <returns>Encrypted bytes.</returns>
|
||||
public async Task<byte[]> SymmetricEncryptBytes(byte[] plainBytes, byte[] encryptionKey)
|
||||
{
|
||||
if (plainBytes == null || plainBytes.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await jsRuntime.InvokeAsync<byte[]>("cryptoInterop.encryptBytes", plainBytes, encryptionKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symmetrically decrypts a byte array using the provided encryption key (raw bytes without base64 encoding).
|
||||
/// </summary>
|
||||
/// <param name="encryptedBytes">Encrypted bytes to decrypt.</param>
|
||||
/// <param name="encryptionKey">Encryption key bytes.</param>
|
||||
/// <returns>Decrypted bytes.</returns>
|
||||
public async Task<byte[]> SymmetricDecryptBytesRaw(byte[] encryptedBytes, byte[] encryptionKey)
|
||||
{
|
||||
if (encryptedBytes == null || encryptedBytes.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await jsRuntime.InvokeAsync<byte[]>("cryptoInterop.decryptBytesRaw", encryptedBytes, encryptionKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available languages for identity generation.
|
||||
/// </summary>
|
||||
|
||||
@@ -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<Uint8Array>} 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<Uint8Array>} 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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Databases\AliasClientDb\AliasClientDb.csproj" />
|
||||
<ProjectReference Include="..\..\Utilities\AliasVault.TotpGenerator\AliasVault.TotpGenerator.csproj" />
|
||||
<ProjectReference Include="..\..\Utilities\Cryptography\AliasVault.Cryptography.Client\AliasVault.Cryptography.Client.csproj" />
|
||||
<ProjectReference Include="..\..\Utilities\Cryptography\AliasVault.Cryptography.Server\AliasVault.Cryptography.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="AvexHeader.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.ImportExport.Models.Exports;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the header for an .avex (encrypted export) file.
|
||||
/// </summary>
|
||||
public class AvexHeader
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the file format identifier.
|
||||
/// </summary>
|
||||
public string Format { get; set; } = "avex";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the format version.
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the key derivation function parameters.
|
||||
/// </summary>
|
||||
public KdfParams Kdf { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the encryption parameters.
|
||||
/// </summary>
|
||||
public EncryptionParams Encryption { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the export metadata.
|
||||
/// </summary>
|
||||
public AvexMetadata Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key derivation function parameters.
|
||||
/// </summary>
|
||||
public class KdfParams
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the KDF algorithm type.
|
||||
/// </summary>
|
||||
public string Type { get; set; } = "Argon2id";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the salt value (base64-encoded).
|
||||
/// </summary>
|
||||
public string Salt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the algorithm-specific parameters.
|
||||
/// </summary>
|
||||
public Dictionary<string, int> Params { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encryption algorithm parameters.
|
||||
/// </summary>
|
||||
public class EncryptionParams
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the encryption algorithm name.
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = "AES-256-GCM";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the byte offset where encrypted data begins.
|
||||
/// </summary>
|
||||
public long EncryptedDataOffset { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about the export.
|
||||
/// </summary>
|
||||
public class AvexMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when the export was created.
|
||||
/// </summary>
|
||||
public DateTime ExportedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the username who created the export.
|
||||
/// </summary>
|
||||
public string ExportedBy { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="VaultEncryptedExportService.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.ImportExport;
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AliasVault.Cryptography.Client;
|
||||
using AliasVault.Cryptography.Server;
|
||||
using AliasVault.ImportExport.Models.Exports;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating encrypted .avex export files.
|
||||
/// </summary>
|
||||
public static class VaultEncryptedExportService
|
||||
{
|
||||
private const string HeaderDelimiter = "\n--- ENCRYPTED PAYLOAD FOLLOWS ---\n";
|
||||
|
||||
/// <summary>
|
||||
/// Exports vault data to .avex (AliasVault Encrypted eXport) format.
|
||||
/// This wraps an existing .avux file in an encrypted container using a user-provided password.
|
||||
/// </summary>
|
||||
/// <param name="avuxBytes">The unencrypted .avux file bytes.</param>
|
||||
/// <param name="exportPassword">The password to encrypt the export with.</param>
|
||||
/// <param name="username">The username creating the export.</param>
|
||||
/// <returns>A byte array containing the encrypted .avex file.</returns>
|
||||
public static async Task<byte[]> 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<string, int>
|
||||
{
|
||||
["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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="VaultEncryptedImportService.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.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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for importing encrypted .avex export files.
|
||||
/// </summary>
|
||||
public static class VaultEncryptedImportService
|
||||
{
|
||||
private const string HeaderDelimiter = "\n--- ENCRYPTED PAYLOAD FOLLOWS ---\n";
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the provided file bytes represent an .avex encrypted export.
|
||||
/// </summary>
|
||||
/// <param name="fileBytes">The file bytes to check.</param>
|
||||
/// <returns>True if the file is an .avex format, false otherwise.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts an .avex file and returns the unencrypted .avux bytes.
|
||||
/// </summary>
|
||||
/// <param name="avexBytes">The encrypted .avex file bytes.</param>
|
||||
/// <param name="exportPassword">The password to decrypt the export with.</param>
|
||||
/// <returns>The decrypted .avux file bytes.</returns>
|
||||
public static async Task<byte[]> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the .avex header and returns the header object and payload offset.
|
||||
/// </summary>
|
||||
/// <param name="avexBytes">The .avex file bytes.</param>
|
||||
/// <returns>A tuple containing the header and the offset where encrypted data begins.</returns>
|
||||
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<AvexHeader>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the index of a byte pattern within a byte array.
|
||||
/// </summary>
|
||||
/// <param name="source">The source byte array to search in.</param>
|
||||
/// <param name="pattern">The pattern to search for.</param>
|
||||
/// <returns>The index of the pattern, or -1 if not found.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user