mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +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)
|
||||
}
|
||||
|
||||
fn encrypt_key_file() -> PathBuf {
|
||||
state_dir().join("encrypt.key")
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ClaimResp {
|
||||
status: String,
|
||||
kiosk_key: Option<String>,
|
||||
kiosk_name: 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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue