mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
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:
parent
9bbbdd19ea
commit
caf6095b6e
7 changed files with 61 additions and 6 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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"]),
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue