systemprompt_security/
at_rest.rs1use chacha20poly1305::aead::Aead;
26use chacha20poly1305::{ChaCha20Poly1305, Nonce};
27use hmac::{Hmac, KeyInit, Mac};
28use sha2::Sha256;
29
30type HmacSha256 = Hmac<Sha256>;
31
32const NONCE_BYTES: usize = 12;
33
34#[derive(Debug, Clone, Copy, thiserror::Error)]
37pub enum AtRestCipherError {
38 #[error(
39 "at-rest encryption requires the `encryption_master_key` secret (32 bytes as 64 hex \
40 characters); with `secrets.source: env` it must also be listed in \
41 SYSTEMPROMPT_CUSTOM_SECRETS"
42 )]
43 KeyUnavailable,
44
45 #[error("encryption_master_key is not a 32-byte hex key")]
46 KeyInvalid,
47
48 #[error("sealed value is malformed")]
49 Malformed,
50
51 #[error("sealed value did not authenticate under encryption_master_key")]
52 NotAuthentic,
53}
54
55fn cipher() -> Result<ChaCha20Poly1305, AtRestCipherError> {
56 let secrets = systemprompt_config::SecretsBootstrap::get()
57 .map_err(|_e| AtRestCipherError::KeyUnavailable)?;
58 let key = secrets
59 .get("encryption_master_key")
60 .ok_or(AtRestCipherError::KeyUnavailable)?;
61 let decoded = hex::decode(key).map_err(|_e| AtRestCipherError::KeyInvalid)?;
62 ChaCha20Poly1305::new_from_slice(&decoded).map_err(|_e| AtRestCipherError::KeyInvalid)
63}
64
65pub fn seal(plaintext: &str) -> Result<String, AtRestCipherError> {
66 let nonce: [u8; NONCE_BYTES] = rand::random();
67 let sealed = cipher()?
68 .encrypt(&Nonce::from(nonce), plaintext.as_bytes())
69 .map_err(|_e| AtRestCipherError::NotAuthentic)?;
70 let mut out = Vec::with_capacity(NONCE_BYTES + sealed.len());
71 out.extend_from_slice(&nonce);
72 out.extend_from_slice(&sealed);
73 Ok(hex::encode(out))
74}
75
76pub fn open(sealed: &str) -> Result<String, AtRestCipherError> {
77 let bytes = hex::decode(sealed).map_err(|_e| AtRestCipherError::Malformed)?;
78 if bytes.len() <= NONCE_BYTES {
79 return Err(AtRestCipherError::Malformed);
80 }
81 let nonce: [u8; NONCE_BYTES] = bytes[..NONCE_BYTES]
82 .try_into()
83 .map_err(|_e| AtRestCipherError::Malformed)?;
84 let plaintext = cipher()?
85 .decrypt(&Nonce::from(nonce), &bytes[NONCE_BYTES..])
86 .map_err(|_e| AtRestCipherError::NotAuthentic)?;
87 String::from_utf8(plaintext).map_err(|_e| AtRestCipherError::Malformed)
88}
89
90#[expect(
91 clippy::expect_used,
92 reason = "HMAC-SHA256 accepts any key length by construction; new_from_slice cannot fail here"
93)]
94pub fn hmac_sha256(pepper: &[u8], value: &[u8]) -> [u8; 32] {
95 let mut mac = HmacSha256::new_from_slice(pepper).expect("HMAC accepts any key length");
96 mac.update(value);
97 let result = mac.finalize().into_bytes();
98 let mut out = [0u8; 32];
99 out.copy_from_slice(&result);
100 out
101}
102
103pub fn hmac_sha256_hex(pepper: &[u8], value: &[u8]) -> String {
104 hex::encode(hmac_sha256(pepper, value))
105}