mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
d4ac406f58
commit
4cf9704350
4 changed files with 53 additions and 13 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue