feat: AES-256-GCM crypto service for credential encryption

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thibaud-leclere 2026-04-13 14:23:23 +02:00
parent e72372fca8
commit 9a451061ea
3 changed files with 107 additions and 0 deletions

View file

@ -2,6 +2,7 @@ mod commands;
mod db;
mod error;
mod models;
mod services;
use std::sync::{Arc, Mutex};
use tauri::Manager;

View file

@ -0,0 +1,105 @@
use aes_gcm::{
aead::{Aead, KeyInit, OsRng},
Aes256Gcm, Key, Nonce,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use rand::RngCore;
pub fn encrypt(key: &[u8; 32], plaintext: &str) -> Result<String, String> {
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| format!("encryption failed: {}", e))?;
let mut combined = nonce_bytes.to_vec();
combined.extend(ciphertext);
Ok(STANDARD.encode(&combined))
}
pub fn decrypt(key: &[u8; 32], encrypted: &str) -> Result<String, String> {
let combined = STANDARD
.decode(encrypted)
.map_err(|e| format!("base64 decode failed: {}", e))?;
if combined.len() < 13 {
return Err("encrypted data too short".to_string());
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| "decryption failed (wrong key or corrupted data)".to_string())?;
String::from_utf8(plaintext).map_err(|e| format!("invalid UTF-8: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
fn test_key() -> [u8; 32] {
[42u8; 32]
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let key = test_key();
let plaintext = "hello world";
let encrypted = encrypt(&key, plaintext).unwrap();
let decrypted = decrypt(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypt_produces_different_ciphertext() {
let key = test_key();
let plaintext = "same input";
let enc1 = encrypt(&key, plaintext).unwrap();
let enc2 = encrypt(&key, plaintext).unwrap();
assert_ne!(enc1, enc2);
}
#[test]
fn test_decrypt_with_wrong_key_fails() {
let key = test_key();
let wrong_key = [99u8; 32];
let encrypted = encrypt(&key, "secret").unwrap();
let result = decrypt(&wrong_key, &encrypted);
assert!(result.is_err());
}
#[test]
fn test_decrypt_invalid_base64_fails() {
let key = test_key();
let result = decrypt(&key, "not valid base64!!!");
assert!(result.is_err());
}
#[test]
fn test_decrypt_too_short_fails() {
let key = test_key();
// Base64 of 5 bytes (less than 13)
let short = STANDARD.encode(&[0u8; 5]);
let result = decrypt(&key, &short);
assert!(result.is_err());
assert!(result.unwrap_err().contains("too short"));
}
#[test]
fn test_encrypt_empty_string() {
let key = test_key();
let encrypted = encrypt(&key, "").unwrap();
let decrypted = decrypt(&key, &encrypted).unwrap();
assert_eq!(decrypted, "");
}
#[test]
fn test_encrypt_unicode() {
let key = test_key();
let plaintext = "héllo wörld àèìòù";
let encrypted = encrypt(&key, plaintext).unwrap();
let decrypted = decrypt(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
}

View file

@ -0,0 +1 @@
pub mod crypto;