Skip to main content

heldar_kernel/services/
secrets.rs

1//! Optional encryption-at-rest for sensitive fields (currently camera credentials), keyed by
2//! `HELDAR_SECRET_KEY`.
3//!
4//! - **No key configured** (the LAN-appliance default, and the open-source default): values are stored
5//!   and served as plaintext — behaviour is unchanged.
6//! - **Key configured** (production): new writes are AES-256-GCM sealed (`enc:v1:` + base64 of
7//!   `nonce ‖ ciphertext+tag`); reads transparently decrypt both sealed and legacy-plaintext values.
8//!
9//! The key is process-global immutable config: [`init_key`] is called once at startup (before any
10//! camera URL is built), and the `camera_url` builder reads it via [`decrypt_stored`]. A sealed value
11//! encountered with no/wrong key is a hard error — the kernel never feeds ciphertext to ffmpeg.
12
13use std::sync::OnceLock;
14
15use anyhow::{anyhow, Context, Result};
16use base64::engine::general_purpose::STANDARD as B64;
17use base64::Engine as _;
18use rand_core::{OsRng, RngCore};
19use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
20
21/// Storage marker for a sealed value. Anything without this prefix is treated as legacy plaintext.
22const PREFIX: &str = "enc:v1:";
23
24/// Process-wide encryption key (None = encryption disabled). Set once at startup via [`init_key`].
25static KEY: OnceLock<Option<[u8; 32]>> = OnceLock::new();
26
27/// Decode + validate `HELDAR_SECRET_KEY` (base64 of 32 bytes) and install it process-wide. Call once
28/// at startup. `None`/empty disables encryption (plaintext at rest). Errors on a malformed key so a
29/// misconfigured master key fails loud at boot rather than silently disabling encryption.
30pub fn init_key(secret_key_b64: Option<&str>) -> Result<()> {
31    let key = match secret_key_b64.map(str::trim).filter(|s| !s.is_empty()) {
32        None => None,
33        Some(b64) => {
34            let bytes = B64
35                .decode(b64)
36                .context("HELDAR_SECRET_KEY must be valid base64")?;
37            let key: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
38                anyhow!(
39                    "HELDAR_SECRET_KEY must decode to 32 bytes (got {})",
40                    bytes.len()
41                )
42            })?;
43            Some(key)
44        }
45    };
46    // First set wins; ignore a redundant re-init with the same intent (e.g. tests).
47    let _ = KEY.set(key);
48    Ok(())
49}
50
51fn process_key() -> Option<&'static [u8; 32]> {
52    KEY.get().and_then(|k| k.as_ref())
53}
54
55/// Whether encryption-at-rest is active for this process.
56pub fn enabled() -> bool {
57    process_key().is_some()
58}
59
60/// Is this stored value already sealed?
61pub fn is_encrypted(stored: &str) -> bool {
62    stored.starts_with(PREFIX)
63}
64
65/// Seal `plaintext` for storage using the process key (plaintext passthrough when no key is set).
66pub fn encrypt_for_storage(plaintext: &str) -> Result<String> {
67    encrypt(process_key(), plaintext)
68}
69
70/// Decrypt a stored value using the process key (legacy-plaintext passthrough; sealed-without-key errors).
71pub fn decrypt_stored(stored: &str) -> Result<String> {
72    decrypt(process_key(), stored)
73}
74
75/// Seal `plaintext` with an explicit key. `None` returns the plaintext unchanged.
76pub fn encrypt(key: Option<&[u8; 32]>, plaintext: &str) -> Result<String> {
77    let Some(key) = key else {
78        return Ok(plaintext.to_string());
79    };
80    let sealing = LessSafeKey::new(
81        UnboundKey::new(&AES_256_GCM, key).map_err(|_| anyhow!("invalid AES-256 key"))?,
82    );
83    let mut nonce = [0u8; NONCE_LEN];
84    OsRng.fill_bytes(&mut nonce);
85    let mut in_out = plaintext.as_bytes().to_vec();
86    sealing
87        .seal_in_place_append_tag(
88            Nonce::assume_unique_for_key(nonce),
89            Aad::empty(),
90            &mut in_out,
91        )
92        .map_err(|_| anyhow!("seal failed"))?;
93    let mut blob = Vec::with_capacity(NONCE_LEN + in_out.len());
94    blob.extend_from_slice(&nonce);
95    blob.extend_from_slice(&in_out);
96    Ok(format!("{PREFIX}{}", B64.encode(blob)))
97}
98
99/// Decrypt a stored value with an explicit key. A value without the `enc:v1:` prefix is returned as-is
100/// (legacy plaintext). A sealed value with `None`/wrong key is an error (never serve ciphertext).
101pub fn decrypt(key: Option<&[u8; 32]>, stored: &str) -> Result<String> {
102    let Some(rest) = stored.strip_prefix(PREFIX) else {
103        return Ok(stored.to_string()); // legacy plaintext
104    };
105    let key = key
106        .ok_or_else(|| anyhow!("an encrypted secret is stored but HELDAR_SECRET_KEY is not set"))?;
107    let blob = B64
108        .decode(rest)
109        .context("malformed encrypted secret (base64)")?;
110    if blob.len() <= NONCE_LEN {
111        return Err(anyhow!("encrypted secret too short"));
112    }
113    let (nonce, ct) = blob.split_at(NONCE_LEN);
114    let nonce: [u8; NONCE_LEN] = nonce.try_into().expect("checked length");
115    let opening = LessSafeKey::new(
116        UnboundKey::new(&AES_256_GCM, key).map_err(|_| anyhow!("invalid AES-256 key"))?,
117    );
118    let mut buf = ct.to_vec();
119    let plain = opening
120        .open_in_place(Nonce::assume_unique_for_key(nonce), Aad::empty(), &mut buf)
121        .map_err(|_| anyhow!("decrypt failed (wrong key or corrupt secret)"))?;
122    String::from_utf8(plain.to_vec()).context("decrypted secret is not valid UTF-8")
123}
124
125/// One-time migration: when a key is configured, seal any legacy-plaintext camera passwords. Idempotent
126/// (skips already-sealed rows). Returns how many rows were re-encrypted. No-op when no key is set.
127pub async fn reencrypt_camera_passwords(pool: &sqlx::SqlitePool) -> Result<usize> {
128    if !enabled() {
129        return Ok(0);
130    }
131    let rows: Vec<(String, String)> = sqlx::query_as(
132        "SELECT id, password FROM cameras WHERE password IS NOT NULL AND password != ''",
133    )
134    .fetch_all(pool)
135    .await?;
136    let mut n = 0usize;
137    for (id, pw) in rows {
138        if is_encrypted(&pw) {
139            continue;
140        }
141        let sealed = encrypt_for_storage(&pw)?;
142        sqlx::query("UPDATE cameras SET password = ? WHERE id = ?")
143            .bind(&sealed)
144            .bind(&id)
145            .execute(pool)
146            .await?;
147        n += 1;
148    }
149    Ok(n)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn key() -> [u8; 32] {
157        let mut k = [0u8; 32];
158        for (i, b) in k.iter_mut().enumerate() {
159            *b = i as u8;
160        }
161        k
162    }
163
164    #[test]
165    fn round_trip_with_key() {
166        let k = key();
167        let sealed = encrypt(Some(&k), "SohHikVision").unwrap();
168        assert!(
169            sealed.starts_with(PREFIX),
170            "sealed value carries the marker"
171        );
172        assert!(
173            !sealed.contains("SohHikVision"),
174            "plaintext must not appear"
175        );
176        assert_eq!(decrypt(Some(&k), &sealed).unwrap(), "SohHikVision");
177    }
178
179    #[test]
180    fn no_key_is_plaintext_passthrough() {
181        // Encrypt + decrypt are identity when no key is configured (LAN appliance / open default).
182        assert_eq!(encrypt(None, "secret").unwrap(), "secret");
183        assert_eq!(decrypt(None, "secret").unwrap(), "secret");
184    }
185
186    #[test]
187    fn legacy_plaintext_reads_through_even_with_key() {
188        // A pre-encryption row (no enc:v1: prefix) still reads, so enabling a key doesn't break
189        // existing cameras before the re-encrypt pass runs.
190        assert_eq!(
191            decrypt(Some(&key()), "legacy-plain").unwrap(),
192            "legacy-plain"
193        );
194    }
195
196    #[test]
197    fn sealed_without_key_errors() {
198        let sealed = encrypt(Some(&key()), "secret").unwrap();
199        assert!(
200            decrypt(None, &sealed).is_err(),
201            "must not silently return ciphertext"
202        );
203    }
204
205    #[test]
206    fn wrong_key_errors() {
207        let sealed = encrypt(Some(&key()), "secret").unwrap();
208        let mut wrong = key();
209        wrong[0] ^= 0xff;
210        assert!(decrypt(Some(&wrong), &sealed).is_err());
211    }
212
213    #[test]
214    fn nonce_is_random_per_call() {
215        let k = key();
216        assert_ne!(
217            encrypt(Some(&k), "x").unwrap(),
218            encrypt(Some(&k), "x").unwrap(),
219            "fresh nonce per encryption"
220        );
221    }
222}