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 =
62 systemprompt_config::decode_master_key(key).map_err(|_e| AtRestCipherError::KeyInvalid)?;
63 Ok(ChaCha20Poly1305::new(&decoded.into()))
64}
65
66pub fn seal(plaintext: &str) -> Result<String, AtRestCipherError> {
67 let nonce: [u8; NONCE_BYTES] = rand::random();
68 let sealed = cipher()?
69 .encrypt(&Nonce::from(nonce), plaintext.as_bytes())
70 .map_err(|_e| AtRestCipherError::NotAuthentic)?;
71 let mut out = Vec::with_capacity(NONCE_BYTES + sealed.len());
72 out.extend_from_slice(&nonce);
73 out.extend_from_slice(&sealed);
74 Ok(hex::encode(out))
75}
76
77pub fn open(sealed: &str) -> Result<String, AtRestCipherError> {
78 let bytes = hex::decode(sealed).map_err(|_e| AtRestCipherError::Malformed)?;
79 if bytes.len() <= NONCE_BYTES {
80 return Err(AtRestCipherError::Malformed);
81 }
82 let nonce: [u8; NONCE_BYTES] = bytes[..NONCE_BYTES]
83 .try_into()
84 .map_err(|_e| AtRestCipherError::Malformed)?;
85 let plaintext = cipher()?
86 .decrypt(&Nonce::from(nonce), &bytes[NONCE_BYTES..])
87 .map_err(|_e| AtRestCipherError::NotAuthentic)?;
88 String::from_utf8(plaintext).map_err(|_e| AtRestCipherError::Malformed)
89}
90
91#[expect(
92 clippy::expect_used,
93 reason = "HMAC-SHA256 accepts any key length by construction; new_from_slice cannot fail here"
94)]
95pub fn hmac_sha256(pepper: &[u8], value: &[u8]) -> [u8; 32] {
96 let mut mac = HmacSha256::new_from_slice(pepper).expect("HMAC accepts any key length");
97 mac.update(value);
98 let result = mac.finalize().into_bytes();
99 let mut out = [0u8; 32];
100 out.copy_from_slice(&result);
101 out
102}
103
104pub fn hmac_sha256_hex(pepper: &[u8], value: &[u8]) -> String {
105 hex::encode(hmac_sha256(pepper, value))
106}