Skip to main content

agent_first_pay/mode/rpc/
crypto.rs

1use aes_gcm::aead::{Aead, OsRng};
2use aes_gcm::{AeadCore, Aes256Gcm, KeyInit};
3use hkdf::Hkdf;
4use sha2::Sha256;
5
6const HKDF_SALT: &[u8] = b"afpay-rpc-v1";
7const HKDF_INFO_AES_GCM: &[u8] = b"afpay-rpc-v1/aes-256-gcm";
8const AES_GCM_NONCE_LEN: usize = 12;
9const MIN_SECRET_BYTES: usize = 32;
10
11pub struct Cipher {
12    key: [u8; 32],
13}
14
15impl Cipher {
16    /// Validate that an operator-supplied RPC PSK is high-entropy enough for production use.
17    pub fn validate_secret(secret: &str) -> Result<(), String> {
18        let secret = secret.trim();
19        if secret.len() < MIN_SECRET_BYTES {
20            return Err(format!(
21                "RPC secret must be at least {MIN_SECRET_BYTES} bytes; generate one with: openssl rand -base64 32"
22            ));
23        }
24        if secret.as_bytes().windows(2).all(|w| w[0] == w[1]) {
25            return Err("RPC secret must not be a repeated single character".to_string());
26        }
27        Ok(())
28    }
29
30    /// Derive a 32-byte AES-256 key from the PSK using HKDF-SHA256.
31    pub fn from_secret(secret: &str) -> Self {
32        let mut key = [0u8; 32];
33        let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), secret.as_bytes());
34        let _ = hk.expand(HKDF_INFO_AES_GCM, &mut key);
35        Self { key }
36    }
37
38    /// Encrypt plaintext: zstd compress → AES-256-GCM encrypt. Returns `(nonce, ciphertext)`.
39    pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
40        let compressed =
41            zstd::bulk::compress(plaintext, 1).map_err(|e| format!("zstd compress: {e}"))?;
42        let cipher =
43            Aes256Gcm::new_from_slice(&self.key).map_err(|e| format!("cipher init: {e}"))?;
44        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
45        let ciphertext = cipher
46            .encrypt(&nonce, compressed.as_slice())
47            .map_err(|e| format!("encrypt: {e}"))?;
48        Ok((nonce.to_vec(), ciphertext))
49    }
50
51    /// Decrypt ciphertext: AES-256-GCM decrypt → zstd decompress.
52    pub fn decrypt(&self, nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, String> {
53        if nonce.len() != AES_GCM_NONCE_LEN {
54            return Err(format!(
55                "invalid nonce length: expected {AES_GCM_NONCE_LEN}, got {}",
56                nonce.len()
57            ));
58        }
59        let cipher =
60            Aes256Gcm::new_from_slice(&self.key).map_err(|e| format!("cipher init: {e}"))?;
61        let nonce = aes_gcm::Nonce::from_slice(nonce);
62        let compressed = cipher
63            .decrypt(nonce, ciphertext)
64            .map_err(|e| format!("decrypt: {e}"))?;
65        // 64 MiB decompression cap to prevent zip-bomb DoS
66        zstd::bulk::decompress(&compressed, 64 * 1024 * 1024)
67            .map_err(|e| format!("zstd decompress: {e}"))
68    }
69}
70
71#[cfg(test)]
72#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn roundtrip() {
78        let cipher = Cipher::from_secret("test-password");
79        let plaintext = b"hello world";
80        let (nonce, ct) = cipher.encrypt(plaintext).ok().unwrap(); // test-only
81        let decrypted = cipher.decrypt(&nonce, &ct).ok().unwrap(); // test-only
82        assert_eq!(decrypted, plaintext);
83    }
84
85    #[test]
86    fn wrong_key_fails() {
87        let c1 = Cipher::from_secret("key-a");
88        let c2 = Cipher::from_secret("key-b");
89        let (nonce, ct) = c1.encrypt(b"secret").ok().unwrap(); // test-only
90        assert!(c2.decrypt(&nonce, &ct).is_err());
91    }
92
93    #[test]
94    fn validates_secret_strength() {
95        assert!(Cipher::validate_secret("short").is_err());
96        assert!(Cipher::validate_secret("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").is_err());
97        assert!(Cipher::validate_secret("0123456789abcdef0123456789abcdef").is_ok());
98    }
99
100    #[test]
101    fn bad_nonce_length_fails() {
102        let cipher = Cipher::from_secret("0123456789abcdef0123456789abcdef");
103        assert!(cipher.decrypt(&[], b"ciphertext").is_err());
104    }
105}