Skip to main content

systemprompt_security/
at_rest.rs

1//! At-rest hashing for secrets that must be looked up by exact match but
2//! must not survive a database read.
3//!
4//! The deployment's `oauth_at_rest_pepper` is a process-resident HMAC key.
5//! Refresh-token identifiers and authorisation codes are stored as the
6//! lowercase-hex HMAC-SHA-256 of the raw value under that key, so a leaked
7//! database backup or replica snapshot yields opaque digests rather than
8//! live credentials.
9//!
10//! Where a stored secret has to be replayed upstream rather than merely
11//! matched — an MCP proxy identity's bearer JWT, say — a digest is no use and
12//! [`seal`]/[`open`] encrypt it instead, with ChaCha20-Poly1305 under the
13//! deployment's `encryption_master_key`. That is the same key and the same
14//! AEAD the gateway accounting journal uses on disk, so a deployment has one
15//! at-rest key to hold, not two.
16//!
17//! Rotation is out of scope here: rolling the pepper invalidates every
18//! row hashed under the old key, and rolling the master key invalidates every
19//! sealed value. The schema reserves no `pepper_version` column today; a
20//! future migration would add one if graceful rotation is required.
21//!
22//! Copyright (c) systemprompt.io — Business Source License 1.1.
23//! See <https://systemprompt.io> for licensing details.
24
25use 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/// Why a sealed value could not be produced or read back. Every variant is an
35/// operator or key fault; none of them carries the value or the key.
36#[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}