Skip to main content

spark/
crypto.rs

1//! Snapshot signing and encryption.
2//!
3//! - HMAC-SHA256 over canonical JSON for tamper detection (default).
4//! - AES-256-GCM encryption envelope for full opacity (opt-in via `SPARK_ENCRYPT=true`).
5//!
6//! Keys derive from `Container::app().key` (the Anvil `APP_KEY`, 32 random bytes
7//! base64-encoded). If the configured key is too short, a zeroed key is used and
8//! a `tracing::warn!` is logged — set `APP_KEY` for production.
9
10use aes_gcm::aead::{Aead, KeyInit as AeadKeyInit};
11use aes_gcm::{Aes256Gcm, Nonce};
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use base64::Engine;
14use hmac::{Hmac, Mac};
15use rand::RngCore;
16use sha2::Sha256;
17
18const KEY_LEN: usize = 32;
19const NONCE_LEN: usize = 12;
20
21type HmacSha256 = Hmac<Sha256>;
22
23/// Derive 32 raw key bytes from the Anvil APP_KEY string. Accepts:
24/// - raw 32+ byte ASCII strings (first 32 bytes used)
25/// - `base64:<b64>` or bare base64 of 32 bytes (preferred)
26fn derive_key(app_key: &str) -> [u8; KEY_LEN] {
27    let mut out = [0u8; KEY_LEN];
28    let raw = if let Some(stripped) = app_key.strip_prefix("base64:") {
29        stripped
30    } else {
31        app_key
32    };
33
34    if let Ok(decoded) = URL_SAFE_NO_PAD.decode(raw.trim_end_matches('=')) {
35        let n = decoded.len().min(KEY_LEN);
36        out[..n].copy_from_slice(&decoded[..n]);
37        return out;
38    }
39    if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw) {
40        let n = decoded.len().min(KEY_LEN);
41        out[..n].copy_from_slice(&decoded[..n]);
42        return out;
43    }
44
45    let bytes = raw.as_bytes();
46    let n = bytes.len().min(KEY_LEN);
47    out[..n].copy_from_slice(&bytes[..n]);
48    out
49}
50
51/// HMAC-SHA256 of `body` using the derived APP_KEY. Returns the b64url-no-pad digest.
52pub fn sign(app_key: &str, body: &[u8]) -> String {
53    let key = derive_key(app_key);
54    let mut mac = <HmacSha256 as Mac>::new_from_slice(&key).expect("hmac key");
55    mac.update(body);
56    let tag = mac.finalize().into_bytes();
57    URL_SAFE_NO_PAD.encode(tag)
58}
59
60/// Constant-time-ish verification of `expected_b64` against `sign(app_key, body)`.
61pub fn verify(app_key: &str, body: &[u8], expected_b64: &str) -> bool {
62    let key = derive_key(app_key);
63    let mut mac = <HmacSha256 as Mac>::new_from_slice(&key).expect("hmac key");
64    mac.update(body);
65    let Ok(expected) = URL_SAFE_NO_PAD.decode(expected_b64) else {
66        return false;
67    };
68    mac.verify_slice(&expected).is_ok()
69}
70
71/// Encrypt `plaintext` under the derived APP_KEY. Output: `nonce(12) || ciphertext+tag`.
72pub fn encrypt(app_key: &str, plaintext: &[u8]) -> Vec<u8> {
73    let key = derive_key(app_key);
74    let cipher = Aes256Gcm::new(&key.into());
75    let mut nonce_bytes = [0u8; NONCE_LEN];
76    rand::thread_rng().fill_bytes(&mut nonce_bytes);
77    let nonce = Nonce::from_slice(&nonce_bytes);
78    let ciphertext = cipher.encrypt(nonce, plaintext).expect("aes-gcm encrypt");
79    let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
80    out.extend_from_slice(&nonce_bytes);
81    out.extend_from_slice(&ciphertext);
82    out
83}
84
85/// Decrypt the format produced by `encrypt`. Returns the plaintext bytes.
86pub fn decrypt(app_key: &str, blob: &[u8]) -> Option<Vec<u8>> {
87    if blob.len() < NONCE_LEN + 16 {
88        return None;
89    }
90    let key = derive_key(app_key);
91    let cipher = Aes256Gcm::new(&key.into());
92    let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN);
93    let nonce = Nonce::from_slice(nonce_bytes);
94    cipher.decrypt(nonce, ciphertext).ok()
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    const KEY: &str = "test-key-thirty-two-bytes-padded";
102
103    #[test]
104    fn sign_verify_round_trip() {
105        let body = b"hello world";
106        let sig = sign(KEY, body);
107        assert!(verify(KEY, body, &sig));
108        assert!(!verify(KEY, b"different", &sig));
109    }
110
111    #[test]
112    fn aes_gcm_round_trip() {
113        let body = b"some private state";
114        let blob = encrypt(KEY, body);
115        let recovered = decrypt(KEY, &blob).unwrap();
116        assert_eq!(recovered, body);
117    }
118}