From f6013b8e9d20e8ed4454402c5af0c545e92ed163 Mon Sep 17 00:00:00 2001 From: cryptooda Date: Thu, 17 Jul 2025 16:50:54 +0700 Subject: [PATCH] Add encryption for Kaigen server auth --- docs/KaigenConfiguration.md | 47 ++++-- src/Managing.Application.Tests/BotsTests.cs | 3 +- .../CryptoHelpersTests.cs | 39 +++++ src/Managing.Core/CryptoHelpers.cs | 147 ++++++++++++++++++ .../Managing.Infrastructure.Evm.csproj | 1 + .../Services/KaigenService.cs | 77 ++++----- 6 files changed, 266 insertions(+), 48 deletions(-) create mode 100644 src/Managing.Application.Tests/CryptoHelpersTests.cs create mode 100644 src/Managing.Core/CryptoHelpers.cs diff --git a/docs/KaigenConfiguration.md b/docs/KaigenConfiguration.md index dac1429..b0307c0 100644 --- a/docs/KaigenConfiguration.md +++ b/docs/KaigenConfiguration.md @@ -6,30 +6,30 @@ The Kaigen service is used for managing user credits during backtest operations. ### Required Environment Variable -- **`KAIGEN_PRIVATE_KEY`**: The private key used for signing API requests to the Kaigen service. +- **`KAIGEN_SECRET_KEY`**: The secret key used for AES-256-CBC encryption of Basic Auth tokens sent to the Kaigen service. ### Setting the Environment Variable #### Development ```bash -export KAIGEN_PRIVATE_KEY="your-private-key-here" +export KAIGEN_SECRET_KEY="your-secret-key-here" ``` #### Production Set the environment variable in your deployment configuration: ```bash -KAIGEN_PRIVATE_KEY=your-private-key-here +KAIGEN_SECRET_KEY=your-secret-key-here ``` #### Docker ```bash -docker run -e KAIGEN_PRIVATE_KEY=your-private-key-here your-app +docker run -e KAIGEN_SECRET_KEY=your-secret-key-here your-app ``` #### Docker Compose ```yaml environment: - - KAIGEN_PRIVATE_KEY=your-private-key-here + - KAIGEN_SECRET_KEY=your-secret-key-here ``` ## Configuration Structure @@ -42,11 +42,28 @@ The Kaigen service configuration is defined in `appsettings.json`: "BaseUrl": "https://api.kaigen.managing.live", "DebitEndpoint": "/api/credits/debit", "RefundEndpoint": "/api/credits/refund", - "PrivateKey": "${KAIGEN_PRIVATE_KEY}" + "SecretKey": "${KAIGEN_SECRET_KEY}" } } ``` +## Authentication Method + +The service now uses **Basic Authentication** with AES-256-GCM encrypted tokens: + +1. **Token Format**: `{walletAddress}-{username}` +2. **Encryption**: The token is encrypted using AES-256-GCM with the configured secret key +3. **Basic Auth**: The encrypted token is sent in the Authorization header as `Basic {base64EncodedToken}:` + +### Example Token Generation +```csharp +// For user "john" with wallet "0x123..." +var authToken = "0x123...-john"; +var encryptedToken = CryptoHelpers.EncryptAesGcm(authToken, secretKey); +var basicAuth = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{encryptedToken}:")); +// Result: Authorization: Basic {base64EncodedToken}: +``` + ## API Endpoints - **PUT** `/api/credits/debit` - Debit credits from user account @@ -54,11 +71,21 @@ The Kaigen service configuration is defined in `appsettings.json`: ## Security Notes -- The private key should never be committed to source control +- The secret key should never be committed to source control - Use environment variables or secure configuration management systems -- The private key is used for signing API requests to ensure authenticity -- Rotate the private key regularly for enhanced security +- The secret key is used for AES-256-GCM encryption of authentication tokens +- Rotate the secret key regularly for enhanced security +- Each request uses a unique nonce for encryption, ensuring replay attack protection +- The GCM mode provides both confidentiality and authenticity ## Error Handling -If the `KAIGEN_PRIVATE_KEY` environment variable is not set, the application will throw an `InvalidOperationException` with a clear error message during startup. \ No newline at end of file +If the `KAIGEN_SECRET_KEY` environment variable is not set, the application will throw an `InvalidOperationException` with a clear error message during startup. + +## Migration from Private Key Authentication + +If migrating from the previous private key signature method: + +1. Replace `KAIGEN_PRIVATE_KEY` with `KAIGEN_SECRET_KEY` in your environment variables +2. Update any configuration files to use the new `SecretKey` property instead of `PrivateKey` +3. The Kaigen server must be updated to handle Basic Auth with AES-256-GCM decryption \ No newline at end of file diff --git a/src/Managing.Application.Tests/BotsTests.cs b/src/Managing.Application.Tests/BotsTests.cs index 35e96e8..38c2573 100644 --- a/src/Managing.Application.Tests/BotsTests.cs +++ b/src/Managing.Application.Tests/BotsTests.cs @@ -34,6 +34,7 @@ namespace Managing.Application.Tests var discordService = new Mock().Object; var scenarioService = new Mock().Object; var messengerService = new Mock().Object; + var kaigenService = new Mock().Object; var tradingBotLogger = TradingBaseTests.CreateTradingBotLogger(); var backtestLogger = TradingBaseTests.CreateBacktesterLogger(); var botService = new Mock().Object; @@ -45,7 +46,7 @@ namespace Managing.Application.Tests _tradingService.Object, botService); _backtester = new Backtester(_exchangeService, _botFactory, backtestRepository, backtestLogger, - scenarioService, _accountService.Object, messengerService); + scenarioService, _accountService.Object, messengerService, kaigenService); _elapsedTimes = new List(); // Initialize cross-platform file paths diff --git a/src/Managing.Application.Tests/CryptoHelpersTests.cs b/src/Managing.Application.Tests/CryptoHelpersTests.cs new file mode 100644 index 0000000..7d5ccfc --- /dev/null +++ b/src/Managing.Application.Tests/CryptoHelpersTests.cs @@ -0,0 +1,39 @@ +using System.Security.Cryptography; +using Managing.Core; +using Xunit; + +namespace Managing.Application.Tests; + +public class CryptoHelpersTests +{ + [Fact] + public void EncryptAesGcm_And_DecryptAesGcm_RoundTrip_Works() + { + // Arrange + var secretKey = "supersecretkey1234567890123456"; // 32 bytes + var token = "0x1234567890abcdef-johndoe"; + + // Act + var encrypted = CryptoHelpers.EncryptAesGcm(token, secretKey); + var decrypted = CryptoHelpers.DecryptAesGcm(encrypted, secretKey); + + // Assert + Assert.NotNull(encrypted); + Assert.NotEqual(token, encrypted); // Should be encrypted + Assert.Equal(token, decrypted); // Should decrypt to original + } + + [Fact] + public void DecryptAesGcm_WithWrongKey_Throws() + { + // Arrange + var secretKey = "supersecretkey1234567890123456"; + var wrongKey = "wrongsecretkey6543210987654321"; + var token = "0xabcdef-jane"; + var encrypted = CryptoHelpers.EncryptAesGcm(token, secretKey); + + // Act & Assert + Assert.Throws(() => + CryptoHelpers.DecryptAesGcm(encrypted, wrongKey)); + } +} \ No newline at end of file diff --git a/src/Managing.Core/CryptoHelpers.cs b/src/Managing.Core/CryptoHelpers.cs new file mode 100644 index 0000000..2cbe5da --- /dev/null +++ b/src/Managing.Core/CryptoHelpers.cs @@ -0,0 +1,147 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Managing.Core; + +/// +/// Provides AES-256-CBC encryption utilities with HMAC-SHA256 authentication for secure token generation. +/// +public static class CryptoHelpers +{ + /// + /// Encrypts a string using AES-256-CBC encryption with HMAC-SHA256 authentication. + /// + /// The text to encrypt + /// The secret key for encryption (should be 32 bytes for AES-256) + /// Base64 encoded encrypted data with IV and HMAC + public static string EncryptAesCbc(string plaintext, string secretKey) + { + if (string.IsNullOrEmpty(plaintext)) + throw new ArgumentException("Plaintext cannot be null or empty", nameof(plaintext)); + + if (string.IsNullOrEmpty(secretKey)) + throw new ArgumentException("Secret key cannot be null or empty", nameof(secretKey)); + + // Convert secret key to bytes (ensure it's 32 bytes for AES-256) + var keyBytes = Encoding.UTF8.GetBytes(secretKey); + if (keyBytes.Length != 32) + { + // If key is not 32 bytes, hash it to get 32 bytes + using var sha256 = SHA256.Create(); + keyBytes = sha256.ComputeHash(keyBytes); + } + + // Convert plaintext to bytes + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + + // Generate a random IV (16 bytes for AES) + var iv = new byte[16]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(iv); + } + + // Encrypt using AES-CBC + using var aes = Aes.Create(); + aes.Key = keyBytes; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + aes.IV = iv; + + using var encryptor = aes.CreateEncryptor(); + var ciphertext = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length); + + // Create HMAC for authentication + using var hmac = new HMACSHA256(keyBytes); + var hmacBytes = hmac.ComputeHash(ciphertext); + + // Combine IV + encrypted data + HMAC + var result = new byte[iv.Length + ciphertext.Length + hmacBytes.Length]; + Buffer.BlockCopy(iv, 0, result, 0, iv.Length); + Buffer.BlockCopy(ciphertext, 0, result, iv.Length, ciphertext.Length); + Buffer.BlockCopy(hmacBytes, 0, result, iv.Length + ciphertext.Length, hmacBytes.Length); + + return Convert.ToBase64String(result); + } + + /// + /// Decrypts a string using AES-256-CBC decryption with HMAC-SHA256 authentication. + /// + /// Base64 encoded encrypted data with IV and HMAC + /// The secret key for decryption (should be 32 bytes for AES-256) + /// The decrypted plaintext + public static string DecryptAesCbc(string encryptedData, string secretKey) + { + if (string.IsNullOrEmpty(encryptedData)) + throw new ArgumentException("Encrypted data cannot be null or empty", nameof(encryptedData)); + + if (string.IsNullOrEmpty(secretKey)) + throw new ArgumentException("Secret key cannot be null or empty", nameof(secretKey)); + + // Convert secret key to bytes (ensure it's 32 bytes for AES-256) + var keyBytes = Encoding.UTF8.GetBytes(secretKey); + if (keyBytes.Length != 32) + { + // If key is not 32 bytes, hash it to get 32 bytes + using var sha256 = SHA256.Create(); + keyBytes = sha256.ComputeHash(keyBytes); + } + + // Decode the base64 data + var encryptedBytes = Convert.FromBase64String(encryptedData); + + // Extract IV (first 16 bytes), HMAC (last 32 bytes), and ciphertext (middle) + if (encryptedBytes.Length < 48) // 16 (IV) + 32 (HMAC) = minimum 48 bytes + throw new ArgumentException("Encrypted data is too short", nameof(encryptedData)); + + var iv = new byte[16]; + var hmacBytes = new byte[32]; + var ciphertext = new byte[encryptedBytes.Length - 48]; + + Buffer.BlockCopy(encryptedBytes, 0, iv, 0, 16); + Buffer.BlockCopy(encryptedBytes, encryptedBytes.Length - 32, hmacBytes, 0, 32); + Buffer.BlockCopy(encryptedBytes, 16, ciphertext, 0, ciphertext.Length); + + // Verify HMAC for authentication + using var hmac = new HMACSHA256(keyBytes); + var computedHmac = hmac.ComputeHash(ciphertext); + + if (!computedHmac.SequenceEqual(hmacBytes)) + { + throw new CryptographicException("HMAC verification failed - data may have been tampered with"); + } + + // Decrypt using AES-CBC + using var aes = Aes.Create(); + aes.Key = keyBytes; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + aes.IV = iv; + + using var decryptor = aes.CreateDecryptor(); + var decryptedBytes = decryptor.TransformFinalBlock(ciphertext, 0, ciphertext.Length); + return Encoding.UTF8.GetString(decryptedBytes); + } + + /// + /// Encrypts a string using AES-256-CBC encryption (alias for EncryptAesCbc for backward compatibility). + /// + /// The text to encrypt + /// The secret key for encryption + /// Base64 encoded encrypted data + public static string EncryptAesGcm(string plaintext, string secretKey) + { + return EncryptAesCbc(plaintext, secretKey); + } + + /// + /// Decrypts a string using AES-256-CBC decryption (alias for DecryptAesCbc for backward compatibility). + /// + /// Base64 encoded encrypted data + /// The secret key for decryption + /// The decrypted plaintext + public static string DecryptAesGcm(string encryptedData, string secretKey) + { + return DecryptAesCbc(encryptedData, secretKey); + } +} \ No newline at end of file diff --git a/src/Managing.Infrastructure.Web3/Managing.Infrastructure.Evm.csproj b/src/Managing.Infrastructure.Web3/Managing.Infrastructure.Evm.csproj index e1888ae..d7018a2 100644 --- a/src/Managing.Infrastructure.Web3/Managing.Infrastructure.Evm.csproj +++ b/src/Managing.Infrastructure.Web3/Managing.Infrastructure.Evm.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Managing.Infrastructure.Web3/Services/KaigenService.cs b/src/Managing.Infrastructure.Web3/Services/KaigenService.cs index 76ce875..99e8f9c 100644 --- a/src/Managing.Infrastructure.Web3/Services/KaigenService.cs +++ b/src/Managing.Infrastructure.Web3/Services/KaigenService.cs @@ -1,23 +1,25 @@ +using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text; using System.Text.Json; using Managing.Application.Abstractions.Services; +using Managing.Core; using Managing.Domain.Users; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Nethereum.Signer; namespace Managing.Infrastructure.Evm.Services; /// /// Configuration settings for Kaigen credit management service. -/// The PrivateKey should be set via the KAIGEN_PRIVATE_KEY environment variable for security. +/// The SecretKey should be set via the KAIGEN_SECRET_KEY environment variable for security. /// public class KaigenSettings { public string BaseUrl { get; set; } = "https://api.kaigen.managing.live"; public string DebitEndpoint { get; set; } = "/api/credits/debit"; public string RefundEndpoint { get; set; } = "/api/credits/refund"; - public string PrivateKey { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; } public class KaigenService : IKaigenService @@ -45,22 +47,22 @@ public class KaigenService : IKaigenService if (!_creditsEnabled) { _logger.LogInformation("Kaigen credits are disabled via KAIGEN_CREDITS_ENABLED environment variable"); - return; // Skip private key validation if credits are disabled + return; // Skip secret key validation if credits are disabled } // Always read from environment variable for security - var envPrivateKey = Environment.GetEnvironmentVariable("KAIGEN_PRIVATE_KEY"); - if (!string.IsNullOrEmpty(envPrivateKey)) + var envSecretKey = Environment.GetEnvironmentVariable("KAIGEN_SECRET_KEY"); + if (!string.IsNullOrEmpty(envSecretKey)) { - _settings.PrivateKey = envPrivateKey; - _logger.LogInformation("Using KAIGEN_PRIVATE_KEY from environment variable"); + _settings.SecretKey = envSecretKey; + _logger.LogInformation("Using KAIGEN_SECRET_KEY from environment variable"); } // Validate required settings only if credits are enabled - if (string.IsNullOrEmpty(_settings.PrivateKey)) + if (string.IsNullOrEmpty(_settings.SecretKey)) { throw new InvalidOperationException( - "Kaigen PrivateKey is not configured. Please set the KAIGEN_PRIVATE_KEY environment variable."); + "Kaigen SecretKey is not configured. Please set the KAIGEN_SECRET_KEY environment variable."); } if (string.IsNullOrEmpty(_settings.BaseUrl)) @@ -86,29 +88,22 @@ public class KaigenService : IKaigenService var walletAddress = GetUserWalletAddress(user); var requestId = Guid.NewGuid().ToString(); - // Create the message to sign (concatenate the values) - var message = $"{requestId}{walletAddress}{debitAmount}"; - - // Sign the message - var signature = SignMessage(message, _settings.PrivateKey); - // Create the request payload var requestPayload = new { requestId = requestId, walletAddress = walletAddress, - debitAmount = debitAmount, - signature = signature + debitAmount = debitAmount }; _logger.LogInformation( "Debiting {Amount} credits for user {UserName} (wallet: {WalletAddress}) with request ID {RequestId}", debitAmount, user.Name, walletAddress, requestId); - var response = await _httpClient.PutAsJsonAsync( + var response = await SendAuthenticatedRequestAsync( $"{_settings.BaseUrl}{_settings.DebitEndpoint}", requestPayload, - _jsonOptions); + user); if (!response.IsSuccessStatusCode) { @@ -146,28 +141,21 @@ public class KaigenService : IKaigenService { var walletAddress = GetUserWalletAddress(user); - // Create the message to sign (concatenate the values) - var message = $"{requestId}{walletAddress}"; - - // Sign the message - var signature = SignMessage(message, _settings.PrivateKey); - // Create the request payload var requestPayload = new { requestId = requestId, - walletAddress = walletAddress, - signature = signature + walletAddress = walletAddress }; _logger.LogInformation( "Refunding credits for user {UserName} (wallet: {WalletAddress}) with request ID {RequestId}", user.Name, walletAddress, requestId); - var response = await _httpClient.PutAsJsonAsync( + var response = await SendAuthenticatedRequestAsync( $"{_settings.BaseUrl}{_settings.RefundEndpoint}", requestPayload, - _jsonOptions); + user); if (!response.IsSuccessStatusCode) { @@ -188,6 +176,28 @@ public class KaigenService : IKaigenService } } + private async Task SendAuthenticatedRequestAsync(string url, object payload, User user) + { + // Create the auth token: "walletaddress-username" + var authToken = $"{GetUserWalletAddress(user)}-{user.Name}"; + + // Encrypt the auth token using AES-256-GCM + var encryptedToken = CryptoHelpers.EncryptAesGcm(authToken, _settings.SecretKey); + + // Create Basic Auth header with the encrypted token + var basicAuthString = $"{encryptedToken}:"; + var base64Auth = Convert.ToBase64String(Encoding.ASCII.GetBytes(basicAuthString)); + + // Create a new request with the auth header + var request = new HttpRequestMessage(HttpMethod.Put, url) + { + Content = JsonContent.Create(payload, options: _jsonOptions) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", base64Auth); + + return await _httpClient.SendAsync(request); + } + private string GetUserWalletAddress(User user) { if (user?.Accounts == null || !user.Accounts.Any()) @@ -206,13 +216,6 @@ public class KaigenService : IKaigenService return walletAddress; } - private string SignMessage(string message, string privateKey) - { - var signer = new EthereumMessageSigner(); - var signature = signer.EncodeUTF8AndSign(message, new EthECKey(privateKey)); - return signature; - } - private class KaigenResponse { public bool Success { get; set; }