fix(onvif-events): store cluster_key at pairing + implement AES-256-GCM decrypt

Root cause: kiosk never stored cluster_key from pairing response.
Bundle ships onvif_password_encrypted (AES-256-GCM with cluster key).
decrypt_cluster was a stub returning None → empty password → WSSE auth
fails → CreatePullPoint rejected → no events ever.

Fix:
1. ClaimResp now includes cluster_key field
2. Stored encrypted at rest alongside kiosk_key (at_rest.rs)
3. Loaded at bundle render, passed to onvif_events::start()
4. decrypt_cluster implements full AES-256-GCM: parse v1.<iv>.<tag>.<ct>
   format, base64url decode, decrypt with cluster key

Also: removed BF_ENABLE_ONVIF_EVENTS env gate — if camera is type=onvif
with onvif_host, subscribe. Gate was redundant with the type filter.

Also: bump Angie proxy_read_timeout to 600s on /api/admin/ for OS
bundle import (downloads ~1GB from GitHub, was timing out at 60s).

NOTE: existing paired kiosks won't have cluster_key stored. They need
to re-pair (delete + re-add) to receive it. New pairings get it
automatically.
This commit is contained in:
Mitchell R 2026-05-22 22:18:25 +02:00
parent d4ac406f58
commit 4cf9704350
No known key found for this signature in database
4 changed files with 53 additions and 13 deletions

View file

@ -50,6 +50,9 @@ server {
proxy_pass http://betterframe_admin; proxy_pass http://betterframe_admin;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
# OS bundle import downloads ~1GB from GitHub — needs long timeout.
proxy_read_timeout 600s;
proxy_send_timeout 600s;
} }
location /ws/kiosk { location /ws/kiosk {

View file

@ -35,10 +35,6 @@ pub fn start(
server_url: &str, server_url: &str,
kiosk_key: &str, kiosk_key: &str,
) { ) {
if std::env::var("BF_ENABLE_ONVIF_EVENTS").as_deref() != Ok("1") {
return;
}
let onvif_cams: Vec<_> = cameras let onvif_cams: Vec<_> = cameras
.iter() .iter()
.filter(|c| c.cam_type == "onvif" && c.onvif_host.is_some()) .filter(|c| c.cam_type == "onvif" && c.onvif_host.is_some())
@ -450,14 +446,42 @@ fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent
// ---- Cluster key decryption ------------------------------------------------ // ---- Cluster key decryption ------------------------------------------------
fn decrypt_cluster(ciphertext: &str, _cluster_key: &str) -> Option<String> { /// Decrypt a value encrypted with secrets.encryptForCluster on the server.
// TODO: AES-256-GCM decrypt using the cluster key delivered at pairing. /// Format: "v1.<iv_b64u>.<tag_b64u>.<ct_b64u>". AES-256-GCM.
// For now, RTSP URIs in the bundle already have plaintext credentials /// cluster_key is base64url-encoded 32-byte key.
// embedded, so most deployments work without this path. Full cluster-key fn decrypt_cluster(ciphertext: &str, cluster_key_b64u: &str) -> Option<String> {
// decrypt needs the same HKDF + AES-GCM as the server's secrets.ts. use aes_gcm::{Aes256Gcm, Key, Nonce, aead::{Aead, KeyInit}};
let _ = ciphertext; use base64::Engine;
let b64u = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let parts: Vec<&str> = ciphertext.split('.').collect();
if parts.len() != 4 || parts[0] != "v1" {
warn!("decrypt_cluster: bad format: {}", ciphertext.chars().take(20).collect::<String>());
return None;
}
let iv = b64u.decode(parts[1]).ok()?;
let tag = b64u.decode(parts[2]).ok()?;
let ct = b64u.decode(parts[3]).ok()?;
let key_bytes = b64u.decode(cluster_key_b64u).ok()?;
if key_bytes.len() != 32 || iv.len() != 12 || tag.len() != 16 {
warn!("decrypt_cluster: bad lengths key={} iv={} tag={}", key_bytes.len(), iv.len(), tag.len());
return None;
}
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
let nonce = Nonce::from_slice(&iv);
// AES-GCM ciphertext+tag concatenated for decryption.
let mut combined = ct;
combined.extend_from_slice(&tag);
match cipher.decrypt(nonce, combined.as_ref()) {
Ok(plaintext) => String::from_utf8(plaintext).ok(),
Err(e) => {
warn!("decrypt_cluster: decrypt failed: {e}");
None None
} }
}
}
fn chrono_now() -> String { fn chrono_now() -> String {
let secs = std::time::SystemTime::now() let secs = std::time::SystemTime::now()

View file

@ -102,6 +102,9 @@ fn server_file() -> PathBuf {
fn bundle_cache_path() -> PathBuf { fn bundle_cache_path() -> PathBuf {
state_dir().join("bundle.json") state_dir().join("bundle.json")
} }
fn cluster_key_file() -> PathBuf {
state_dir().join("cluster.key")
}
fn local_key_file() -> PathBuf { fn local_key_file() -> PathBuf {
state_dir().join("local.key") state_dir().join("local.key")
} }
@ -208,6 +211,11 @@ pub fn is_paired() -> bool {
key_file().exists() key_file().exists()
} }
/// Load cluster key (if stored from pairing). Used for ONVIF password decrypt.
pub fn load_cluster_key() -> Option<String> {
crate::at_rest::read_text_maybe_encrypted(&cluster_key_file())
}
/// Read stored kiosk key. Detects legacy plaintext (kiosks upgraded from /// Read stored kiosk key. Detects legacy plaintext (kiosks upgraded from
/// a pre-at_rest build) and re-stores it ciphertext in place so subsequent /// a pre-at_rest build) and re-stores it ciphertext in place so subsequent
/// SD-card extractions don't see the bearer token. /// SD-card extractions don't see the bearer token.
@ -261,6 +269,7 @@ struct ClaimResp {
status: String, status: String,
kiosk_key: Option<String>, kiosk_key: Option<String>,
kiosk_name: Option<String>, kiosk_name: Option<String>,
cluster_key: Option<String>,
} }
/// Poll for pairing claim. Returns (name, key) when admin confirms. /// Poll for pairing claim. Returns (name, key) when admin confirms.
@ -280,7 +289,10 @@ pub fn poll_claim(server: &str, code: &str) -> (String, String) {
let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into()); let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into());
crate::at_rest::write_encrypted(&key_file(), key.as_bytes()) crate::at_rest::write_encrypted(&key_file(), key.as_bytes())
.expect("failed to save kiosk key"); .expect("failed to save kiosk key");
// Successful pairing resets all terminal lockout state. // Store cluster key for ONVIF password decryption.
if let Some(ref ck) = claim.cluster_key {
let _ = crate::at_rest::write_encrypted(&cluster_key_file(), ck.as_bytes());
}
crate::remote_debug::reset_all_lockouts(); crate::remote_debug::reset_all_lockouts();
return (name, key); return (name, key);
} }

View file

@ -750,7 +750,8 @@ fn render_bundle(
// (Re)start ONVIF event subscriptions for all ONVIF cameras in the bundle. // (Re)start ONVIF event subscriptions for all ONVIF cameras in the bundle.
// Workers self-terminate when a new start() call replaces the generation. // Workers self-terminate when a new start() call replaces the generation.
onvif_events::start(&bundle.cameras, None, server_url, kiosk_key); let cluster_key = server::load_cluster_key();
onvif_events::start(&bundle.cameras, cluster_key.as_deref(), server_url, kiosk_key);
let displays = bundle.normalized_displays(); let displays = bundle.normalized_displays();
if displays.is_empty() { if displays.is_empty() {