From caf6095b6e809e44ffe1c51c511d2c451c149096 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sat, 23 May 2026 01:36:43 +0200 Subject: [PATCH] feat(security): per-kiosk encryption keys for camera passwords Replaces shared cluster_key for bundle encryption. Each kiosk gets a unique 32-byte AES key generated at pairing time: Server: - confirmPairing generates randomBytes(32), stores encrypted with server secret on kiosks.encrypt_key_encrypted column - Delivers plaintext encrypt_key to kiosk in claim response (one-time) - generateBundle prefers per-kiosk key over cluster_key for encryptForCluster (same AES-256-GCM format, different key per kiosk) Kiosk: - ClaimResp gains encrypt_key field, stored encrypted at rest - onvif_events prefers encrypt_key over cluster_key for decryption - Backward compatible: old kiosks without encrypt_key still use cluster_key (both delivered at pairing) Security improvement: compromised SD card only exposes camera passwords encrypted for THAT specific kiosk, not the entire fleet. Rotate by deleting + re-pairing the compromised kiosk. --- kiosk/src/server.rs | 17 ++++++++++++++++- kiosk/src/ui.rs | 5 +++-- server/src/plugins/service-store/mappers.ts | 1 + .../src/plugins/service-store/migrations.ts | 8 ++++++++ server/src/shared/bundle.ts | 19 +++++++++++++++++-- server/src/shared/pairing.ts | 12 +++++++++++- server/src/shared/types.ts | 5 +++++ 7 files changed, 61 insertions(+), 6 deletions(-) diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 557d555..ba60c67 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -264,12 +264,23 @@ pub fn initiate_pairing(server: &str) -> (String, String) { (resp.code, resp.expires_at) } +fn encrypt_key_file() -> PathBuf { + state_dir().join("encrypt.key") +} + #[derive(Deserialize)] struct ClaimResp { status: String, kiosk_key: Option, kiosk_name: Option, cluster_key: Option, + encrypt_key: Option, +} + +/// Load the per-kiosk encryption key. Preferred over cluster_key for +/// decrypting camera passwords in the bundle. +pub fn load_encrypt_key() -> Option { + crate::at_rest::read_text_maybe_encrypted(&encrypt_key_file()) } /// Poll for pairing claim. Returns (name, key) when admin confirms. @@ -289,10 +300,14 @@ 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"); - // Store cluster key for ONVIF password decryption. + // Store cluster key for backward compat ONVIF password decryption. if let Some(ref ck) = claim.cluster_key { let _ = crate::at_rest::write_encrypted(&cluster_key_file(), ck.as_bytes()); } + // Store per-kiosk encryption key (preferred over cluster_key). + if let Some(ref ek) = claim.encrypt_key { + let _ = crate::at_rest::write_encrypted(&encrypt_key_file(), ek.as_bytes()); + } crate::remote_debug::reset_all_lockouts(); return (name, key); } diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index b8632a9..8ebb8ca 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -753,8 +753,9 @@ 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. - let cluster_key = server::load_cluster_key(); - onvif_events::start(&bundle.cameras, cluster_key.as_deref(), server_url, kiosk_key); + // Prefer per-kiosk encryption key over shared cluster key. + let decrypt_key = server::load_encrypt_key().or_else(|| server::load_cluster_key()); + onvif_events::start(&bundle.cameras, decrypt_key.as_deref(), server_url, kiosk_key); let displays = bundle.normalized_displays(); if displays.is_empty() { diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index b6374c7..1efb7b8 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -288,6 +288,7 @@ export function rowToKiosk(r: Row): Kiosk { local_key: sn(r["local_key"]), local_port: nn(r["local_port"]), local_last_ip: sn(r["local_last_ip"]), + encrypt_key_encrypted: sn(r["encrypt_key_encrypted"]), reported_hostname: sn(r["reported_hostname"]), network_interfaces_json: sn(r["network_interfaces_json"]), managed_image: b(r["managed_image"]), diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 78760c3..fa3980a 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -960,6 +960,14 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL"); }, + // Per-kiosk encryption key. Replaces shared cluster_key for bundle + // encryption. Generated at pairing, stored encrypted with server secret, + // delivered to kiosk once. Compromised SD → only this kiosk's camera + // passwords exposed (not fleet-wide). + (db: DatabaseSync) => { + addColumnIfNotExists(db, "kiosks", "encrypt_key_encrypted", "TEXT"); + }, + // ONVIF event routing: per-camera event_source (who polls), event_sink // (where push callbacks go), and discovered supported topics. (db: DatabaseSync) => { diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index 7d3cea7..bbaaca9 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -115,6 +115,16 @@ export function generateBundle( const kiosk = repo.getKioskById(kioskId); if (!kiosk) return null; + // Per-kiosk encryption key (preferred) — decrypt from server storage. + let kioskEncryptKey: string | undefined; + if (kiosk.encrypt_key_encrypted) { + try { + kioskEncryptKey = secrets.decryptString(kiosk.encrypt_key_encrypted, "kiosk-encrypt"); + } catch { + // Decrypt failed — fall back to cluster key. + } + } + // Find all displays for this kiosk (displays now point to kiosks via kiosk_id) const kioskDisplays = repo.listDisplaysForKiosk(kioskId); // Fall back to legacy kiosk.display_id if no displays point to this kiosk yet @@ -224,9 +234,14 @@ export function generateBundle( }] : [] ); + // Encrypt camera password with per-kiosk key if available (stronger + // isolation — compromised SD only exposes this kiosk's cameras). Falls + // back to shared cluster_key for kiosks that paired before per-kiosk + // keys were introduced. let onvifPwEncrypted: string | null = null; - if (cam.onvif_password && clusterKey) { - onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, clusterKey); + const encryptKey = kioskEncryptKey ?? clusterKey; + if (cam.onvif_password && encryptKey) { + onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, encryptKey); } return { id: cam.id, diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index 7e63478..a02323b 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -216,13 +216,23 @@ export async function confirmPairing( kioskName = candidate; } - // Cluster key delivery (shared across pair + replace) + // Per-kiosk encryption key: generate a fresh 32-byte key for this kiosk, + // store it encrypted with the server's secret, deliver plaintext to the + // kiosk (one-time). Replaces shared cluster_key for bundle encryption. + const kioskEncryptKey = randomBytes(32).toString("base64url"); + const kioskEncryptKeyEncrypted = secrets.encryptString(kioskEncryptKey, "kiosk-encrypt"); + repo.updateKiosk(kioskId, { encrypt_key_encrypted: kioskEncryptKeyEncrypted } as any); + + // Still deliver cluster_key for backward compat (old kiosk binaries + // that don't understand encrypt_key yet). Remove once all kiosks are + // on the new binary. const clusterKeyEncrypted = repo.getSetupExtra("cluster_key_encrypted") as string | undefined; const clusterKey = clusterKeyEncrypted ? secrets.decryptString(clusterKeyEncrypted, "cluster") : undefined; repo.markPairingCodeClaimed(input.code, kioskId, { kiosk_key_plaintext: kioskKeyPlaintext, cluster_key: clusterKey, + encrypt_key: kioskEncryptKey, }); return { kioskId, kioskName }; diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index f0c14d4..bbbf3ce 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -240,6 +240,11 @@ export interface Kiosk { local_key: string | null; local_port: number | null; local_last_ip: string | null; + /** Per-kiosk symmetric key for encrypting camera passwords in the bundle. + * Stored server-side encrypted with the server secret. Delivered to the + * kiosk once at pairing. Replaces the shared cluster_key for bundle + * encryption so a compromised SD card only exposes this kiosk's cameras. */ + encrypt_key_encrypted: string | null; reported_hostname: string | null; network_interfaces_json: string | null; // Managed-image device config. Only meaningful when managed_image=true; for