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.
This commit is contained in:
Mitchell R 2026-05-23 01:36:43 +02:00
parent 9bbbdd19ea
commit caf6095b6e
No known key found for this signature in database
7 changed files with 61 additions and 6 deletions

View file

@ -264,12 +264,23 @@ pub fn initiate_pairing(server: &str) -> (String, String) {
(resp.code, resp.expires_at) (resp.code, resp.expires_at)
} }
fn encrypt_key_file() -> PathBuf {
state_dir().join("encrypt.key")
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct ClaimResp { 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>, cluster_key: Option<String>,
encrypt_key: Option<String>,
}
/// Load the per-kiosk encryption key. Preferred over cluster_key for
/// decrypting camera passwords in the bundle.
pub fn load_encrypt_key() -> Option<String> {
crate::at_rest::read_text_maybe_encrypted(&encrypt_key_file())
} }
/// Poll for pairing claim. Returns (name, key) when admin confirms. /// 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()); 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");
// Store cluster key for ONVIF password decryption. // Store cluster key for backward compat ONVIF password decryption.
if let Some(ref ck) = claim.cluster_key { if let Some(ref ck) = claim.cluster_key {
let _ = crate::at_rest::write_encrypted(&cluster_key_file(), ck.as_bytes()); 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(); crate::remote_debug::reset_all_lockouts();
return (name, key); return (name, key);
} }

View file

@ -753,8 +753,9 @@ 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.
let cluster_key = server::load_cluster_key(); // Prefer per-kiosk encryption key over shared cluster key.
onvif_events::start(&bundle.cameras, cluster_key.as_deref(), server_url, kiosk_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(); let displays = bundle.normalized_displays();
if displays.is_empty() { if displays.is_empty() {

View file

@ -288,6 +288,7 @@ export function rowToKiosk(r: Row): Kiosk {
local_key: sn(r["local_key"]), local_key: sn(r["local_key"]),
local_port: nn(r["local_port"]), local_port: nn(r["local_port"]),
local_last_ip: sn(r["local_last_ip"]), local_last_ip: sn(r["local_last_ip"]),
encrypt_key_encrypted: sn(r["encrypt_key_encrypted"]),
reported_hostname: sn(r["reported_hostname"]), reported_hostname: sn(r["reported_hostname"]),
network_interfaces_json: sn(r["network_interfaces_json"]), network_interfaces_json: sn(r["network_interfaces_json"]),
managed_image: b(r["managed_image"]), managed_image: b(r["managed_image"]),

View file

@ -960,6 +960,14 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL"); 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 // ONVIF event routing: per-camera event_source (who polls), event_sink
// (where push callbacks go), and discovered supported topics. // (where push callbacks go), and discovered supported topics.
(db: DatabaseSync) => { (db: DatabaseSync) => {

View file

@ -115,6 +115,16 @@ export function generateBundle(
const kiosk = repo.getKioskById(kioskId); const kiosk = repo.getKioskById(kioskId);
if (!kiosk) return null; 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) // Find all displays for this kiosk (displays now point to kiosks via kiosk_id)
const kioskDisplays = repo.listDisplaysForKiosk(kioskId); const kioskDisplays = repo.listDisplaysForKiosk(kioskId);
// Fall back to legacy kiosk.display_id if no displays point to this kiosk yet // 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; let onvifPwEncrypted: string | null = null;
if (cam.onvif_password && clusterKey) { const encryptKey = kioskEncryptKey ?? clusterKey;
onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, clusterKey); if (cam.onvif_password && encryptKey) {
onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, encryptKey);
} }
return { return {
id: cam.id, id: cam.id,

View file

@ -216,13 +216,23 @@ export async function confirmPairing(
kioskName = candidate; 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 clusterKeyEncrypted = repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
const clusterKey = clusterKeyEncrypted ? secrets.decryptString(clusterKeyEncrypted, "cluster") : undefined; const clusterKey = clusterKeyEncrypted ? secrets.decryptString(clusterKeyEncrypted, "cluster") : undefined;
repo.markPairingCodeClaimed(input.code, kioskId, { repo.markPairingCodeClaimed(input.code, kioskId, {
kiosk_key_plaintext: kioskKeyPlaintext, kiosk_key_plaintext: kioskKeyPlaintext,
cluster_key: clusterKey, cluster_key: clusterKey,
encrypt_key: kioskEncryptKey,
}); });
return { kioskId, kioskName }; return { kioskId, kioskName };

View file

@ -240,6 +240,11 @@ export interface Kiosk {
local_key: string | null; local_key: string | null;
local_port: number | null; local_port: number | null;
local_last_ip: string | 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; reported_hostname: string | null;
network_interfaces_json: string | null; network_interfaces_json: string | null;
// Managed-image device config. Only meaningful when managed_image=true; for // Managed-image device config. Only meaningful when managed_image=true; for