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_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 {

View file

@ -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,14 +446,42 @@ 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<String> {
// 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;
/// Decrypt a value encrypted with secrets.encryptForCluster on the server.
/// Format: "v1.<iv_b64u>.<tag_b64u>.<ct_b64u>". AES-256-GCM.
/// cluster_key is base64url-encoded 32-byte key.
fn decrypt_cluster(ciphertext: &str, cluster_key_b64u: &str) -> Option<String> {
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::<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
}
}
}
fn chrono_now() -> String {
let secs = std::time::SystemTime::now()

View file

@ -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<String> {
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<String>,
kiosk_name: Option<String>,
cluster_key: Option<String>,
}
/// 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);
}

View file

@ -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() {