diff --git a/Cargo.toml b/Cargo.toml index aee74ed..290946d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,7 @@ rumqttc = { version = "0.25.1", default-features = false, features = ["use-nativ # of explicit AUTH TLS. The printers do not care. # MIT OR Apache-2.0 - checked against docs/LICENSES.md before adding. suppaftp = { version = "12.1.1", features = ["native-tls", "deprecated"] } -zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2"] } +zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2", "aes-crypto"] } # Reads the subject CN out of the certificate a printer presents on 8883, # which is where its serial number is (see src/discovery.rs). Already in the # tree - native-tls is openssl on Linux - so naming it directly adds nothing diff --git a/src/config_import.rs b/src/config_import.rs index feddea1..b4de34d 100644 --- a/src/config_import.rs +++ b/src/config_import.rs @@ -30,6 +30,63 @@ //! 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 @@ -43,7 +100,10 @@ //! 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.** At most [`MAX_ENTRIES`] entries and [`MAX_ENTRY_BYTES`] +//! - **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 @@ -89,6 +149,76 @@ pub const MAX_ENTRIES: usize = 16; /// 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. @@ -98,7 +228,8 @@ pub enum Outcome { /// 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. - Installed { bundle: PathBuf, sha256: String }, + /// `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 }, } @@ -113,6 +244,18 @@ enum BundleError { 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")] @@ -133,6 +276,7 @@ enum BundleError { struct Verified { bytes: Vec, sha256: String, + encrypted: bool, } /// Rejects an entry name that could escape a directory it were joined onto. @@ -194,7 +338,7 @@ fn parse_hash_file(text: &str) -> Result { /// 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]) -> Result { +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 { @@ -206,6 +350,8 @@ fn verify_bundle(archive: &[u8]) -> Result { // 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 @@ -216,6 +362,17 @@ fn verify_bundle(archive: &[u8]) -> Result { 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 }); } @@ -226,18 +383,47 @@ fn verify_bundle(archive: &[u8]) -> Result { } } - let mut read = |index: Option, name: &'static str| -> Result, BundleError> { - let index = index.ok_or(BundleError::MissingEntry(name))?; - let entry = zip.by_index(index).map_err(|e| BundleError::Unreadable { name, detail: e.to_string() })?; + // 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| BundleError::Unreadable { name, detail: e.to_string() })? + .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_at, CONFIG_ENTRY)?; - let hash_text = read(hash_at, HASH_ENTRY)?; + 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 @@ -248,6 +434,8 @@ fn verify_bundle(archive: &[u8]) -> Result { 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 @@ -257,7 +445,22 @@ fn verify_bundle(archive: &[u8]) -> Result { 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 }) + 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 @@ -321,14 +524,14 @@ fn install_atomically_with( /// 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) -> Outcome { +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) { + let verified = match verify_bundle(&archive, policy) { Ok(v) => v, Err(err) => return reject(err.to_string()), }; @@ -343,7 +546,7 @@ pub fn import_bundle(bundle: &Path, installed: &Path) -> Outcome { } match install_atomically(installed, &verified.bytes) { - Ok(()) => Outcome::Installed { bundle: bundle.to_path_buf(), sha256: verified.sha256 }, + Ok(()) => Outcome::Installed { bundle: bundle.to_path_buf(), sha256: verified.sha256, encrypted: verified.encrypted }, Err(err) => reject(format!("could not install into {installed:?}: {err}")), } } @@ -398,7 +601,7 @@ fn find_bundles(roots: &[PathBuf]) -> Vec { /// /// 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) -> Vec { +pub fn import_from_pendrives(bootstrap: &Bootstrap, policy: &BundlePolicy) -> Vec { if bootstrap.mode != Mode::ProxyBox || bootstrap.pendrive_dirs.is_empty() { return vec![Outcome::Disabled]; } @@ -408,7 +611,7 @@ pub fn import_from_pendrives(bootstrap: &Bootstrap) -> Vec { } let mut outcomes = Vec::new(); for bundle in bundles { - let outcome = import_bundle(&bundle, &bootstrap.config_path); + let outcome = import_bundle(&bundle, &bootstrap.config_path, policy); let installed = matches!(outcome, Outcome::Installed { .. }); outcomes.push(outcome); if installed { @@ -424,8 +627,14 @@ pub fn log_outcome(outcome: &Outcome) { Outcome::Unchanged { bundle } => { tracing::info!(bundle = %bundle.display(), "config bundle matches the installed config; nothing to do"); } - Outcome::Installed { bundle, sha256 } => { + 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. @@ -449,15 +658,15 @@ pub fn log_outcome(outcome: &Outcome) { /// 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) { +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 = bootstrap.clone(); - let Ok(outcomes) = tokio::task::spawn_blocking(move || import_from_pendrives(&scan)).await else { + 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 { @@ -524,12 +733,12 @@ mod tests { } fn rejection(archive: &[u8]) -> String { - verify_bundle(archive).err().expect("bundle should be rejected").to_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)).unwrap(); + 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())); } @@ -538,7 +747,7 @@ mod tests { 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).is_ok()); + assert!(verify_bundle(&archive, &BundlePolicy::unencrypted_only()).is_ok()); } #[test] @@ -627,7 +836,7 @@ mod tests { 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), Outcome::Rejected { .. })); + 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); } @@ -659,7 +868,7 @@ mod tests { 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")), Outcome::Rejected { .. })); + assert!(matches!(import_bundle(&bundle, &scratch.path("config.toml"), &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. })); } #[test] @@ -669,7 +878,7 @@ mod tests { fs::write(&bundle, good_bundle(GOOD)).unwrap(); let installed = scratch.path("var/lib/continuum/config.toml"); - assert!(matches!(import_bundle(&bundle, &installed), Outcome::Installed { .. })); + assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Installed { .. })); assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD); } @@ -692,7 +901,7 @@ mod tests { fs::write(&installed, GOOD).unwrap(); let before = fs::metadata(&installed).unwrap().modified().unwrap(); - assert!(matches!(import_bundle(&bundle, &installed), Outcome::Unchanged { .. })); + 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"); } @@ -704,7 +913,7 @@ mod tests { let installed = scratch.path("config.toml"); fs::write(&installed, format!("{GOOD}uplink_url = \"wss://tampered\"\n")).unwrap(); - assert!(matches!(import_bundle(&bundle, &installed), Outcome::Installed { .. })); + assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Installed { .. })); assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD); } @@ -741,7 +950,7 @@ mod tests { let bundle = scratch.path("stick.zip"); fs::write(&bundle, good_bundle("not = = toml")).unwrap(); - assert!(matches!(import_bundle(&bundle, &installed), Outcome::Rejected { .. })); + assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. })); assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD); } @@ -761,7 +970,7 @@ mod tests { 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); + 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); } @@ -778,7 +987,7 @@ mod tests { config_path_is_explicit: true, pendrive_dirs: vec![scratch.path("media")], }; - assert!(matches!(import_from_pendrives(&bootstrap).as_slice(), [Outcome::Installed { .. }])); + assert!(matches!(import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()).as_slice(), [Outcome::Installed { .. }])); } #[test] @@ -789,7 +998,7 @@ mod tests { // `Bootstrap` already empties them: the mode check must hold alone. bootstrap.pendrive_dirs = vec![scratch.path("media")]; - assert_eq!(import_from_pendrives(&bootstrap), vec![Outcome::Disabled]); + assert_eq!(import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()), vec![Outcome::Disabled]); assert!(!scratch.path("installed.toml").exists(), "PC mode imported from removable media"); } @@ -803,7 +1012,7 @@ mod tests { config_path_is_explicit: true, pendrive_dirs: vec![scratch.path("media")], }; - assert_eq!(import_from_pendrives(&bootstrap), vec![Outcome::NoBundle]); + assert_eq!(import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()), vec![Outcome::NoBundle]); } #[cfg(unix)] @@ -814,7 +1023,7 @@ mod tests { 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")), Outcome::Rejected { .. })); + assert!(matches!(import_bundle(&link, &scratch.path("c.toml"), &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. })); } #[test] @@ -824,8 +1033,199 @@ mod tests { 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); + 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}"); + } } diff --git a/src/main.rs b/src/main.rs index 04229ae..b275783 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,13 @@ async fn main() -> anyhow::Result<()> { // the installed config by the time it is read. A rejected stick is // logged inside and does not stop startup - the box runs on what it has. let bootstrap = Bootstrap::discover()?; - for outcome in config_import::import_from_pendrives(&bootstrap) { + let policy = config_import::BundlePolicy { + key: config_import::load_bundle_key()?, + // Plaintext bundles are accepted (and logged at warn on install); + // see the config_import module docs for what that costs. + accept_unencrypted: true, + }; + for outcome in config_import::import_from_pendrives(&bootstrap, &policy) { config_import::log_outcome(&outcome); } let file = bootstrap.load_config_file()?; @@ -112,7 +118,7 @@ async fn main() -> anyhow::Result<()> { // on a box) to start it again on the new config. Not exercised end // to end - there is no supervisor or real stick in the dev // environment. - _ = config_import::watch(bootstrap.clone()) => { + _ = config_import::watch(bootstrap.clone(), policy.clone()) => { info!("exiting so the supervisor restarts the proxy on the newly installed config"); } }