//! Installing a Proxy Box's config from a pendrive. //! //! A bundle is a ZIP (`continuum-config.zip`) holding `config.toml` and //! `config.toml.sha256`. On boot, and while running when a stick appears, //! a Proxy Box looks for one, checks it, and replaces its installed config //! only if the new one differs from what it already has. This module is //! everything between "there is a file on the stick" and "the installed //! config is now different"; *which* installation this is lives in //! `bootstrap.rs`, and what the config means in `config.rs`. //! //! # What the hash does and does not buy - read this before relying on it //! //! The SHA-256 in the bundle detects **corruption and change**: a stick that //! was half-copied, a file edited by hand without regenerating the hash, a //! bit flipped in flash. It does **not** detect **forgery**. The hash sits in //! the same archive as the file it hashes, so anyone able to write the //! pendrive can write a matching pair. It is a checksum, not authentication, //! and nothing here calls it that. //! //! What would give authenticity is a signature (e.g. Ed25519 over the config //! bytes, public key baked into the box image) - the follow-up, not done //! here. Until then the **trust assumption is: whoever can physically put a //! stick in the box is trusted to configure it.** That is the same trust a //! person with physical access already has, and it is why PC mode refuses //! removable media entirely: there the assumption does not hold. //! //! SHA-256 over MD5/CRC32: not for strength (forgery is out of scope as //! above) but because it is the hash every operator's tooling already has //! (`sha256sum`), it costs nothing here (`openssl` is already a direct //! dependency; no `sha2` crate added), and it will not need changing when a //! signature is layered on. //! //! # Encryption: what it adds, and what it does not //! //! The bundle is produced by Continuum Cloud's configurator and carried on a //! stick, so it is a **secret at rest** - it holds the gateway token and //! possibly a wifi PSK - and may be encrypted (WinZip AES-256, "AE-x"). //! //! **Confidentiality: yes, and that is the real new benefit.** A stick left //! in a drawer or posted to a customer no longer discloses the token to //! whoever picks it up. //! //! **Authenticity: not by itself.** WinZip-AES carries an HMAC, so a //! modification made by someone *without the password* is detected. But the //! password must live on the box, so extracting it from one box's image //! yields it for every box that shares it, and with it the ability to build //! bundles that decrypt, verify and install. Encryption therefore raises the //! bar from "anyone with a text editor" to "anyone who can read a firmware //! image" - possibly adequate for the stated goal of keeping customers on the //! configurator - and it is **not tamper-proof**. Nothing here calls it that. //! //! A second, blunter caveat: while [`BundlePolicy::accept_unencrypted`] is //! true (the current choice, see below) an attacker does not need to break //! the encryption at all - they just hand over a *plaintext* bundle. Until //! plaintext is refused, encryption protects the token's confidentiality //! but does not discourage tampering one bit. //! //! **What would resist tampering is a signature** (Ed25519 or similar) with //! only the *public* key in the box image: a public key can check but cannot //! forge, so reading a box's image gains nothing. That is the follow-up and is //! deliberately not implemented. `verify_bundle` is staged so it slots in //! without reshaping the flow - see `check_authenticity`. //! //! **Only AES.** Legacy ZipCrypto is refused outright, whatever key is //! configured: it falls to a known-plaintext attack with a handful of bytes, //! and a config file starts with a very guessable prefix. An entry that is //! encrypted without the AES extra field rejects the bundle before any //! decryption is attempted, so ZipCrypto code is never run on our input. //! //! **Unencrypted bundles are still accepted, loudly.** Installing one logs at //! `warn`. The alternative (refusing them) breaks every stick made before the //! configurator encrypts, and any hand-made one used to provision a box //! during development, with nothing to fall back on in the field. Accepting //! both but logging keeps the transition possible while making the weaker //! state visible; `accept_unencrypted = false` is the one-line switch for //! when it is not wanted, and is what makes encryption mean anything for //! tamper-resistance (previous paragraph). //! //! **Wrong key vs corruption.** An AES entry carries a 16-bit password //! check, so a wrong or missing key is reported as its own reason - "this //! bundle is not for this box" - not as a generic failure. The check has a //! 1-in-65536 false-accept rate; a wrong key that slips through is then //! caught by the HMAC at the end of the entry and reported as an integrity //! failure that says a wrong key is one possible cause. A bundle that *does* //! decrypt then goes through exactly the same chain as a plaintext one. //! //! The key is not designed here: see [`BundleKey`] and [`load_bundle_key`], //! the single seam where provisioning lands. //! //! # Hostile archives //! //! The stick is untrusted input even though its author is trusted: it can be //! mistyped, mis-made by a script, or simply the wrong stick. //! //! - **Zip-slip.** Entry names with a leading `/`, a `..` component, a drive //! prefix or a backslash separator reject the whole bundle. This code never //! writes to a path derived from an entry name - only the two expected //! entries are read, into memory, and the only file written is the //! configured destination - so the check is defence in depth, kept because //! a later change that starts extracting would otherwise be one line from a //! path-traversal bug, and because a bundle with such a name is wrong //! enough to refuse on its own. //! - **Zip bomb.** (For an encrypted bundle the caps apply to the //! *decrypted* output: the read is bounded on what comes out of the //! cipher, and the encrypted entry's size header is as untrusted as a //! plaintext one's.) At most [`MAX_ENTRIES`] entries and [`MAX_ENTRY_BYTES`] //! per entry, checked against the *declared* size before decompressing and //! again against the *actual* output while reading, because a header can //! lie. Only the two expected entries are ever decompressed, so output is //! bounded by twice the per-entry cap whatever the archive claims. The //! file read off the stick is itself capped at [`MAX_BUNDLE_BYTES`] //! so a mis-named multi-gigabyte file cannot exhaust memory first. //! - **Non-regular bundle files** (symlinks, devices) are skipped: a link //! from a stick to a device node would otherwise be read as a bundle. //! //! # A bad stick never bricks the box //! //! Every failure here is an [`Outcome::Rejected`], not an error that //! propagates: the installed config is untouched, the reason is logged at //! `error` level, and the box carries on running with what it has. Install //! is atomic - temp file in the destination directory, `fsync`, `rename` - //! so a stick yanked mid-copy, or power lost mid-write, leaves either the old //! config or the new one and never a truncated mix. The temp file lives in //! the destination's directory (not `/tmp`) because `rename` is only atomic //! within one filesystem. use std::collections::HashSet; use std::fs; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::time::Duration; use crate::bootstrap::{Bootstrap, Mode}; use crate::config::{Config, ProxyFile}; /// The file an operator puts on the stick. pub const BUNDLE_NAME: &str = "continuum-config.zip"; const CONFIG_ENTRY: &str = "config.toml"; const HASH_ENTRY: &str = "config.toml.sha256"; /// A real config is a few hundred bytes; 256 KiB is three orders of /// magnitude of headroom and still small enough to hold in memory twice. pub const MAX_ENTRY_BYTES: u64 = 256 * 1024; /// Two expected entries plus a little for whatever a ZIP tool adds /// (`__MACOSX/` resource forks, a README). A bundle with dozens of entries /// is not a config bundle. pub const MAX_ENTRIES: usize = 16; /// The compressed file. Generous against `MAX_ENTRY_BYTES * MAX_ENTRIES` /// worth of incompressible junk; its job is only to bound the read. pub const MAX_BUNDLE_BYTES: u64 = 1024 * 1024; /// The secret that opens an encrypted bundle (a WinZip-AES password). /// /// `Debug` is hand-written so the key cannot reach a log line by accident. #[derive(Clone, PartialEq, Eq)] pub struct BundleKey(Vec); impl BundleKey { pub fn new(bytes: impl Into>) -> Self { Self(bytes.into()) } } impl std::fmt::Debug for BundleKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("BundleKey()") } } /// Default location of the key on a box. A **placeholder**, not a decision. const BUNDLE_KEY_PATH: &str = "/etc/continuum/bundle.key"; /// THE SEAM WHERE KEY PROVISIONING LANDS. Everything else in this module /// takes a [`BundleKey`] as a value and does not care where it came from. /// /// This reads a file (`$CONTINUUM_BUNDLE_KEY_FILE`, else /// `/etc/continuum/bundle.key`), trailing newline stripped, because something /// had to be here to make the mechanism runnable. It is NOT the answer to /// where a box's key should live or whether it is shared, per-box, or bound /// to the box - that is a product decision still to be made, and replacing /// this function (a TPM, a secure element, a per-box key fetched at /// manufacture) should not require touching anything else. /// /// `Ok(None)` (no key file) is normal: the box then can only take unencrypted /// bundles. A file that exists but is empty or unreadable is an error - a box /// that quietly treated a broken key file as "no key" would reject every /// encrypted stick with a misleading reason. pub fn load_bundle_key() -> anyhow::Result> { let path = std::env::var("CONTINUUM_BUNDLE_KEY_FILE").unwrap_or_else(|_| BUNDLE_KEY_PATH.to_string()); match fs::read(&path) { Ok(mut bytes) => { while matches!(bytes.last(), Some(b'\n' | b'\r')) { bytes.pop(); } anyhow::ensure!(!bytes.is_empty(), "bundle key file {path:?} is empty"); Ok(Some(BundleKey::new(bytes))) } Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), Err(err) => anyhow::bail!("failed to read bundle key file {path:?}: {err}"), } } /// What a box will accept. Passed in rather than read from globals so the /// whole flow is testable and the policy is visible at the call site. #[derive(Debug, Clone)] pub struct BundlePolicy { /// Opens encrypted bundles. `None`: every encrypted bundle is rejected /// as having no key. pub key: Option, /// Whether a bundle that is not encrypted is acceptable. See the module /// docs for why this is `true` today and what that costs. pub accept_unencrypted: bool, } impl BundlePolicy { /// Plaintext accepted, no key. What an unprovisioned box does. pub fn unencrypted_only() -> Self { Self { key: None, accept_unencrypted: true } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { /// PC mode, or no directories configured: nothing was looked at. Disabled, /// No bundle on any scanned medium. NoBundle, /// A valid bundle whose config is byte-identical to the installed one. Unchanged { bundle: PathBuf }, /// The installed config was replaced. `sha256` is the new config's hash. /// `encrypted` is false for a plaintext bundle, which is logged at warn. Installed { bundle: PathBuf, sha256: String, encrypted: bool }, /// The bundle was refused; the installed config is untouched. Rejected { bundle: PathBuf, reason: String }, } #[derive(Debug, thiserror::Error)] enum BundleError { #[error("not a readable ZIP archive: {0}")] NotAnArchive(String), #[error("unsafe entry name {0:?} (absolute path, `..`, drive prefix or backslash)")] UnsafeEntry(String), #[error("{0} entries; at most {MAX_ENTRIES} are allowed")] TooManyEntries(usize), #[error("entry {name:?} is {size} bytes; at most {MAX_ENTRY_BYTES} are allowed")] EntryTooLarge { name: String, size: u64 }, #[error("entry {0:?} uses legacy ZipCrypto encryption, which is broken and refused; only WinZip AES is accepted")] LegacyEncryption(String), #[error("{CONFIG_ENTRY} and {HASH_ENTRY} are not encrypted alike; a bundle is wholly encrypted or not at all")] MixedEncryption, #[error("the bundle is encrypted but this box has no bundle key, so it cannot be opened - this bundle is not for this box")] NoKey, #[error("the bundle is encrypted with a key this box does not have - this bundle is not for this box")] WrongKey, #[error("entry {0:?} failed its AES integrity check after decryption: the data was damaged or modified, or (1 in 65536) the key is wrong")] EncryptedIntegrity(&'static str), #[error("the bundle is not encrypted, and this box requires encrypted bundles")] UnencryptedRefused, #[error("missing entry {0:?}")] MissingEntry(&'static str), #[error("entry {0:?} appears more than once")] DuplicateEntry(&'static str), #[error("entry {0:?} is not a regular file")] NotARegularFile(&'static str), #[error("unreadable entry {name:?}: {detail}")] Unreadable { name: &'static str, detail: String }, #[error("{HASH_ENTRY} is malformed: {0}")] MalformedHash(String), #[error("hash mismatch: {HASH_ENTRY} says {claimed}, {CONFIG_ENTRY} is {actual} - the stick is corrupt or the config was edited without regenerating the hash")] HashMismatch { claimed: String, actual: String }, #[error("{CONFIG_ENTRY} is not valid: {0}")] InvalidConfig(String), } /// A config that passed every check and is safe to install. struct Verified { bytes: Vec, sha256: String, encrypted: bool, } /// Rejects an entry name that could escape a directory it were joined onto. /// /// Checked on the raw string rather than via `Path::components`, because the /// platform's idea of a separator is the wrong test for an archive: a ZIP /// made on Windows uses `\`, and on Linux `..\evil` is a legal single file /// name that becomes a traversal the moment anything extracts it on the /// other platform. Treating both as separators is stricter than needed on /// Linux and costs nothing. fn entry_name_is_safe(name: &str) -> bool { if name.is_empty() || name.contains('\0') || name.starts_with('/') || name.starts_with('\\') { return false; } // `C:foo` and `C:\foo`. let mut chars = name.chars(); if let (Some(first), Some(':')) = (chars.next(), chars.next()) && first.is_ascii_alphabetic() { return false; } !name.split(['/', '\\']).any(|component| component == "..") } /// Reads at most `limit` bytes, failing if the source has more. Separate from /// the archive so the cap on *actual* output - the one that survives a lying /// header - can be tested with a source that never ends. fn read_bounded(source: impl Read, limit: u64) -> io::Result>> { let mut out = Vec::new(); source.take(limit + 1).read_to_end(&mut out)?; Ok((out.len() as u64 <= limit).then_some(out)) } pub fn sha256_hex(bytes: &[u8]) -> String { openssl::sha::sha256(bytes).iter().map(|b| format!("{b:02x}")).collect() } /// Accepts `sha256sum`'s own output (` config.toml`, with or without /// the `*` binary marker) as well as a bare hex digest, because the first is /// what an operator will actually produce and the second is what a script /// will. A filename, if present, must name our config: a hash file copied /// from some other project is a mistake worth catching. fn parse_hash_file(text: &str) -> Result { let mut tokens = text.split_whitespace(); let digest = tokens.next().ok_or_else(|| BundleError::MalformedHash("empty".into()))?; if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(BundleError::MalformedHash(format!("expected 64 hex characters, got {digest:?}"))); } if let Some(name) = tokens.next() && name.trim_start_matches('*') != CONFIG_ENTRY { return Err(BundleError::MalformedHash(format!("it hashes {name:?}, not {CONFIG_ENTRY:?}"))); } if tokens.next().is_some() { return Err(BundleError::MalformedHash("unexpected trailing text".into())); } Ok(digest.to_ascii_lowercase()) } /// Everything that decides whether bytes from a stick are acceptable, with no /// filesystem effects - so the whole policy is testable on in-memory ZIPs. fn verify_bundle(archive: &[u8], policy: &BundlePolicy) -> Result { let mut zip = zip::ZipArchive::new(io::Cursor::new(archive)).map_err(|e| BundleError::NotAnArchive(e.to_string()))?; if zip.len() > MAX_ENTRIES { return Err(BundleError::TooManyEntries(zip.len())); } // First pass: names and declared sizes of every entry, expected or not. // Nothing is decompressed yet, so a bomb is refused before it costs // anything. let mut config_at = None; let mut hash_at = None; // Whether each expected entry is AES-encrypted. Set in the loop below. let mut encrypted_at = std::collections::HashMap::new(); for index in 0..zip.len() { let entry = zip.by_index_raw(index).map_err(|e| BundleError::NotAnArchive(e.to_string()))?; // A name that is not valid UTF-8 cannot be vetted component by // component, so it is refused like any other unsafe name. let Ok(name) = entry.name().map(|n| n.to_string()) else { return Err(BundleError::UnsafeEntry(String::from_utf8_lossy(entry.name_raw()).into_owned())); }; if !entry_name_is_safe(&name) { return Err(BundleError::UnsafeEntry(name)); } // Judged on the metadata, before anything is decrypted. `encrypted()` // is set for ZipCrypto and AES alike; only AES also carries the AE-x // extra field. Encrypted-without-it is ZipCrypto, and the whole // bundle is refused - for *every* entry, expected or not, so no // ZipCrypto decryptor ever runs on our input. let encrypted = entry.encrypted(); let aes = entry.extra_data_fields().any(|f| matches!(f, zip::ExtraField::AeXEncryption(_))); if encrypted && !aes { return Err(BundleError::LegacyEncryption(name)); } encrypted_at.insert(index, encrypted); if entry.size() > MAX_ENTRY_BYTES { return Err(BundleError::EntryTooLarge { size: entry.size(), name }); } for (wanted, slot) in [(CONFIG_ENTRY, &mut config_at), (HASH_ENTRY, &mut hash_at)] { if name == wanted && slot.replace(index).is_some() { return Err(BundleError::DuplicateEntry(wanted)); } } } // Stage: decide encryption state. Both expected entries must agree, and // the policy must allow it, before any key is used. let config_index = config_at.ok_or(BundleError::MissingEntry(CONFIG_ENTRY))?; let hash_index = hash_at.ok_or(BundleError::MissingEntry(HASH_ENTRY))?; let encrypted = encrypted_at[&config_index]; if encrypted != encrypted_at[&hash_index] { return Err(BundleError::MixedEncryption); } let key = if encrypted { Some(policy.key.as_ref().ok_or(BundleError::NoKey)?.0.as_slice()) } else if policy.accept_unencrypted { None } else { return Err(BundleError::UnencryptedRefused); }; // Stage: read, bounded on the *output*. For an encrypted entry that is // the decrypted plaintext, so a small ciphertext that inflates is capped // exactly as a plaintext bomb is. The AES HMAC is only checked when the // stream reaches its end, which `read_bounded` does for anything within // the cap - an entry over the cap is rejected before that matters. let mut read = |index: usize, name: &'static str| -> Result, BundleError> { let entry = match key { Some(key) => zip.by_index_decrypt(index, key), None => zip.by_index(index), } .map_err(|e| match e { zip::result::ZipError::InvalidPassword => BundleError::WrongKey, other => BundleError::Unreadable { name, detail: other.to_string() }, })?; if entry.is_dir() || entry.is_symlink() { return Err(BundleError::NotARegularFile(name)); } read_bounded(entry, MAX_ENTRY_BYTES) .map_err(|e| { if key.is_some() { BundleError::EncryptedIntegrity(name) } else { BundleError::Unreadable { name, detail: e.to_string() } } })? .ok_or_else(|| BundleError::EntryTooLarge { name: name.into(), size: MAX_ENTRY_BYTES + 1 }) }; let config = read(config_index, CONFIG_ENTRY)?; let hash_text = read(hash_index, HASH_ENTRY)?; // The hash is checked before the bytes are parsed or trusted for // anything: the TOML parser should not be the first thing to look at // possibly-corrupt input. (Corruption only - see the module docs.) let claimed = parse_hash_file(&String::from_utf8_lossy(&hash_text))?; let actual = sha256_hex(&config); if claimed != actual { return Err(BundleError::HashMismatch { claimed, actual }); } check_authenticity(&config)?; // Validated as the box will actually run it: with no environment. On a // box the file has to be complete by itself, so a config that parses but // lacks `gateway_id` is refused now rather than installed and then // failing to start on the next boot - which would be a bricked box // caused by exactly the stick we were being careful about. let text = std::str::from_utf8(&config).map_err(|e| BundleError::InvalidConfig(e.to_string()))?; let parsed = ProxyFile::parse(text).map_err(|e| BundleError::InvalidConfig(e.to_string()))?; Config::layered(Some(&parsed), &|_| None).map_err(|e| BundleError::InvalidConfig(e.to_string()))?; Ok(Verified { bytes: config, sha256: actual, encrypted }) } /// **NOT IMPLEMENTED - the place a signature check goes.** /// /// By this stage the bundle has been opened (decrypted if need be) and the /// config bytes have matched their hash: the checks that catch damage and /// casual edits. What is missing is the check that the bytes were made by /// Continuum Cloud: an Ed25519 signature over `config` (or over its hash), /// shipped as a third entry, verified against a *public* key in the box /// image. It belongs here - after integrity, before the config is parsed or /// trusted - and would add one `BundleError` variant and one policy field. /// This function intentionally verifies nothing; it exists so that the lack /// of authenticity is a named, searchable gap rather than an implicit one. fn check_authenticity(_config: &[u8]) -> Result<(), BundleError> { Ok(()) } /// Replaces `dest` with `bytes` so that no observer, and no crash, ever sees /// anything but the old contents or the new ones. pub fn install_atomically(dest: &Path, bytes: &[u8]) -> io::Result<()> { install_atomically_with(dest, bytes, |file, bytes| file.write_all(bytes)) } /// `install_atomically` with the write step injectable, so a test can make it /// fail halfway the way a pulled stick or a full disk would - the situation /// the atomicity exists for, and not one a happy-path test ever reaches. fn install_atomically_with( dest: &Path, bytes: &[u8], write: impl FnOnce(&mut fs::File, &[u8]) -> io::Result<()>, ) -> io::Result<()> { let dir = match dest.parent() { Some(p) if !p.as_os_str().is_empty() => p, _ => Path::new("."), }; let name = dest .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "destination has no file name"))?; fs::create_dir_all(dir)?; let mut temp_name = std::ffi::OsString::from("."); temp_name.push(name); temp_name.push(format!(".tmp-{}", std::process::id())); let temp = dir.join(temp_name); // A leftover from a previous crash with a recycled pid would make // `create_new` fail forever; it is ours by name, so clear it. let _ = fs::remove_file(&temp); let result = (|| { let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); // The config holds the gateway token (and maybe a wifi password), so // it is owner-only from the first byte rather than chmod'd after. #[cfg(unix)] std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); let mut file = options.open(&temp)?; write(&mut file, bytes)?; // Data to disk before the rename makes it visible: without this a // power cut can leave a renamed-but-empty file on some filesystems. file.sync_all()?; drop(file); fs::rename(&temp, dest) })(); if result.is_err() { let _ = fs::remove_file(&temp); } else if let Ok(dir_handle) = fs::File::open(dir) { // Best effort: persists the rename itself. Failure here does not // undo it, so it is not reported as an install failure. let _ = dir_handle.sync_all(); } result } /// Verifies one bundle and installs it if it differs from `installed`. /// Never panics and never returns an error: every outcome, including every /// refusal, is a value the caller reports, and none of them touch the /// installed config except `Installed`. pub fn import_bundle(bundle: &Path, installed: &Path, policy: &BundlePolicy) -> Outcome { let reject = |reason: String| Outcome::Rejected { bundle: bundle.to_path_buf(), reason }; let archive = match read_bundle_file(bundle) { Ok(bytes) => bytes, Err(err) => return reject(format!("could not read the bundle: {err}")), }; let verified = match verify_bundle(&archive, policy) { Ok(v) => v, Err(err) => return reject(err.to_string()), }; // Compared against the bytes actually on disk, not a remembered hash: // if someone did edit the installed file, the next stick restores it, // which is the "changed only by a new pendrive" rule enforced and not // just asserted. An unreadable or missing installed file counts as // different. if fs::read(installed).is_ok_and(|current| sha256_hex(¤t) == verified.sha256) { return Outcome::Unchanged { bundle: bundle.to_path_buf() }; } match install_atomically(installed, &verified.bytes) { Ok(()) => Outcome::Installed { bundle: bundle.to_path_buf(), sha256: verified.sha256, encrypted: verified.encrypted }, Err(err) => reject(format!("could not install into {installed:?}: {err}")), } } fn read_bundle_file(path: &Path) -> io::Result> { // `symlink_metadata`, not `metadata`: do not follow a link off the stick. let meta = fs::symlink_metadata(path)?; if !meta.is_file() { return Err(io::Error::other("not a regular file")); } read_bounded(fs::File::open(path)?, MAX_BUNDLE_BYTES)? .ok_or_else(|| io::Error::other(format!("larger than {MAX_BUNDLE_BYTES} bytes"))) } /// Bundles under `roots`, looking at `root`, `root/*` and `root/*/*` - the /// shapes of `/mnt/stick`, `/media/LABEL` and `/media//LABEL`, which /// between them are where udisks and a hand `mount` put a stick. Sorted, so /// two sticks are always tried in the same order. fn find_bundles(roots: &[PathBuf]) -> Vec { fn subdirs(dir: &Path) -> Vec { let Ok(entries) = fs::read_dir(dir) else { return Vec::new() }; // `file_type` does not follow links, so a symlinked directory is not // descended into. entries .flatten() .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) .map(|e| e.path()) .collect() } let mut found = Vec::new(); for root in roots { let mut dirs = vec![root.clone()]; let level1 = subdirs(root); dirs.extend(level1.iter().flat_map(|d| subdirs(d))); dirs.extend(level1); for dir in dirs { let candidate = dir.join(BUNDLE_NAME); if fs::symlink_metadata(&candidate).is_ok() { found.push(candidate); } } } found.sort(); found.dedup(); found } /// One pass over every configured medium. Returns what happened to each /// bundle; stops after the first install, since a second stick would /// otherwise immediately overwrite the first with whichever sorts last. /// /// This is the gate for PC mode: anything but a Proxy Box returns /// `Disabled` without opening a directory. pub fn import_from_pendrives(bootstrap: &Bootstrap, policy: &BundlePolicy) -> Vec { if bootstrap.mode != Mode::ProxyBox || bootstrap.pendrive_dirs.is_empty() { return vec![Outcome::Disabled]; } let bundles = find_bundles(&bootstrap.pendrive_dirs); if bundles.is_empty() { return vec![Outcome::NoBundle]; } let mut outcomes = Vec::new(); for bundle in bundles { let outcome = import_bundle(&bundle, &bootstrap.config_path, policy); let installed = matches!(outcome, Outcome::Installed { .. }); outcomes.push(outcome); if installed { break; } } outcomes } pub fn log_outcome(outcome: &Outcome) { match outcome { Outcome::Disabled | Outcome::NoBundle => {} Outcome::Unchanged { bundle } => { tracing::info!(bundle = %bundle.display(), "config bundle matches the installed config; nothing to do"); } Outcome::Installed { bundle, sha256, encrypted } => { tracing::warn!(bundle = %bundle.display(), %sha256, "installed a new config from the pendrive; a restart is needed to use it"); // Visible on purpose: an unencrypted bundle is accepted, but // it is the weaker state (token readable by anyone holding the // stick, and no tamper discouragement at all). if !encrypted { tracing::warn!(bundle = %bundle.display(), "the installed bundle was NOT encrypted"); } } // `error`, not `warn`: someone inserted a stick expecting something // to happen, and "nothing happened" must not be silent. Outcome::Rejected { bundle, reason } => { tracing::error!( bundle = %bundle.display(), %reason, "REJECTED the config bundle; the box keeps running on its existing config" ); } } } /// Polls for a stick while the proxy runs. Returns only after installing a /// new config, because settings are read once at startup: the caller is /// expected to let the process exit so its supervisor (systemd /// `Restart=always` on a box) starts it on the new config. Never returns in /// PC mode. /// /// Polling, not inotify/udev: a mount appearing under `/media` is not a /// reliable inotify event across desktop and headless setups, and a 5s scan /// of a handful of directories is cheap. A rejected stick is reported once /// per distinct reason, not every five seconds for as long as it stays in. pub async fn watch(bootstrap: Bootstrap, policy: BundlePolicy) { if bootstrap.mode != Mode::ProxyBox { std::future::pending::<()>().await; } let mut reported: HashSet<(PathBuf, String)> = HashSet::new(); loop { tokio::time::sleep(Duration::from_secs(5)).await; let (scan, policy) = (bootstrap.clone(), policy.clone()); let Ok(outcomes) = tokio::task::spawn_blocking(move || import_from_pendrives(&scan, &policy)).await else { continue; }; for outcome in &outcomes { if let Outcome::Rejected { bundle, reason } = outcome && !reported.insert((bundle.clone(), reason.clone())) { continue; } log_outcome(outcome); } if outcomes.iter().any(|o| matches!(o, Outcome::Installed { .. })) { return; } } } #[cfg(test)] mod tests { use super::*; const GOOD: &str = "gateway_id = \"g\"\nfarm_id = \"f\"\ngateway_token = \"t\"\n"; /// A self-deleting scratch directory, as in tests/discovery_to_fleet.rs - /// a dependency for a dozen lines would be the wrong trade here. struct Scratch(PathBuf); impl Scratch { fn new() -> Self { use std::sync::atomic::{AtomicUsize, Ordering}; static NEXT: AtomicUsize = AtomicUsize::new(0); let path = std::env::temp_dir() .join(format!("continuum-import-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); fs::create_dir_all(&path).unwrap(); Self(path) } fn path(&self, rel: &str) -> PathBuf { self.0.join(rel) } } impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn zip_of(entries: &[(&str, &[u8])], method: zip::CompressionMethod) -> Vec { let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new())); let opts = zip::write::SimpleFileOptions::default().compression_method(method); for (name, body) in entries { w.start_file(*name, opts).unwrap(); w.write_all(body).unwrap(); } w.finish().unwrap().into_inner() } fn stored(entries: &[(&str, &[u8])]) -> Vec { zip_of(entries, zip::CompressionMethod::Stored) } fn good_bundle(config: &str) -> Vec { let hash = format!("{} config.toml\n", sha256_hex(config.as_bytes())); stored(&[(CONFIG_ENTRY, config.as_bytes()), (HASH_ENTRY, hash.as_bytes())]) } fn rejection(archive: &[u8]) -> String { verify_bundle(archive, &BundlePolicy::unencrypted_only()).err().expect("bundle should be rejected").to_string() } #[test] fn a_bundle_whose_hash_matches_is_accepted() { let verified = verify_bundle(&good_bundle(GOOD), &BundlePolicy::unencrypted_only()).unwrap(); assert_eq!(verified.bytes, GOOD.as_bytes()); assert_eq!(verified.sha256, sha256_hex(GOOD.as_bytes())); } #[test] fn a_bare_hex_digest_with_no_filename_is_also_accepted() { let hash = sha256_hex(GOOD.as_bytes()); let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())]); assert!(verify_bundle(&archive, &BundlePolicy::unencrypted_only()).is_ok()); } #[test] fn a_config_edited_after_its_hash_was_made_is_rejected_as_a_mismatch() { let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes())); let edited = GOOD.replace("\"g\"", "\"other\""); let archive = stored(&[(CONFIG_ENTRY, edited.as_bytes()), (HASH_ENTRY, hash.as_bytes())]); assert!(rejection(&archive).contains("hash mismatch")); } #[test] fn a_hash_file_that_names_some_other_file_is_rejected() { let hash = format!("{} other.toml", sha256_hex(GOOD.as_bytes())); let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())]); assert!(rejection(&archive).contains("other.toml")); } #[test] fn a_hash_that_is_not_sixty_four_hex_characters_is_rejected() { let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, b"deadbeef")]); assert!(rejection(&archive).contains("malformed")); } #[test] fn a_bundle_missing_its_hash_is_rejected_rather_than_trusted() { let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes())]); assert!(rejection(&archive).contains("missing entry")); } #[test] fn a_correctly_hashed_config_that_is_not_valid_toml_is_rejected() { assert!(rejection(&good_bundle("this is = = not toml")).contains("not valid")); } #[test] fn a_correctly_hashed_config_missing_required_settings_is_rejected_so_it_cannot_brick_the_next_boot() { assert!(rejection(&good_bundle("uplink_url = \"wss://x\"\n")).contains("gateway_id")); } #[test] fn a_correctly_hashed_config_with_a_misspelled_key_is_rejected() { let config = format!("{GOOD}uplink_ulr = \"x\"\n"); assert!(rejection(&good_bundle(&config)).contains("uplink_ulr")); } #[test] fn a_file_that_is_not_a_zip_is_rejected() { assert!(rejection(b"definitely not a zip").contains("not a readable ZIP")); } #[test] fn entry_names_that_could_escape_the_target_directory_are_all_unsafe() { for name in ["/etc/passwd", "../evil", "a/../../evil", "a/..", "..\\evil", "\\abs", "C:\\evil", "C:evil", ""] { assert!(!entry_name_is_safe(name), "{name:?} should be unsafe"); } for name in ["config.toml", "dir/config.toml", "..config", "a..b/c", "__MACOSX/._x"] { assert!(entry_name_is_safe(name), "{name:?} should be safe"); } } #[test] fn a_zip_slip_entry_rejects_the_whole_bundle_even_when_the_rest_is_valid() { let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes())); let archive = stored(&[ (CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes()), ("../../../../tmp/evil.toml", b"pwned"), ]); assert!(rejection(&archive).contains("unsafe entry")); } #[test] fn an_absolute_path_entry_rejects_the_bundle() { let archive = stored(&[("/etc/cron.d/evil", b"x")]); assert!(rejection(&archive).contains("unsafe entry")); } #[test] fn importing_a_zip_slip_bundle_writes_nothing_outside_the_target_and_keeps_the_old_config() { let scratch = Scratch::new(); let installed = scratch.path("box/config.toml"); fs::create_dir_all(installed.parent().unwrap()).unwrap(); fs::write(&installed, GOOD).unwrap(); let hash = format!("{} config.toml", sha256_hex(b"new")); let bundle = scratch.path("stick.zip"); fs::write(&bundle, stored(&[(CONFIG_ENTRY, b"new"), (HASH_ENTRY, hash.as_bytes()), ("../evil.toml", b"pwned")])).unwrap(); assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. })); assert!(!scratch.path("evil.toml").exists(), "a file escaped the target directory"); assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD); } #[test] fn a_bundle_with_too_many_entries_is_rejected() { let names: Vec = (0..=MAX_ENTRIES).map(|i| format!("junk{i}")).collect(); let entries: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), &b"x"[..])).collect(); assert!(rejection(&stored(&entries)).contains("entries")); } #[test] fn a_highly_compressed_entry_that_would_inflate_past_the_cap_is_rejected_before_it_is_inflated() { let bomb = vec![0u8; (MAX_ENTRY_BYTES as usize) * 40]; let archive = zip_of(&[(CONFIG_ENTRY, &bomb), (HASH_ENTRY, b"x")], zip::CompressionMethod::Deflated); assert!(archive.len() < 64 * 1024, "the fixture should be small on disk: {}", archive.len()); assert!(rejection(&archive).contains("at most")); } #[test] fn the_bounded_reader_stops_an_endless_source_instead_of_filling_memory() { // The cap that survives a header which lies about the size. assert_eq!(read_bounded(io::repeat(0), 1000).unwrap(), None); assert_eq!(read_bounded(&b"abc"[..], 1000).unwrap(), Some(b"abc".to_vec())); } #[test] fn an_oversized_file_on_the_stick_is_refused_without_being_read_whole() { let scratch = Scratch::new(); let bundle = scratch.path("big.zip"); fs::write(&bundle, vec![0u8; MAX_BUNDLE_BYTES as usize + 1]).unwrap(); assert!(matches!(import_bundle(&bundle, &scratch.path("config.toml"), &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. })); } #[test] fn a_new_config_is_installed_when_none_exists_yet() { let scratch = Scratch::new(); let bundle = scratch.path("stick.zip"); fs::write(&bundle, good_bundle(GOOD)).unwrap(); let installed = scratch.path("var/lib/continuum/config.toml"); assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Installed { .. })); assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD); } #[cfg(unix)] #[test] fn the_installed_config_is_readable_only_by_its_owner() { use std::os::unix::fs::PermissionsExt; let scratch = Scratch::new(); let installed = scratch.path("config.toml"); install_atomically(&installed, b"secret").unwrap(); assert_eq!(fs::metadata(&installed).unwrap().permissions().mode() & 0o777, 0o600); } #[test] fn a_bundle_identical_to_the_installed_config_changes_nothing() { let scratch = Scratch::new(); let bundle = scratch.path("stick.zip"); fs::write(&bundle, good_bundle(GOOD)).unwrap(); let installed = scratch.path("config.toml"); fs::write(&installed, GOOD).unwrap(); let before = fs::metadata(&installed).unwrap().modified().unwrap(); assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Unchanged { .. })); assert_eq!(fs::metadata(&installed).unwrap().modified().unwrap(), before, "an unchanged config was rewritten"); } #[test] fn a_config_hand_edited_on_the_box_is_restored_by_the_next_stick() { let scratch = Scratch::new(); let bundle = scratch.path("stick.zip"); fs::write(&bundle, good_bundle(GOOD)).unwrap(); let installed = scratch.path("config.toml"); fs::write(&installed, format!("{GOOD}uplink_url = \"wss://tampered\"\n")).unwrap(); assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Installed { .. })); assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD); } #[test] fn a_pendrive_yanked_mid_copy_leaves_the_old_config_intact() { let scratch = Scratch::new(); let installed = scratch.path("config.toml"); fs::write(&installed, "old config").unwrap(); let result = install_atomically_with(&installed, b"the whole new config", |file, bytes| { file.write_all(&bytes[..5])?; Err(io::Error::other("stick removed")) }); assert!(result.is_err()); assert_eq!(fs::read_to_string(&installed).unwrap(), "old config"); } #[test] fn a_failed_install_leaves_no_temp_file_behind() { let scratch = Scratch::new(); let installed = scratch.path("config.toml"); let _ = install_atomically_with(&installed, b"new", |_, _| Err(io::Error::other("disk full"))); let leftovers: Vec<_> = fs::read_dir(&scratch.0).unwrap().flatten().collect(); assert!(leftovers.is_empty(), "left behind: {leftovers:?}"); } #[test] fn a_rejected_bundle_never_touches_the_installed_config() { let scratch = Scratch::new(); let installed = scratch.path("config.toml"); fs::write(&installed, GOOD).unwrap(); let bundle = scratch.path("stick.zip"); fs::write(&bundle, good_bundle("not = = toml")).unwrap(); assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. })); assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD); } fn box_with_stick(scratch: &Scratch, mode: Mode) -> Bootstrap { let stick = scratch.path("media/USB"); fs::create_dir_all(&stick).unwrap(); fs::write(stick.join(BUNDLE_NAME), good_bundle(GOOD)).unwrap(); Bootstrap { mode, config_path: scratch.path("installed.toml"), config_path_is_explicit: true, pendrive_dirs: vec![scratch.path("media")], } } #[test] fn a_proxy_box_finds_a_stick_mounted_one_level_down_and_installs_from_it() { let scratch = Scratch::new(); let bootstrap = box_with_stick(&scratch, Mode::ProxyBox); let outcomes = import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()); assert!(matches!(outcomes.as_slice(), [Outcome::Installed { .. }]), "{outcomes:?}"); assert_eq!(fs::read_to_string(scratch.path("installed.toml")).unwrap(), GOOD); } #[test] fn a_proxy_box_finds_a_stick_at_the_media_user_label_depth() { let scratch = Scratch::new(); let deep = scratch.path("media/pi/LABEL"); fs::create_dir_all(&deep).unwrap(); fs::write(deep.join(BUNDLE_NAME), good_bundle(GOOD)).unwrap(); let bootstrap = Bootstrap { mode: Mode::ProxyBox, config_path: scratch.path("installed.toml"), config_path_is_explicit: true, pendrive_dirs: vec![scratch.path("media")], }; assert!(matches!(import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()).as_slice(), [Outcome::Installed { .. }])); } #[test] fn pc_mode_ignores_a_pendrive_even_when_one_with_a_valid_bundle_is_mounted() { let scratch = Scratch::new(); let mut bootstrap = box_with_stick(&scratch, Mode::Pc); // Force the dirs back in, as if some future code path forgot that // `Bootstrap` already empties them: the mode check must hold alone. bootstrap.pendrive_dirs = vec![scratch.path("media")]; assert_eq!(import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()), vec![Outcome::Disabled]); assert!(!scratch.path("installed.toml").exists(), "PC mode imported from removable media"); } #[test] fn an_empty_pendrive_directory_is_not_an_error() { let scratch = Scratch::new(); fs::create_dir_all(scratch.path("media")).unwrap(); let bootstrap = Bootstrap { mode: Mode::ProxyBox, config_path: scratch.path("installed.toml"), config_path_is_explicit: true, pendrive_dirs: vec![scratch.path("media")], }; assert_eq!(import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()), vec![Outcome::NoBundle]); } #[cfg(unix)] #[test] fn a_bundle_that_is_a_symlink_is_not_followed() { let scratch = Scratch::new(); let real = scratch.path("real.zip"); fs::write(&real, good_bundle(GOOD)).unwrap(); let link = scratch.path("link.zip"); std::os::unix::fs::symlink(&real, &link).unwrap(); assert!(matches!(import_bundle(&link, &scratch.path("c.toml"), &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. })); } #[test] fn a_bad_stick_is_reported_and_the_box_keeps_the_config_it_had() { let scratch = Scratch::new(); let bootstrap = box_with_stick(&scratch, Mode::ProxyBox); fs::write(&bootstrap.config_path, "previous").unwrap(); fs::write(scratch.path("media/USB").join(BUNDLE_NAME), b"garbage").unwrap(); let outcomes = import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()); assert!(matches!(outcomes.as_slice(), [Outcome::Rejected { .. }])); assert_eq!(fs::read_to_string(&bootstrap.config_path).unwrap(), "previous"); } // ---- encrypted bundles ------------------------------------------------- // // Fixtures are written by the `zip` crate's own AES writer // (`with_aes_encryption`), so they are real WinZip-AES archives, not // hand-rolled bytes. The one exception is the ZipCrypto fixture, which // the crate will not write without its `unstable` feature: it is made by // setting the "encrypted" flag on a plain archive's headers, which is // exactly the shape this code must refuse on metadata alone. const KEY: &[u8] = b"correct horse battery staple"; fn aes_zip(entries: &[(&str, &[u8])], key: &[u8]) -> Vec { let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new())); for (name, body) in entries { let opts = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Stored) .with_aes_encryption_bytes(zip::AesMode::Aes256, key); w.start_file(*name, opts).unwrap(); w.write_all(body).unwrap(); } w.finish().unwrap().into_inner() } fn encrypted_bundle(config: &str, key: &[u8]) -> Vec { let hash = format!("{} config.toml\n", sha256_hex(config.as_bytes())); aes_zip(&[(CONFIG_ENTRY, config.as_bytes()), (HASH_ENTRY, hash.as_bytes())], key) } fn keyed(key: &[u8]) -> BundlePolicy { BundlePolicy { key: Some(BundleKey::new(key)), accept_unencrypted: true } } fn rejection_with(archive: &[u8], policy: &BundlePolicy) -> String { verify_bundle(archive, policy).err().expect("bundle should be rejected").to_string() } #[test] fn an_aes_encrypted_bundle_opens_with_the_right_key_and_is_marked_encrypted() { let verified = verify_bundle(&encrypted_bundle(GOOD, KEY), &keyed(KEY)).unwrap(); assert_eq!(verified.bytes, GOOD.as_bytes()); assert!(verified.encrypted); } #[test] fn the_encrypted_fixture_really_does_hide_the_config_text() { let archive = encrypted_bundle(GOOD, KEY); assert!(!archive.windows(b"gateway_token".len()).any(|w| w == b"gateway_token")); } #[test] fn an_encrypted_bundle_on_a_box_with_no_key_says_it_is_not_for_this_box() { let reason = rejection_with(&encrypted_bundle(GOOD, KEY), &BundlePolicy::unencrypted_only()); assert!(reason.contains("no bundle key") && reason.contains("not for this box"), "{reason}"); } #[test] fn an_encrypted_bundle_made_for_a_different_key_says_it_is_not_for_this_box() { let reason = rejection_with(&encrypted_bundle(GOOD, KEY), &keyed(b"some other box's key")); // The 16-bit password check lets 1 wrong key in 65536 through to the // HMAC, which then says so itself; either wording names the key. assert!(reason.contains("not for this box") || reason.contains("key is wrong"), "{reason}"); assert!(!reason.contains("hash mismatch"), "a wrong key must not look like a corrupt stick: {reason}"); } #[test] fn a_flipped_ciphertext_byte_is_caught_by_the_aes_integrity_check_not_trusted() { let mut archive = encrypted_bundle(GOOD, KEY); // First entry's data starts after the local header (30 bytes + name + // extra); skip the 16-byte AES-256 salt and 2-byte password check. let name_len = u16::from_le_bytes([archive[26], archive[27]]) as usize; let extra_len = u16::from_le_bytes([archive[28], archive[29]]) as usize; archive[30 + name_len + extra_len + 18] ^= 0xff; let reason = rejection_with(&archive, &keyed(KEY)); assert!(reason.contains("AES integrity check"), "{reason}"); } #[test] fn legacy_zipcrypto_is_refused_even_when_a_key_is_configured() { // Flag bit 0 = encrypted, with no AE-x extra field: that is ZipCrypto. let mut archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, b"x")]); let mut at = 0; while at + 4 <= archive.len() { match &archive[at..at + 4] { b"PK\x03\x04" => archive[at + 6] |= 1, b"PK\x01\x02" => archive[at + 8] |= 1, _ => {} } at += 1; } let reason = rejection_with(&archive, &keyed(KEY)); assert!(reason.contains("ZipCrypto"), "{reason}"); } #[test] fn a_bundle_with_one_encrypted_entry_and_one_plain_entry_is_rejected() { let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes())); let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new())); let plain = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); w.start_file(CONFIG_ENTRY, plain.with_aes_encryption_bytes(zip::AesMode::Aes256, KEY)).unwrap(); w.write_all(GOOD.as_bytes()).unwrap(); w.start_file(HASH_ENTRY, plain).unwrap(); w.write_all(hash.as_bytes()).unwrap(); let archive = w.finish().unwrap().into_inner(); assert!(rejection_with(&archive, &keyed(KEY)).contains("not encrypted alike")); } #[test] fn an_encrypted_bundle_that_decrypts_to_a_wrong_hash_is_rejected_like_a_plaintext_one() { let hash = format!("{} config.toml", sha256_hex(b"something else")); let archive = aes_zip(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())], KEY); assert!(rejection_with(&archive, &keyed(KEY)).contains("hash mismatch")); } #[test] fn an_encrypted_bundle_that_decrypts_to_invalid_toml_is_rejected() { assert!(rejection_with(&encrypted_bundle("not = = toml", KEY), &keyed(KEY)).contains("not valid")); } #[test] fn an_encrypted_bundle_with_a_zip_slip_entry_is_rejected_before_any_decryption() { let archive = aes_zip(&[("../../evil", b"x"), (CONFIG_ENTRY, GOOD.as_bytes())], KEY); // Rejected with no key at all: the name check is on metadata. assert!(rejection_with(&archive, &BundlePolicy::unencrypted_only()).contains("unsafe entry")); } #[test] fn an_encrypted_bomb_is_rejected_by_its_size_before_it_is_decrypted() { let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new())); let opts = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated) .with_aes_encryption_bytes(zip::AesMode::Aes256, KEY); w.start_file(CONFIG_ENTRY, opts).unwrap(); w.write_all(&vec![0u8; (MAX_ENTRY_BYTES as usize) * 40]).unwrap(); let archive = w.finish().unwrap().into_inner(); assert!(archive.len() < 64 * 1024, "{}", archive.len()); assert!(rejection_with(&archive, &keyed(KEY)).contains("at most")); } #[test] fn a_plaintext_bundle_is_refused_when_the_policy_requires_encryption() { let policy = BundlePolicy { key: Some(BundleKey::new(KEY)), accept_unencrypted: false }; assert!(rejection_with(&good_bundle(GOOD), &policy).contains("requires encrypted")); } #[test] fn a_plaintext_bundle_is_still_accepted_by_default_and_flagged_unencrypted() { let verified = verify_bundle(&good_bundle(GOOD), &keyed(KEY)).unwrap(); assert!(!verified.encrypted); } #[test] fn installing_an_encrypted_bundle_stores_the_decrypted_config_and_reports_it_encrypted() { let scratch = Scratch::new(); let bundle = scratch.path("stick.zip"); fs::write(&bundle, encrypted_bundle(GOOD, KEY)).unwrap(); let installed = scratch.path("config.toml"); let outcome = import_bundle(&bundle, &installed, &keyed(KEY)); assert!(matches!(outcome, Outcome::Installed { encrypted: true, .. }), "{outcome:?}"); assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD); } #[test] fn installing_a_plaintext_bundle_reports_it_unencrypted_so_it_can_be_warned_about() { let scratch = Scratch::new(); let bundle = scratch.path("stick.zip"); fs::write(&bundle, good_bundle(GOOD)).unwrap(); let outcome = import_bundle(&bundle, &scratch.path("config.toml"), &keyed(KEY)); assert!(matches!(outcome, Outcome::Installed { encrypted: false, .. }), "{outcome:?}"); } #[test] fn a_bundle_for_another_box_leaves_this_boxs_existing_config_untouched() { let scratch = Scratch::new(); let bundle = scratch.path("stick.zip"); fs::write(&bundle, encrypted_bundle(GOOD, b"another box")).unwrap(); let installed = scratch.path("config.toml"); fs::write(&installed, "previous").unwrap(); assert!(matches!(import_bundle(&bundle, &installed, &keyed(KEY)), Outcome::Rejected { .. })); assert_eq!(fs::read_to_string(&installed).unwrap(), "previous"); } #[test] fn a_bundle_key_never_appears_in_debug_output() { let shown = format!("{:?}", keyed(KEY)); assert!(!shown.contains("horse") && shown.contains("redacted"), "{shown}"); } }