feat: AES-256-GCM crypto service for credential encryption
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e72372fca8
commit
9a451061ea
3 changed files with 107 additions and 0 deletions
|
|
@ -2,6 +2,7 @@ mod commands;
|
||||||
mod db;
|
mod db;
|
||||||
mod error;
|
mod error;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod services;
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
|
||||||
105
src-tauri/src/services/crypto.rs
Normal file
105
src-tauri/src/services/crypto.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src-tauri/src/services/mod.rs
Normal file
1
src-tauri/src/services/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod crypto;
|
||||||
Loading…
Reference in a new issue