15
crates/adapters/crypto/Cargo.toml
Normal file
15
crates/adapters/crypto/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "crypto"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
config.workspace = true
|
||||
async-trait.workspace = true
|
||||
base64.workspace = true
|
||||
chacha20poly1305.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
68
crates/adapters/crypto/src/chacha_cipher.rs
Normal file
68
crates/adapters/crypto/src/chacha_cipher.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use base64::Engine;
|
||||
use chacha20poly1305::aead::{Aead, KeyInit, OsRng, rand_core::RngCore};
|
||||
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::provider::{CredentialCipher, EncryptedCredential};
|
||||
|
||||
const KEY_BYTES: usize = 32;
|
||||
const NONCE_BYTES: usize = 24;
|
||||
|
||||
pub struct ChaChaCredentialCipher {
|
||||
cipher: XChaCha20Poly1305,
|
||||
}
|
||||
|
||||
impl ChaChaCredentialCipher {
|
||||
pub fn new(base64_key: &str) -> Result<Self, DomainError> {
|
||||
let key_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(base64_key.trim())
|
||||
.map_err(|_| {
|
||||
DomainError::InvalidInput("credential encryption key must be base64".into())
|
||||
})?;
|
||||
|
||||
if key_bytes.len() != KEY_BYTES {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"credential encryption key must decode to {KEY_BYTES} bytes, got {}",
|
||||
key_bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
cipher: XChaCha20Poly1305::new(Key::from_slice(&key_bytes)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialCipher for ChaChaCredentialCipher {
|
||||
fn encrypt(&self, plaintext: &[u8]) -> Result<EncryptedCredential, DomainError> {
|
||||
let mut nonce_bytes = [0u8; NONCE_BYTES];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = XNonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = self
|
||||
.cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|_| DomainError::InvalidInput("failed to encrypt credential".into()))?;
|
||||
|
||||
let mut sealed = nonce_bytes.to_vec();
|
||||
sealed.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(EncryptedCredential::from_persistence(sealed))
|
||||
}
|
||||
|
||||
fn decrypt(&self, credential: &EncryptedCredential) -> Result<Vec<u8>, DomainError> {
|
||||
let sealed = credential.value();
|
||||
|
||||
if sealed.len() <= NONCE_BYTES {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"stored credential is truncated".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let (nonce_bytes, ciphertext) = sealed.split_at(NONCE_BYTES);
|
||||
|
||||
self.cipher
|
||||
.decrypt(XNonce::from_slice(nonce_bytes), ciphertext)
|
||||
.map_err(|_| DomainError::InvalidInput("failed to decrypt credential".into()))
|
||||
}
|
||||
}
|
||||
3
crates/adapters/crypto/src/lib.rs
Normal file
3
crates/adapters/crypto/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod chacha_cipher;
|
||||
|
||||
pub use chacha_cipher::ChaChaCredentialCipher;
|
||||
77
crates/adapters/crypto/tests/cipher_test.rs
Normal file
77
crates/adapters/crypto/tests/cipher_test.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use domain::provider::{CredentialCipher, EncryptedCredential};
|
||||
|
||||
use crypto::ChaChaCredentialCipher;
|
||||
|
||||
const TEST_KEY: &str = "gXqvVQF3v9pT2mKz8sYbN4jHcR7wLdE1uA0iO5tPxZk=";
|
||||
|
||||
fn cipher() -> ChaChaCredentialCipher {
|
||||
ChaChaCredentialCipher::new(TEST_KEY).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credential_survives_a_round_trip() {
|
||||
let cipher = cipher();
|
||||
let secret = br#"{"url":"https://music.example","username":"gabriel","password":"hunter2"}"#;
|
||||
|
||||
let encrypted = cipher.encrypt(secret).unwrap();
|
||||
let decrypted = cipher.decrypt(&encrypted).unwrap();
|
||||
|
||||
assert_eq!(decrypted, secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_plaintext_never_appears_in_the_ciphertext() {
|
||||
let cipher = cipher();
|
||||
|
||||
let encrypted = cipher.encrypt(b"hunter2").unwrap();
|
||||
|
||||
assert!(
|
||||
!encrypted
|
||||
.value()
|
||||
.windows(7)
|
||||
.any(|window| window == b"hunter2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypting_the_same_secret_twice_gives_different_ciphertext() {
|
||||
let cipher = cipher();
|
||||
|
||||
let first = cipher.encrypt(b"hunter2").unwrap();
|
||||
let second = cipher.encrypt(b"hunter2").unwrap();
|
||||
|
||||
assert_ne!(first.value(), second.value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tampered_credential_is_rejected_rather_than_silently_decrypted() {
|
||||
let cipher = cipher();
|
||||
let encrypted = cipher.encrypt(b"hunter2").unwrap();
|
||||
|
||||
let mut tampered = encrypted.value().to_vec();
|
||||
let last = tampered.len() - 1;
|
||||
tampered[last] ^= 0xff;
|
||||
|
||||
let result = cipher.decrypt(&EncryptedCredential::from_persistence(tampered));
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credential_encrypted_under_another_key_is_rejected() {
|
||||
let other_key = "AAAAVQF3v9pT2mKz8sYbN4jHcR7wLdE1uA0iO5tPxZk=";
|
||||
let encrypted = cipher().encrypt(b"hunter2").unwrap();
|
||||
|
||||
let result = ChaChaCredentialCipher::new(other_key)
|
||||
.unwrap()
|
||||
.decrypt(&encrypted);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_that_is_not_32_bytes_is_refused_at_construction() {
|
||||
assert!(ChaChaCredentialCipher::new("dGhpcyBpcyB0b28gc2hvcnQ=").is_err());
|
||||
assert!(ChaChaCredentialCipher::new("not-base64!!").is_err());
|
||||
assert!(ChaChaCredentialCipher::new("").is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user