use std::sync::OnceLock;
use anyhow::{anyhow, Context, Result};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use rand_core::{OsRng, RngCore};
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
const PREFIX: &str = "enc:v1:";
static KEY: OnceLock<Option<[u8; 32]>> = OnceLock::new();
pub fn init_key(secret_key_b64: Option<&str>) -> Result<()> {
let key = match secret_key_b64.map(str::trim).filter(|s| !s.is_empty()) {
None => None,
Some(b64) => {
let bytes = B64
.decode(b64)
.context("HELDAR_SECRET_KEY must be valid base64")?;
let key: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
anyhow!(
"HELDAR_SECRET_KEY must decode to 32 bytes (got {})",
bytes.len()
)
})?;
Some(key)
}
};
let _ = KEY.set(key);
Ok(())
}
fn process_key() -> Option<&'static [u8; 32]> {
KEY.get().and_then(|k| k.as_ref())
}
pub fn enabled() -> bool {
process_key().is_some()
}
pub fn is_encrypted(stored: &str) -> bool {
stored.starts_with(PREFIX)
}
pub fn encrypt_for_storage(plaintext: &str) -> Result<String> {
encrypt(process_key(), plaintext)
}
pub fn decrypt_stored(stored: &str) -> Result<String> {
decrypt(process_key(), stored)
}
pub fn encrypt(key: Option<&[u8; 32]>, plaintext: &str) -> Result<String> {
let Some(key) = key else {
return Ok(plaintext.to_string());
};
let sealing = LessSafeKey::new(
UnboundKey::new(&AES_256_GCM, key).map_err(|_| anyhow!("invalid AES-256 key"))?,
);
let mut nonce = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce);
let mut in_out = plaintext.as_bytes().to_vec();
sealing
.seal_in_place_append_tag(
Nonce::assume_unique_for_key(nonce),
Aad::empty(),
&mut in_out,
)
.map_err(|_| anyhow!("seal failed"))?;
let mut blob = Vec::with_capacity(NONCE_LEN + in_out.len());
blob.extend_from_slice(&nonce);
blob.extend_from_slice(&in_out);
Ok(format!("{PREFIX}{}", B64.encode(blob)))
}
pub fn decrypt(key: Option<&[u8; 32]>, stored: &str) -> Result<String> {
let Some(rest) = stored.strip_prefix(PREFIX) else {
return Ok(stored.to_string()); };
let key = key
.ok_or_else(|| anyhow!("an encrypted secret is stored but HELDAR_SECRET_KEY is not set"))?;
let blob = B64
.decode(rest)
.context("malformed encrypted secret (base64)")?;
if blob.len() <= NONCE_LEN {
return Err(anyhow!("encrypted secret too short"));
}
let (nonce, ct) = blob.split_at(NONCE_LEN);
let nonce: [u8; NONCE_LEN] = nonce.try_into().expect("checked length");
let opening = LessSafeKey::new(
UnboundKey::new(&AES_256_GCM, key).map_err(|_| anyhow!("invalid AES-256 key"))?,
);
let mut buf = ct.to_vec();
let plain = opening
.open_in_place(Nonce::assume_unique_for_key(nonce), Aad::empty(), &mut buf)
.map_err(|_| anyhow!("decrypt failed (wrong key or corrupt secret)"))?;
String::from_utf8(plain.to_vec()).context("decrypted secret is not valid UTF-8")
}
pub async fn reencrypt_camera_passwords(pool: &sqlx::SqlitePool) -> Result<usize> {
if !enabled() {
return Ok(0);
}
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT id, password FROM cameras WHERE password IS NOT NULL AND password != ''",
)
.fetch_all(pool)
.await?;
let mut n = 0usize;
for (id, pw) in rows {
if is_encrypted(&pw) {
continue;
}
let sealed = encrypt_for_storage(&pw)?;
sqlx::query("UPDATE cameras SET password = ? WHERE id = ?")
.bind(&sealed)
.bind(&id)
.execute(pool)
.await?;
n += 1;
}
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> [u8; 32] {
let mut k = [0u8; 32];
for (i, b) in k.iter_mut().enumerate() {
*b = i as u8;
}
k
}
#[test]
fn round_trip_with_key() {
let k = key();
let sealed = encrypt(Some(&k), "SohHikVision").unwrap();
assert!(
sealed.starts_with(PREFIX),
"sealed value carries the marker"
);
assert!(
!sealed.contains("SohHikVision"),
"plaintext must not appear"
);
assert_eq!(decrypt(Some(&k), &sealed).unwrap(), "SohHikVision");
}
#[test]
fn no_key_is_plaintext_passthrough() {
assert_eq!(encrypt(None, "secret").unwrap(), "secret");
assert_eq!(decrypt(None, "secret").unwrap(), "secret");
}
#[test]
fn legacy_plaintext_reads_through_even_with_key() {
assert_eq!(
decrypt(Some(&key()), "legacy-plain").unwrap(),
"legacy-plain"
);
}
#[test]
fn sealed_without_key_errors() {
let sealed = encrypt(Some(&key()), "secret").unwrap();
assert!(
decrypt(None, &sealed).is_err(),
"must not silently return ciphertext"
);
}
#[test]
fn wrong_key_errors() {
let sealed = encrypt(Some(&key()), "secret").unwrap();
let mut wrong = key();
wrong[0] ^= 0xff;
assert!(decrypt(Some(&wrong), &sealed).is_err());
}
#[test]
fn nonce_is_random_per_call() {
let k = key();
assert_ne!(
encrypt(Some(&k), "x").unwrap(),
encrypt(Some(&k), "x").unwrap(),
"fresh nonce per encryption"
);
}
}