heldar_kernel/services/
secrets.rs1use 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
21const PREFIX: &str = "enc:v1:";
23
24static KEY: OnceLock<Option<[u8; 32]>> = OnceLock::new();
26
27pub 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 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
55pub fn enabled() -> bool {
57 process_key().is_some()
58}
59
60pub fn is_encrypted(stored: &str) -> bool {
62 stored.starts_with(PREFIX)
63}
64
65pub fn encrypt_for_storage(plaintext: &str) -> Result<String> {
67 encrypt(process_key(), plaintext)
68}
69
70pub fn decrypt_stored(stored: &str) -> Result<String> {
72 decrypt(process_key(), stored)
73}
74
75pub 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
99pub 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()); };
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
125pub 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 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 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}