From 4cf9704350be0e793c1197e56fb4ce321e4ceb99 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Fri, 22 May 2026 22:18:25 +0200 Subject: [PATCH] fix(onvif-events): store cluster_key at pairing + implement AES-256-GCM decrypt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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... 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. --- deploy/angie/betterframe.docker.conf | 3 ++ kiosk/src/onvif_events.rs | 46 +++++++++++++++++++++------- kiosk/src/server.rs | 14 ++++++++- kiosk/src/ui.rs | 3 +- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/deploy/angie/betterframe.docker.conf b/deploy/angie/betterframe.docker.conf index 02e192f..28e8fb7 100644 --- a/deploy/angie/betterframe.docker.conf +++ b/deploy/angie/betterframe.docker.conf @@ -50,6 +50,9 @@ server { proxy_pass http://betterframe_admin; proxy_set_header Host $host; 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 { diff --git a/kiosk/src/onvif_events.rs b/kiosk/src/onvif_events.rs index dc95b5a..b52b76f 100644 --- a/kiosk/src/onvif_events.rs +++ b/kiosk/src/onvif_events.rs @@ -35,10 +35,6 @@ pub fn start( server_url: &str, kiosk_key: &str, ) { - if std::env::var("BF_ENABLE_ONVIF_EVENTS").as_deref() != Ok("1") { - return; - } - let onvif_cams: Vec<_> = cameras .iter() .filter(|c| c.cam_type == "onvif" && c.onvif_host.is_some()) @@ -450,13 +446,41 @@ fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent // ---- Cluster key decryption ------------------------------------------------ -fn decrypt_cluster(ciphertext: &str, _cluster_key: &str) -> Option { - // TODO: AES-256-GCM decrypt using the cluster key delivered at pairing. - // For now, RTSP URIs in the bundle already have plaintext credentials - // embedded, so most deployments work without this path. Full cluster-key - // decrypt needs the same HKDF + AES-GCM as the server's secrets.ts. - let _ = ciphertext; - None +/// Decrypt a value encrypted with secrets.encryptForCluster on the server. +/// Format: "v1...". AES-256-GCM. +/// cluster_key is base64url-encoded 32-byte key. +fn decrypt_cluster(ciphertext: &str, cluster_key_b64u: &str) -> Option { + use aes_gcm::{Aes256Gcm, Key, Nonce, aead::{Aead, KeyInit}}; + 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::()); + 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::::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 + } + } } fn chrono_now() -> String { diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 2d4e2fa..f2ea4df 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -102,6 +102,9 @@ fn server_file() -> PathBuf { fn bundle_cache_path() -> PathBuf { state_dir().join("bundle.json") } +fn cluster_key_file() -> PathBuf { + state_dir().join("cluster.key") +} fn local_key_file() -> PathBuf { state_dir().join("local.key") } @@ -208,6 +211,11 @@ pub fn is_paired() -> bool { key_file().exists() } +/// Load cluster key (if stored from pairing). Used for ONVIF password decrypt. +pub fn load_cluster_key() -> Option { + crate::at_rest::read_text_maybe_encrypted(&cluster_key_file()) +} + /// Read stored kiosk key. Detects legacy plaintext (kiosks upgraded from /// a pre-at_rest build) and re-stores it ciphertext in place so subsequent /// SD-card extractions don't see the bearer token. @@ -261,6 +269,7 @@ struct ClaimResp { status: String, kiosk_key: Option, kiosk_name: Option, + cluster_key: Option, } /// 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()); crate::at_rest::write_encrypted(&key_file(), key.as_bytes()) .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(); return (name, key); } diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 4543d3b..8d6fee8 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -750,7 +750,8 @@ fn render_bundle( // (Re)start ONVIF event subscriptions for all ONVIF cameras in the bundle. // 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(); if displays.is_empty() {