From 79afd42195126b921d4233996e56579cb6e4adab Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 27 Aug 2024 20:23:37 +0200 Subject: [PATCH] Added symmetric encryption to the Rust runtime and the .NET app --- app/MindWork AI Studio/Pages/About.razor | 2 + app/MindWork AI Studio/Program.cs | 32 ++- app/MindWork AI Studio/Tools/EncryptedText.cs | 13 ++ .../Tools/EncryptedTextExtensions.cs | 8 + app/MindWork AI Studio/Tools/Encryption.cs | 141 ++++++++++++ app/MindWork AI Studio/Tools/Rust.cs | 10 +- runtime/Cargo.lock | 176 +++++++-------- runtime/Cargo.toml | 7 + runtime/src/main.rs | 209 +++++++++++++++--- 9 files changed, 464 insertions(+), 134 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/EncryptedText.cs create mode 100644 app/MindWork AI Studio/Tools/EncryptedTextExtensions.cs create mode 100644 app/MindWork AI Studio/Tools/Encryption.cs diff --git a/app/MindWork AI Studio/Pages/About.razor b/app/MindWork AI Studio/Pages/About.razor index 2729920a..cfc70a93 100644 --- a/app/MindWork AI Studio/Pages/About.razor +++ b/app/MindWork AI Studio/Pages/About.razor @@ -48,6 +48,8 @@ + + diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 7e6ee1e1..a4db1d08 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -29,13 +29,23 @@ if(appPort == 0) } // Read the secret key for the IPC from the AI_STUDIO_SECRET_KEY environment variable: -var secretKey = Environment.GetEnvironmentVariable("AI_STUDIO_SECRET_KEY"); -if(string.IsNullOrWhiteSpace(secretKey)) +var secretPasswordEncoded = Environment.GetEnvironmentVariable("AI_STUDIO_SECRET_PASSWORD"); +if(string.IsNullOrWhiteSpace(secretPasswordEncoded)) { - Console.WriteLine("The AI_STUDIO_SECRET_KEY environment variable is not set."); + Console.WriteLine("The AI_STUDIO_SECRET_PASSWORD environment variable is not set."); return; } +var secretPassword = Convert.FromBase64String(secretPasswordEncoded); +var secretKeySaltEncoded = Environment.GetEnvironmentVariable("AI_STUDIO_SECRET_KEY_SALT"); +if(string.IsNullOrWhiteSpace(secretKeySaltEncoded)) +{ + Console.WriteLine("The AI_STUDIO_SECRET_KEY_SALT environment variable is not set."); + return; +} + +var secretKeySalt = Convert.FromBase64String(secretKeySaltEncoded); + var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.Logging.SetMinimumLevel(LogLevel.Debug); @@ -92,7 +102,19 @@ builder.WebHost.UseWebRoot("wwwroot"); builder.WebHost.UseStaticWebAssets(); #endif +// Execute the builder to get the app: var app = builder.Build(); + +// Initialize the encryption service: +var encryptionLogger = app.Services.GetRequiredService>(); +var encryption = new Encryption(encryptionLogger, secretPassword, secretKeySalt); +var encryptionInitializer = encryption.Initialize(); + +// Set the logger for the Rust service: +var rustLogger = app.Services.GetRequiredService>(); +rust.SetLogger(rustLogger); +rust.SetEncryptor(encryption); + app.Use(Redirect.HandlerContentAsync); #if DEBUG @@ -115,8 +137,6 @@ app.MapRazorComponents() var serverTask = app.RunAsync(); -var rustLogger = app.Services.GetRequiredService>(); -rust.SetLogger(rustLogger); - +await encryptionInitializer; await rust.AppIsReady(); await serverTask; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/EncryptedText.cs b/app/MindWork AI Studio/Tools/EncryptedText.cs new file mode 100644 index 00000000..34b24c99 --- /dev/null +++ b/app/MindWork AI Studio/Tools/EncryptedText.cs @@ -0,0 +1,13 @@ +using System.Security; + +namespace AIStudio.Tools; + +public readonly record struct EncryptedText(string EncryptedData) +{ + public EncryptedText() : this(string.Empty) + { + throw new SecurityException("Please provide the encrypted data."); + } + + public static implicit operator string(EncryptedText encryptedText) => encryptedText.EncryptedData; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/EncryptedTextExtensions.cs b/app/MindWork AI Studio/Tools/EncryptedTextExtensions.cs new file mode 100644 index 00000000..8e77f8f8 --- /dev/null +++ b/app/MindWork AI Studio/Tools/EncryptedTextExtensions.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools; + +public static class EncryptedTextExtensions +{ + public static async Task Encrypt(this string data, Encryption encryption) => await encryption.Encrypt(data); + + public static async Task Decrypt(this EncryptedText encryptedText, Encryption encryption) => await encryption.Decrypt(encryptedText); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Encryption.cs b/app/MindWork AI Studio/Tools/Encryption.cs new file mode 100644 index 00000000..8c96b609 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Encryption.cs @@ -0,0 +1,141 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace AIStudio.Tools; + +public sealed class Encryption(ILogger logger, byte[] secretPassword, byte[] secretKeySalt) +{ + /// + /// The number of iterations to derive the key and IV from the password. For a password manager + /// where the user has to enter their primary password, 100 iterations would be too few and + /// insecure. Here, the use case is different: We generate a 512-byte long and cryptographically + /// secure password at every start. This password already contains enough entropy. In our case, + /// we need key and IV primarily because AES, with the algorithms we chose, requires a fixed key + /// length, and our password is too long. + /// + private const int ITERATIONS = 100; + + private readonly byte[] key = new byte[32]; + private readonly byte[] iv = new byte[16]; + + public async Task Initialize() + { + logger.LogInformation("Initializing encryption service..."); + var stopwatch = Stopwatch.StartNew(); + + if (secretPassword.Length != 512) + { + logger.LogError($"The secret password must be 512 bytes long. It was {secretPassword.Length} bytes long."); + throw new CryptographicException("The secret password must be 512 bytes long."); + } + + if(secretKeySalt.Length != 16) + { + logger.LogError($"The salt data must be 16 bytes long. It was {secretKeySalt.Length} bytes long."); + throw new CryptographicException("The salt data must be 16 bytes long."); + } + + // Derive key and iv vector: the operations take several seconds. Thus, using a task: + await Task.Run(() => + { + using var keyVectorObj = new Rfc2898DeriveBytes(secretPassword, secretKeySalt, ITERATIONS, HashAlgorithmName.SHA512); + var keyBytes = keyVectorObj.GetBytes(32); // the max valid key length = 256 bit = 32 bytes + var ivBytes = keyVectorObj.GetBytes(16); // the only valid block size = 128 bit = 16 bytes + + Array.Copy(keyBytes, this.key, this.key.Length); + Array.Copy(ivBytes, this.iv, this.iv.Length); + }); + + var initDuration = stopwatch.Elapsed; + + stopwatch.Stop(); + logger.LogInformation($"Encryption service initialized in {initDuration.TotalMilliseconds} milliseconds."); + } + + public async Task Encrypt(string data) + { + // Create AES encryption: + using var aes = Aes.Create(); + aes.Padding = PaddingMode.PKCS7; + aes.Key = this.key; + aes.IV = this.iv; + aes.Mode = CipherMode.CBC; + + using var encryption = aes.CreateEncryptor(); + + // Copy the given string data into a memory stream: + await using var plainDataStream = new MemoryStream(Encoding.UTF8.GetBytes(data)); + + // A memory stream for the final, encrypted data: + await using var encryptedAndEncodedData = new MemoryStream(); + + // A base64 stream for the encoding: + await using var base64Stream = new CryptoStream(encryptedAndEncodedData, new ToBase64Transform(), CryptoStreamMode.Write); + + // Write the salt into the base64 stream: + await base64Stream.WriteAsync(secretKeySalt); + + // Create the encryption stream: + await using var cryptoStream = new CryptoStream(base64Stream, encryption, CryptoStreamMode.Write); + + // Write the payload into the encryption stream: + await plainDataStream.CopyToAsync(cryptoStream); + + // Flush the final block. Please note that it is not enough to call the regular flush method. + await cryptoStream.FlushFinalBlockAsync(); + + // Convert the base64 encoded data back into a string. Uses GetBuffer due to the advantage that + // it does not create another copy of the data. ToArray would create another copy of the data. + return new EncryptedText(Encoding.ASCII.GetString(encryptedAndEncodedData.GetBuffer()[..(int)encryptedAndEncodedData.Length])); + } + + public async Task Decrypt(EncryptedText encryptedData) + { + // Build a memory stream to access the given base64 encoded data: + await using var encodedEncryptedStream = new MemoryStream(Encoding.ASCII.GetBytes(encryptedData)); + + // Wrap around the base64 decoder stream: + await using var base64Stream = new CryptoStream(encodedEncryptedStream, new FromBase64Transform(), CryptoStreamMode.Read); + + // A buffer for the salt's bytes: + var readSaltBytes = new byte[16]; // 16 bytes = Guid + + // Read the salt's bytes out of the stream: + var readBytes = await base64Stream.ReadAsync(readSaltBytes); + if(readBytes != 16) + { + logger.LogError($"Read {readBytes} bytes instead of 16 bytes for the salt."); + throw new CryptographicException("Failed to read the salt bytes."); + } + + // Check the salt bytes: + if(!readSaltBytes.SequenceEqual(secretKeySalt)) + { + logger.LogError("The salt bytes do not match. The data is corrupted or tampered."); + throw new CryptographicException("The salt bytes do not match. The data is corrupted or tampered."); + } + + // Create AES decryption: + using var aes = Aes.Create(); + aes.Padding = PaddingMode.PKCS7; + aes.Key = this.key; + aes.IV = this.iv; + + using var decryption = aes.CreateDecryptor(); + + // A memory stream for the final, decrypted data: + await using var decryptedData = new MemoryStream(); + + // The crypto stream: + await using var cryptoStream = new CryptoStream(base64Stream, decryption, CryptoStreamMode.Read); + + // Reads all remaining data through the decrypt stream. Note that this operation + // starts at the current position, i.e., after the salt bytes: + await cryptoStream.CopyToAsync(decryptedData); + + // Convert the decrypted data back into a string. Uses GetBuffer due to the advantage that + // it does not create another copy of the data. ToArray would create another copy of the data. + return Encoding.UTF8.GetString(decryptedData.GetBuffer()[..(int)decryptedData.Length]); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust.cs b/app/MindWork AI Studio/Tools/Rust.cs index fcfedc75..b7d158bd 100644 --- a/app/MindWork AI Studio/Tools/Rust.cs +++ b/app/MindWork AI Studio/Tools/Rust.cs @@ -9,14 +9,20 @@ public sealed class Rust(string apiPort) : IDisposable { BaseAddress = new Uri($"http://127.0.0.1:{apiPort}"), }; - - private ILogger? logger; + private ILogger? logger; + private Encryption? encryptor; + public void SetLogger(ILogger logService) { this.logger = logService; } + public void SetEncryptor(Encryption encryptionService) + { + this.encryptor = encryptionService; + } + public async Task GetAppPort() { Console.WriteLine("Trying to get app port from Rust runtime..."); diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 21063317..98b91160 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -17,6 +17,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -239,6 +250,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -346,6 +366,15 @@ dependencies = [ "toml 0.7.8", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.1.6" @@ -407,6 +436,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.0" @@ -754,6 +793,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1496,6 +1536,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.26.0" @@ -1628,23 +1677,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" -dependencies = [ - "futures-util", - "http 1.1.0", - "hyper 1.4.1", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - [[package]] name = "hyper-tls" version = "0.5.0" @@ -1821,6 +1853,16 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -2090,17 +2132,24 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" name = "mindwork-ai-studio" version = "0.8.12" dependencies = [ + "aes", "arboard", + "base64 0.22.1", + "cbc", + "cipher", "flexi_logger", + "hmac", "keyring", "log", "once_cell", + "pbkdf2", "rand 0.8.5", "rand_chacha 0.3.1", - "reqwest 0.12.5", + "reqwest 0.12.4", "rocket", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-window-state", @@ -2627,6 +2676,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pear" version = "0.2.9" @@ -3160,7 +3219,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 0.1.2", + "sync_wrapper", "system-configuration", "tokio", "tokio-native-tls", @@ -3176,9 +3235,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.5" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" dependencies = [ "base64 0.22.1", "bytes", @@ -3190,7 +3249,6 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.4.1", - "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", "ipnet", @@ -3205,7 +3263,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.1", + "sync_wrapper", "system-configuration", "tokio", "tokio-native-tls", @@ -3241,21 +3299,6 @@ dependencies = [ "windows 0.37.0", ] -[[package]] -name = "ring" -version = "0.17.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.15", - "libc", - "spin", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rocket" version = "0.5.1" @@ -3366,19 +3409,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustls" -version = "0.23.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" -dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -3404,17 +3434,6 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" -[[package]] -name = "rustls-webpki" -version = "0.102.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.17" @@ -3812,9 +3831,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.6.1" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" @@ -3844,12 +3863,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" -[[package]] -name = "sync_wrapper" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" - [[package]] name = "system-configuration" version = "0.5.1" @@ -4339,17 +4352,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" -dependencies = [ - "rustls", - "rustls-pki-types", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.15" @@ -4594,12 +4596,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229730647fbc343e3a80e463c1db7f78f3855d3f3739bee0dda773c9a037c90a" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.2" @@ -5404,12 +5400,6 @@ dependencies = [ "is-terminal", ] -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" - [[package]] name = "zip" version = "0.6.6" diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 8c7a70e5..87f662cb 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -22,6 +22,13 @@ once_cell = "1.19.0" rocket = { version = "0.5", default-features = false, features = ["json"] } rand = "0.8" rand_chacha = "0.3.1" +base64 = "0.22.1" +cipher = { version = "0.4.4", features = ["std"] } +aes = "0.8.4" +cbc = "0.1.2" +pbkdf2 = "0.12.2" +hmac = "0.12.1" +sha2 = "0.10.8" [target.'cfg(target_os = "linux")'.dependencies] # See issue https://github.com/tauri-apps/tauri/issues/4470 diff --git a/runtime/src/main.rs b/runtime/src/main.rs index 7b2fd97d..9116e5ff 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -5,28 +5,41 @@ extern crate rocket; extern crate core; use std::collections::{BTreeMap, HashMap, HashSet}; -use std::iter::once; +use std::fmt; use std::net::TcpListener; use std::sync::{Arc, Mutex}; -use std::fmt::Write; +use std::time::{Duration, Instant}; use once_cell::sync::Lazy; use arboard::Clipboard; +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; use keyring::Entry; -use serde::Serialize; +use serde::{Deserialize, Deserializer, Serialize}; use tauri::{Manager, Url, Window}; use tauri::api::process::{Command, CommandChild, CommandEvent}; use tokio::time; use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger}; use flexi_logger::writers::FileLogWriter; +use hmac::Hmac; use keyring::error::Error::NoEntry; use log::{debug, error, info, kv, warn}; use log::kv::{Key, Value, VisitSource}; +use pbkdf2::pbkdf2; use rand::{RngCore, SeedableRng}; use rocket::figment::Figment; -use rocket::{get, post, routes}; +use rocket::{data, get, post, routes, Data, Request}; use rocket::config::Shutdown; +use rocket::data::{Outcome, ToByteUnit}; +use rocket::http::Status; +use rocket::serde::json::Json; +use sha2::Sha512; use tauri::updater::UpdateResponse; +use tokio::io::AsyncReadExt; + +type Aes256CbcEnc = cbc::Encryptor; +type Aes256CbcDec = cbc::Decryptor; // The .NET server is started in a separate process and communicates with this // runtime process via IPC. However, we do net start the .NET server in @@ -55,6 +68,25 @@ static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)) // The update response coming from the Tauri updater. static CHECK_UPDATE_RESPONSE: Lazy>>> = Lazy::new(|| Mutex::new(None)); +static ENCRYPTION: Lazy = Lazy::new(|| { + // + // Generate a secret key & salt for the AES encryption for the IPC channel: + // + let mut secret_key = [0u8; 512]; // 512 bytes = 4096 bits + let mut secret_key_salt = [0u8; 16]; // 16 bytes = 128 bits + + // We use a cryptographically secure pseudo-random number generator + // to generate the secret password & salt. ChaCha20Rng is the algorithm + // of our choice: + let mut rng = rand_chacha::ChaChaRng::from_entropy(); + + // Fill the secret key & salt with random bytes: + rng.fill_bytes(&mut secret_key); + rng.fill_bytes(&mut secret_key_salt); + + Encryption::new(&secret_key, &secret_key_salt).unwrap() +}); + #[tokio::main] async fn main() { @@ -167,24 +199,11 @@ async fn main() { .launch().await.unwrap(); }); - // - // Generate a secret key for the AES encryption for the IPC channel: - // - let mut secret_key = [0u8; 512]; // 512 bytes = 4096 bits + // Get the secret password & salt and convert it to a base64 string: + let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password); + let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt); - // We use a cryptographically secure pseudo-random number generator - // to generate the secret key. ChaCha20Rng is the algorithm of our choice: - let mut rng = rand_chacha::ChaChaRng::from_entropy(); - - // Fill the secret key with random bytes: - rng.fill_bytes(&mut secret_key); - - // Convert the secret key to a hexadecimal string: - let secret_key = secret_key.iter().fold(String::new(), |mut out, b| { - _ = write!(out, "{b:02X}"); - out - }); - info!("Secret key for the IPC channel was generated successfully."); + info!("Secret password for the IPC channel was generated successfully."); info!("Try to start the .NET server..."); let server_spawn_clone = DOTNET_SERVER.clone(); tauri::async_runtime::spawn(async move { @@ -194,7 +213,7 @@ async fn main() { true => { // We are in the development environment, so we try to start a process // with `dotnet run` in the `../app/MindWork AI Studio` directory. But - // we cannot issue a sidecar, because we cannot use any command for the + // we cannot issue a sidecar because we cannot use any command for the // sidecar (see Tauri configuration). Thus, we use a standard Rust process: warn!(Source = "Bootloader .NET"; "Development environment detected; start .NET server using 'dotnet run'."); Command::new("dotnet") @@ -203,12 +222,12 @@ async fn main() { // We provide the runtime API server port to the .NET server: .args(["run", "--project", "../app/MindWork AI Studio", "--", format!("{api_port}").as_str()]) - // Provide the secret key for the IPC channel to the .NET server by using + // Provide the secret password & salt for the IPC channel to the .NET server by using // an environment variable. We must use a HashMap for this: - .envs(HashMap::from_iter(once(( - String::from("AI_STUDIO_SECRET_KEY"), - secret_key - )))) + .envs(HashMap::from_iter([ + (String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password), + (String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt), + ])) .spawn() .expect("Failed to spawn .NET server process.") } @@ -220,12 +239,12 @@ async fn main() { // Provide the runtime API server port to the .NET server: .args([format!("{api_port}").as_str()]) - // Provide the secret key for the IPC channel to the .NET server by using + // Provide the secret password & salt for the IPC channel to the .NET server by using // an environment variable. We must use a HashMap for this: - .envs(HashMap::from_iter(once(( - String::from("AI_STUDIO_SECRET_KEY"), - secret_key - )))) + .envs(HashMap::from_iter([ + (String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password), + (String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt), + ])) .spawn() .expect("Failed to spawn .NET server process.") } @@ -454,6 +473,130 @@ pub fn file_logger_format( write!(w, "{}", &record.args()) } +pub struct Encryption { + key: [u8; 32], + iv: [u8; 16], + + secret_password: [u8; 512], + secret_key_salt: [u8; 16], +} + +impl Encryption { + // The number of iterations to derive the key and IV from the password. For a password + // manager where the user has to enter their primary password, 100 iterations would be + // too few and insecure. Here, the use case is different: We generate a 512-byte long + // and cryptographically secure password at every start. This password already contains + // enough entropy. In our case, we need key and IV primarily because AES, with the + // algorithms we chose, requires a fixed key length, and our password is too long. + const ITERATIONS: u32 = 100; + + pub fn new(secret_password: &[u8], secret_key_salt: &[u8]) -> Result { + if secret_password.len() != 512 { + return Err("The secret password must be 512 bytes long.".to_string()); + } + + if secret_key_salt.len() != 16 { + return Err("The salt must be 16 bytes long.".to_string()); + } + + info!(Source = "Encryption"; "Initializing encryption..."); + let mut encryption = Encryption { + key: [0u8; 32], + iv: [0u8; 16], + + secret_password: [0u8; 512], + secret_key_salt: [0u8; 16], + }; + + encryption.secret_password.copy_from_slice(secret_password); + encryption.secret_key_salt.copy_from_slice(secret_key_salt); + + let start = Instant::now(); + let mut key_iv = [0u8; 48]; + pbkdf2::>(secret_password, secret_key_salt, Self::ITERATIONS, &mut key_iv).map_err(|e| format!("Error while generating key and IV: {e}"))?; + encryption.key.copy_from_slice(&key_iv[0..32]); + encryption.iv.copy_from_slice(&key_iv[32..48]); + + let duration = start.elapsed(); + let duration = duration.as_millis(); + info!(Source = "Encryption"; "Encryption initialized in {duration} milliseconds.", ); + + Ok(encryption) + } + + pub fn encrypt(&self, data: &str) -> Result { + let cipher = Aes256CbcEnc::new(&self.key.into(), &self.iv.into()); + let encrypted = cipher.encrypt_padded_vec_mut::(data.as_bytes()); + let mut result = BASE64_STANDARD.encode(self.secret_key_salt); + result.push_str(&BASE64_STANDARD.encode(&encrypted)); + Ok(EncryptedText::new(result)) + } + + pub fn decrypt(&self, encrypted_data: &EncryptedText) -> Result { + let decoded = BASE64_STANDARD.decode(encrypted_data.get_encrypted()).map_err(|e| format!("Error decoding base64: {e}"))?; + + if decoded.len() < 16 { + return Err("Encrypted data is too short.".to_string()); + } + + let (salt, encrypted) = decoded.split_at(16); + if salt != self.secret_key_salt { + return Err("The salt bytes do not match. The data is corrupted or tampered.".to_string()); + } + + let cipher = Aes256CbcDec::new(&self.key.into(), &self.iv.into()); + let decrypted = cipher.decrypt_padded_vec_mut::(encrypted).map_err(|e| format!("Error decrypting data: {e}"))?; + + String::from_utf8(decrypted).map_err(|e| format!("Error converting decrypted data to string: {}", e)) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct EncryptedText(String); + +impl EncryptedText { + pub fn new(encrypted_data: String) -> Self { + EncryptedText(encrypted_data) + } + + pub fn get_encrypted(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for EncryptedText { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "EncryptedText(**********)") + } +} + +impl fmt::Display for EncryptedText { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "**********") + } +} + +// Use Case: When we receive encrypted text from the client as body (e.g., in a POST request). +// We must interpret the body as EncryptedText. +#[rocket::async_trait] +impl<'r> data::FromData<'r> for EncryptedText { + type Error = String; + async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> { + let content_type = req.content_type(); + if content_type.map_or(true, |ct| !ct.is_text()) { + return Outcome::Forward((data, Status::Ok)); + } + + let mut stream = data.open(2.mebibytes()); + let mut body = String::new(); + if let Err(e) = stream.read_to_string(&mut body).await { + return Outcome::Error((Status::InternalServerError, format!("Failed to read data: {}", e))); + } + + Outcome::Success(EncryptedText(body)) + } +} + #[get("/system/dotnet/port")] fn dotnet_port() -> String { let dotnet_server_port = *DOTNET_SERVER_PORT; @@ -491,7 +634,7 @@ async fn dotnet_ready() { main_window_status_reported = true; } - time::sleep(time::Duration::from_millis(100)).await; + time::sleep(Duration::from_millis(100)).await; } }