//! 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`. //! //! # The layers, and what each one is for //! //! A bundle passes four independent checks. They answer different questions, //! and conflating them is how a config scheme ends up trusted for something //! it never established. //! //! | Layer | Catches | Does NOT stop | //! |---|---|---| //! | SHA-256 (`config.toml.sha256`) | corruption, a hand edit | forgery - the hash rides next to the file | //! | AES-256 encryption (WinZip AE-x) | disclosure of the token/PSK on a lost stick | forgery by anyone holding the key | //! | **Ed25519 signature** (`config.toml.sig`) | **forgery and tampering**, by anyone without the cloud's private key | a compromised cloud signing key | //! | box id + `config_version` (inside the signature) | a bundle meant for another box; replay of an older config | a signing-key compromise | //! //! ## Hash: corruption, not 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. SHA-256 rather than CRC/MD5: not for strength but because //! it is what `sha256sum` already produces, and `openssl` is a direct //! dependency (no `sha2` crate). //! //! ## Encryption: confidentiality, not authenticity //! //! The bundle is produced by Continuum Cloud and carried on a stick, so it is //! a **secret at rest** - it holds the gateway token and possibly a wifi PSK. //! Encryption (WinZip AES-256) means a lost or posted stick discloses //! nothing. That is its job. //! //! It is not a tamper defence. WinZip-AES carries an HMAC, so a change by //! someone *without the key* is detected - but the key has to be on the box, //! so whoever extracts it from a box's image can build bundles that decrypt //! and verify. Per-box keys limit that to one box; they do not prevent it. //! Nothing here calls encryption tamper-proof. //! //! **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 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 never runs on our input. //! //! **Plaintext is refused on a Proxy Box** (`accept_unencrypted = false`, //! [`BundlePolicy::proxy_box`]). While it was accepted an attacker did not //! need to break the encryption - they just handed over a plaintext bundle - //! so encryption discouraged nothing. The cost, accepted: sticks made before //! the configurator encrypts, and hand-made development sticks, stop working //! on a Box. The PC path is unaffected; it takes a plain file and never //! reaches this module. //! //! ## Signature: the thing that resists tampering //! //! Continuum Cloud signs; the box verifies with only the **public** key in //! its image. A public key can check a signature but cannot make one, so //! reading a box's image - or every box's - yields nothing that forges a //! bundle. This is what the encryption could not give. //! //! **What is signed** is a message built by [`signed_message`]: a fixed //! domain-separation tag, the box id, the `config_version`, and the **raw //! `config.toml` bytes**. The config bytes (not the ZIP, not the ciphertext), //! so the signature is independent of ZIP framing, compression, entry //! order, and of whether the bundle is encrypted; re-zipping or re-encrypting //! a bundle for another transport cannot invalidate it. The box id and //! version are inside the signed message, not beside it, so they cannot be //! swapped without breaking the signature; they travel in //! `config.toml.sig` (a small TOML) rather than as fields of `config.toml` //! because the config schema describes proxy *settings* and is strict about //! its keys. The domain tag stops a signature made for some other Continuum //! purpose being replayed as a config signature; the box id is //! length-prefixed so `("ab","c...")` and `("a","bc...")` cannot collide. //! //! **Mandatory once a public key is present.** If the box has any signing //! key, an unsigned bundle is rejected as its own reason, as is a bad //! signature. With *no* key the check cannot run and is skipped - an //! unprovisioned box - and the install is logged at `warn` as unsigned. //! Several keys may be listed so the cloud's signing key can be rotated with //! a transition window; any one verifying is enough. //! //! **Box binding.** The box checks the signed box id against its own //! identity ([`load_provisioning`]). A bundle for box A is refused on box B //! with "this bundle is for box A, this is box B" even if the encryption key //! were somehow shared. A box with no identity cannot confirm a bundle is for //! it and refuses signed bundles. //! //! **Replay.** The signed `config_version` is a monotonic number the cloud //! increments per box. The box records the version it installed in a sidecar //! (`.version`) and refuses a bundle with a *lower* one - otherwise a //! stick from last year, perfectly validly signed and encrypted for this box, //! would roll it back (to a revoked token, say). An *equal* version is //! accepted on purpose: the version file is written before the config, so a //! power cut between the two leaves "new version, old config", and //! re-inserting the same stick must be able to finish the job; the other //! order would leave a replay window instead. The sidecar lives on the box's //! own disk, so someone who can rewrite it can already rewrite the config; //! that is outside what a stick can do. //! //! ### Which error wins //! //! A signature over the *plaintext* can only be checked after decryption. So //! a box holding the wrong key cannot tell "made for another box" from //! "forged": both fail at the same stage. The wrong-key reason wins, and //! deliberately: it is the conservative one (neither is installed), it is //! almost always the true one (someone inserted the wrong stick), and //! claiming "forged" about a bundle we could not even open would be an //! accusation we cannot support. Past decryption the order is: hash, //! signature, box id, version, then parse. The box id and version are only //! believed - and only reported - once the signature has verified, so "this //! is for box A" is always a statement about an authentic bundle, and an //! attacker cannot probe the box's identity with unsigned junk. //! //! ## Trust assumptions that remain //! //! Physical access is trusted only as far as it can obtain a *genuine* //! bundle: with the signature in place, a person with the box and a stick //! can no longer make it run a config the cloud did not issue. A compromise //! of the cloud's private signing key breaks everything signed under it; that //! key belongs in an HSM/KMS on the cloud side, which is not this crate's //! concern. //! //! ## Cloud side, precisely //! //! To produce a bundle for box `ID` at version `N`, Continuum Cloud must: //! 1. render `config.toml` (complete by itself; see `config.rs`); //! 2. write `config.toml.sha256` (` config.toml`); //! 3. sign `signed_message(ID, N, config_bytes)` with the fleet **Ed25519 //! private key** (pure Ed25519, no prehash) and write //! `config.toml.sig` as TOML: `box_id`, `config_version`, `signature` //! (128 lowercase hex characters); //! 4. ZIP all three, each entry encrypted with **WinZip AES-256 (AE-2)** //! under box `ID`'s own key - never ZipCrypto, never a mix; //! 5. keep `N` strictly increasing per box. //! //! The key is not designed here: [`load_provisioning`] is the single seam //! where a box's id, decryption keys and signing public keys come from. //! //! # 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 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 three expected entries are ever decompressed, so output is //! bounded by three times 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 openssl::pkey::{Id, PKey}; use openssl::sign::Verifier; 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"; const SIG_ENTRY: &str = "config.toml.sig"; /// 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()") } } /// An Ed25519 *public* key the cloud's signatures are checked against. Public, /// so printing it is harmless - it is the one thing in this module that is. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SigningKey([u8; 32]); impl SigningKey { pub fn from_raw(bytes: [u8; 32]) -> Self { Self(bytes) } fn parse_hex(text: &str) -> anyhow::Result { let text = text.trim(); anyhow::ensure!(text.len() == 64 && text.bytes().all(|b| b.is_ascii_hexdigit()), "a signing key must be 64 hex characters (a raw Ed25519 public key)"); let mut raw = [0u8; 32]; for (i, byte) in raw.iter_mut().enumerate() { *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)?; } // Constructed once here so a key OpenSSL rejects fails at startup, // not on the first stick. PKey::public_key_from_raw_bytes(&raw, Id::ED25519).map_err(|e| anyhow::anyhow!("not a valid Ed25519 public key: {e}"))?; Ok(Self(raw)) } fn verifies(&self, message: &[u8], signature: &[u8]) -> bool { let Ok(key) = PKey::public_key_from_raw_bytes(&self.0, Id::ED25519) else { return false }; let Ok(mut verifier) = Verifier::new_without_digest(&key) else { return false }; verifier.verify_oneshot(signature, message).unwrap_or(false) } } /// The bytes the cloud signs and the box verifies - see the module docs for /// why these and not the ZIP. Public so the cloud-side tooling (and tests) /// build exactly the same message. pub fn signed_message(box_id: &str, config_version: u64, config: &[u8]) -> Vec { let mut message = Vec::with_capacity(64 + box_id.len() + config.len()); message.extend_from_slice(b"continuum-proxy-config-v1\0"); message.extend_from_slice(&(box_id.len() as u32).to_be_bytes()); message.extend_from_slice(box_id.as_bytes()); message.extend_from_slice(&config_version.to_be_bytes()); message.extend_from_slice(config); message } /// Placeholder locations on a box. **Not decisions** - see `load_provisioning`. const BOX_ID_PATH: &str = "/etc/continuum/box.id"; const BUNDLE_KEYS_PATH: &str = "/etc/continuum/bundle.keys"; const SIGNING_KEYS_PATH: &str = "/etc/continuum/signing.pub"; /// THE SEAM WHERE PROVISIONING LANDS: the box's identity, the keys that open /// its bundles, and the public keys that authenticate them. Everything else /// in this module takes a [`BundlePolicy`] as a value and does not care where /// its contents came from, so a TPM, a secure element, or keys fetched at /// manufacture replace this function and nothing else. /// /// Today each is a root-owned file in `/etc/continuum/` (overridable with /// `CONTINUUM_BOX_ID_FILE`, `CONTINUUM_BUNDLE_KEYS_FILE`, /// `CONTINUUM_SIGNING_KEYS_FILE`) - the cheapest thing that makes the /// mechanism runnable, not a claim about where they should live. The manufacturing /// flow that writes them is out of scope. /// /// A missing file is "not provisioned" (`None`/empty) and the box then /// refuses what it cannot check; a file that exists but is empty or invalid /// is an *error*, because quietly treating a broken key file as absent would /// turn a provisioning mistake into a box that accepts unsigned bundles. /// /// The result is a Proxy Box policy: plaintext refused. pub fn load_provisioning() -> anyhow::Result { let read = |var: &str, default: &str| -> anyhow::Result> { let path = std::env::var(var).unwrap_or_else(|_| default.to_string()); match fs::read_to_string(&path) { Ok(text) => Ok(Some(text)), Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), Err(err) => anyhow::bail!("failed to read {path:?}: {err}"), } }; let box_id = read("CONTINUUM_BOX_ID_FILE", BOX_ID_PATH)?.map(|t| parse_box_id(&t)).transpose()?; let keys = read("CONTINUUM_BUNDLE_KEYS_FILE", BUNDLE_KEYS_PATH)?.map(|t| parse_bundle_keys(&t)).transpose()?.unwrap_or_default(); let signing = read("CONTINUUM_SIGNING_KEYS_FILE", SIGNING_KEYS_PATH)?.map(|t| parse_signing_keys(&t)).transpose()?.unwrap_or_default(); Ok(BundlePolicy::proxy_box(box_id, keys, signing)) } fn parse_box_id(text: &str) -> anyhow::Result { let id = text.trim(); anyhow::ensure!(!id.is_empty() && !id.contains(char::is_whitespace), "the box id file must hold one non-empty id with no whitespace"); Ok(id.to_string()) } /// One key per line, `#` comments and blank lines ignored. First is the /// current key, the rest are still accepted - that is the rotation window. fn parse_bundle_keys(text: &str) -> anyhow::Result> { let keys: Vec<_> = significant_lines(text).map(|l| BundleKey::new(l.as_bytes())).collect(); anyhow::ensure!(!keys.is_empty(), "the bundle keys file holds no keys"); Ok(keys) } fn parse_signing_keys(text: &str) -> anyhow::Result> { let keys = significant_lines(text).map(SigningKey::parse_hex).collect::>>()?; anyhow::ensure!(!keys.is_empty(), "the signing keys file holds no keys"); Ok(keys) } fn significant_lines(text: &str) -> impl Iterator { text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) } /// 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 { /// Keys that may open an encrypted bundle, tried in order. Several so a /// rotation has a window in which both old and new work. Empty: every /// encrypted bundle is rejected as having no key. pub keys: Vec, /// Whether a bundle that is not encrypted is acceptable. `false` on a /// Proxy Box; see the module docs for why and what it costs. pub accept_unencrypted: bool, /// Public keys a signature may verify against. **Non-empty makes /// signatures mandatory**; empty skips the check (unprovisioned box). pub signing_keys: Vec, /// This box's own identity, which a signed bundle must name. pub box_id: Option, } impl BundlePolicy { /// Plaintext accepted, no key, no signatures. What an unprovisioned /// development setup does - never what a Proxy Box runs with. pub fn unencrypted_only() -> Self { Self { keys: Vec::new(), accept_unencrypted: true, signing_keys: Vec::new(), box_id: None } } /// The policy of a Proxy Box: plaintext refused. pub fn proxy_box(box_id: Option, keys: Vec, signing_keys: Vec) -> Self { Self { keys, accept_unencrypted: false, signing_keys, box_id } } } /// `config.toml.sig`: the claims that are covered by the signature, and the /// signature itself. Strict, like every other file this crate reads. #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] struct SignatureFile { box_id: String, config_version: u64, signature: String, } #[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. /// `signed` is false only when the box had no signing key to check /// against; that, like a plaintext bundle, is logged at warn. Installed { bundle: PathBuf, sha256: String, encrypted: bool, signed: 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("{SIG_ENTRY} is missing, and this box requires signed bundles")] MissingSignature, #[error("{SIG_ENTRY} is malformed: {0}")] MalformedSignature(String), #[error("the signature does not verify against any of this box's signing keys: the bundle was not made by Continuum Cloud, or the config was changed after signing")] BadSignature, #[error("this box has no identity configured, so it cannot confirm the bundle is meant for it")] NoBoxId, #[error("this bundle is for box {bundle:?}, I am box {this:?}")] WrongBox { bundle: String, this: String }, #[error("config_version {bundle} is older than the installed version {installed}: replaying an older config is refused")] OlderConfig { bundle: u64, installed: u64 }, #[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, /// `Some` only if a signature was checked and passed. version: Option, } /// 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, installed_version: u64) -> 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; let mut sig_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), (SIG_ENTRY, &mut sig_at)] { if name == wanted && slot.replace(index).is_some() { return Err(BundleError::DuplicateEntry(wanted)); } } } // Stage: decide encryption state. Every expected entry present 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 [Some(hash_index), sig_at].into_iter().flatten().any(|i| encrypted_at[&i] != encrypted) { return Err(BundleError::MixedEncryption); } if !encrypted && !policy.accept_unencrypted { 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, key: Option<&[u8]>| -> 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 }) }; // Stage: find the key. Each configured key is tried on the config entry // and the first whose output passes the AES HMAC is used for the rest - // that is the rotation window. The error kept if none works is the most // informative one seen: an HMAC failure means a key got past the 16-bit // password check, so the data (not only the key) is suspect. let (config, key) = if encrypted { if policy.keys.is_empty() { return Err(BundleError::NoKey); } let mut failure = BundleError::WrongKey; let mut found = None; for candidate in &policy.keys { match read(config_index, CONFIG_ENTRY, Some(&candidate.0)) { Ok(bytes) => { found = Some((bytes, Some(candidate.0.as_slice()))); break; } Err(BundleError::WrongKey) => {} Err(err @ BundleError::EncryptedIntegrity(_)) => failure = err, Err(other) => return Err(other), } } found.ok_or(failure)? } else { (read(config_index, CONFIG_ENTRY, None)?, None) }; let hash_text = read(hash_index, HASH_ENTRY, key)?; // Only read when signatures are required. A box with no signing key // ignores the entry rather than decompressing something it cannot use. let sig_text = match (policy.signing_keys.is_empty(), sig_at) { (true, _) => None, (false, None) => None, (false, Some(index)) => Some(read(index, SIG_ENTRY, key)?), }; // 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 }); } let version = check_authenticity(&config, sig_text.as_deref(), policy, installed_version)?; // 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, version }) } /// Authenticity: was this config issued by Continuum Cloud, for this box, and /// is it newer than what is installed? Runs after the hash (cheap damage /// check first) and before the config is parsed or believed. /// /// Returns the signed `config_version` if a signature was checked, `None` /// when the box has no signing key and so cannot check one. /// /// Order within: signature, then box id, then version. The claims in /// `config.toml.sig` are attacker-controlled text until the signature over /// them verifies, so nothing is concluded from them - and nothing about this /// box's identity is revealed - before then. fn check_authenticity( config: &[u8], sig_text: Option<&[u8]>, policy: &BundlePolicy, installed_version: u64, ) -> Result, BundleError> { if policy.signing_keys.is_empty() { return Ok(None); } let sig_text = sig_text.ok_or(BundleError::MissingSignature)?; let text = std::str::from_utf8(sig_text).map_err(|e| BundleError::MalformedSignature(e.to_string()))?; let claims: SignatureFile = toml::from_str(text).map_err(|e| BundleError::MalformedSignature(e.to_string()))?; let signature = decode_hex(&claims.signature) .filter(|s| s.len() == 64) .ok_or_else(|| BundleError::MalformedSignature("`signature` must be 128 hex characters (a 64-byte Ed25519 signature)".into()))?; let message = signed_message(&claims.box_id, claims.config_version, config); if !policy.signing_keys.iter().any(|key| key.verifies(&message, &signature)) { return Err(BundleError::BadSignature); } // Authentic from here: the claims may be believed. let this = policy.box_id.as_deref().ok_or(BundleError::NoBoxId)?; if claims.box_id != this { return Err(BundleError::WrongBox { bundle: claims.box_id, this: this.to_string() }); } if claims.config_version < installed_version { return Err(BundleError::OlderConfig { bundle: claims.config_version, installed: installed_version }); } Ok(Some(claims.config_version)) } fn decode_hex(text: &str) -> Option> { let text = text.trim(); if !text.len().is_multiple_of(2) || !text.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } (0..text.len()).step_by(2).map(|i| u8::from_str_radix(&text[i..i + 2], 16).ok()).collect() } /// Where the installed config's version is recorded: beside it, so it moves /// with the config's directory and needs no other writable place. fn version_path(installed: &Path) -> PathBuf { let mut name = installed.as_os_str().to_owned(); name.push(".version"); PathBuf::from(name) } /// Missing means 0 (nothing signed has been installed). An unreadable or /// non-numeric file is also treated as 0, with a warning: failing closed /// would make a damaged sidecar a permanent block on every update, and /// whoever can damage it can already edit the config. fn read_installed_version(installed: &Path) -> u64 { match fs::read_to_string(version_path(installed)) { Ok(text) => text.trim().parse().unwrap_or_else(|_| { tracing::warn!(path = ?version_path(installed), "installed config version is not a number; treating it as 0"); 0 }), Err(_) => 0, } } /// 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 installed_version = read_installed_version(installed); let verified = match verify_bundle(&archive, policy, installed_version) { 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) { // Same config re-issued at a higher version: record the version, or // an older-but-different config at a version in between would later // pass the replay check. if let Some(version) = verified.version && version > installed_version && let Err(err) = install_atomically(&version_path(installed), version.to_string().as_bytes()) { return reject(format!("could not record config version {version}: {err}")); } return Outcome::Unchanged { bundle: bundle.to_path_buf() }; } // Version before config, so a crash between them leaves "new version, old // config" - which re-inserting the same stick repairs, since an equal // version is accepted - and never "new config, old version", which would // leave a replay window open. if let Some(version) = verified.version && let Err(err) = install_atomically(&version_path(installed), version.to_string().as_bytes()) { return reject(format!("could not record config version {version}: {err}")); } match install_atomically(installed, &verified.bytes) { Ok(()) => Outcome::Installed { bundle: bundle.to_path_buf(), sha256: verified.sha256, encrypted: verified.encrypted, signed: verified.version.is_some(), }, 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, signed } => { 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"); } if !signed { tracing::warn!(bundle = %bundle.display(), "the installed bundle was NOT signature-checked: this box has no signing key"); } } // `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(), 0).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(), 0).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(), 0).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 { keys: vec![BundleKey::new(key)], ..BundlePolicy::unencrypted_only() } } fn rejection_with(archive: &[u8], policy: &BundlePolicy) -> String { verify_bundle(archive, policy, 0).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), 0).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 { keys: vec![BundleKey::new(KEY)], accept_unencrypted: false, ..BundlePolicy::unencrypted_only() }; 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), 0).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}"); } // ---- signatures, box binding, replay, key rotation --------------------- // // The cloud's real keypair is not available, so each test makes its own // Ed25519 keypair with OpenSSL and signs fixtures with it exactly as the // cloud would, through the same `signed_message`. That proves the box // verifies what this code defines as correct; it cannot prove the cloud // signs the same bytes (see the module docs for what it must do). use openssl::pkey::Private; use openssl::sign::Signer; fn keypair() -> (PKey, SigningKey) { let private = PKey::generate_ed25519().unwrap(); let public = SigningKey::from_raw(private.raw_public_key().unwrap().try_into().unwrap()); (private, public) } fn sign(private: &PKey, box_id: &str, version: u64, config: &[u8]) -> String { let mut signer = Signer::new_without_digest(private).unwrap(); let bytes = signer.sign_oneshot_to_vec(&signed_message(box_id, version, config)).unwrap(); bytes.iter().map(|b| format!("{b:02x}")).collect() } fn sig_file(box_id: &str, version: u64, signature: &str) -> String { format!("box_id = \"{box_id}\"\nconfig_version = {version}\nsignature = \"{signature}\"\n") } /// Writes a bundle as the cloud would. `key` of `None` is a plaintext one. fn bundle_of(entries: &[(&str, &[u8])], key: Option<&[u8]>) -> Vec { match key { Some(key) => aes_zip(entries, key), None => stored(entries), } } fn signed_bundle(config: &str, box_id: &str, version: u64, private: &PKey, key: Option<&[u8]>) -> Vec { let hash = format!("{} config.toml\n", sha256_hex(config.as_bytes())); let sig = sig_file(box_id, version, &sign(private, box_id, version, config.as_bytes())); bundle_of(&[(CONFIG_ENTRY, config.as_bytes()), (HASH_ENTRY, hash.as_bytes()), (SIG_ENTRY, sig.as_bytes())], key) } fn box_policy(public: &SigningKey, box_id: &str) -> BundlePolicy { BundlePolicy::proxy_box(Some(box_id.into()), vec![BundleKey::new(KEY)], vec![public.clone()]) } #[test] fn a_signed_encrypted_bundle_for_this_box_is_accepted_and_reports_its_version() { let (private, public) = keypair(); let archive = signed_bundle(GOOD, "BOX-1", 7, &private, Some(KEY)); let verified = verify_bundle(&archive, &box_policy(&public, "BOX-1"), 0).unwrap(); assert!(verified.encrypted); assert_eq!(verified.version, Some(7)); } #[test] fn a_bundle_without_a_signature_is_rejected_once_the_box_has_a_signing_key() { let (_, public) = keypair(); let reason = rejection_with(&encrypted_bundle(GOOD, KEY), &box_policy(&public, "BOX-1")); assert!(reason.contains("requires signed bundles"), "{reason}"); } #[test] fn a_signature_made_by_a_different_key_is_rejected_as_a_bad_signature() { let (impostor, _) = keypair(); let (_, real_public) = keypair(); let archive = signed_bundle(GOOD, "BOX-1", 1, &impostor, Some(KEY)); let reason = rejection_with(&archive, &box_policy(&real_public, "BOX-1")); assert!(reason.contains("does not verify"), "{reason}"); } #[test] fn a_signature_over_different_config_bytes_is_rejected_even_though_the_hash_matches() { // Someone with the encryption key swaps in their own config and fixes // up the hash - the case the hash alone cannot catch. let (private, public) = keypair(); let evil = GOOD.replace("\"t\"", "\"stolen\""); let hash = format!("{} config.toml", sha256_hex(evil.as_bytes())); let sig = sig_file("BOX-1", 1, &sign(&private, "BOX-1", 1, GOOD.as_bytes())); let archive = aes_zip(&[(CONFIG_ENTRY, evil.as_bytes()), (HASH_ENTRY, hash.as_bytes()), (SIG_ENTRY, sig.as_bytes())], KEY); assert!(rejection_with(&archive, &box_policy(&public, "BOX-1")).contains("does not verify")); } #[test] fn a_valid_signature_on_a_config_whose_hash_does_not_match_is_still_rejected() { let (private, public) = keypair(); let hash = format!("{} config.toml", sha256_hex(b"other bytes")); let sig = sig_file("BOX-1", 1, &sign(&private, "BOX-1", 1, GOOD.as_bytes())); let archive = aes_zip(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes()), (SIG_ENTRY, sig.as_bytes())], KEY); assert!(rejection_with(&archive, &box_policy(&public, "BOX-1")).contains("hash mismatch")); } #[test] fn editing_the_version_in_the_signature_file_breaks_the_signature() { let (private, public) = keypair(); let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes())); let real = sign(&private, "BOX-1", 1, GOOD.as_bytes()); let sig = sig_file("BOX-1", 99, &real); let archive = aes_zip(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes()), (SIG_ENTRY, sig.as_bytes())], KEY); assert!(rejection_with(&archive, &box_policy(&public, "BOX-1")).contains("does not verify")); } #[test] fn editing_the_box_id_in_the_signature_file_breaks_the_signature_rather_than_rebinding_it() { let (private, public) = keypair(); let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes())); let sig = sig_file("BOX-2", 1, &sign(&private, "BOX-1", 1, GOOD.as_bytes())); let archive = aes_zip(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes()), (SIG_ENTRY, sig.as_bytes())], KEY); assert!(rejection_with(&archive, &box_policy(&public, "BOX-2")).contains("does not verify")); } #[test] fn a_bundle_genuinely_signed_for_another_box_names_both_boxes_even_with_a_shared_key() { let (private, public) = keypair(); let archive = signed_bundle(GOOD, "BOX-A", 1, &private, Some(KEY)); let reason = rejection_with(&archive, &box_policy(&public, "BOX-B")); assert!(reason.contains("for box \"BOX-A\"") && reason.contains("I am box \"BOX-B\""), "{reason}"); } #[test] fn a_box_with_no_identity_refuses_a_signed_bundle_it_cannot_confirm_is_its_own() { let (private, public) = keypair(); let mut policy = box_policy(&public, "BOX-1"); policy.box_id = None; let reason = rejection_with(&signed_bundle(GOOD, "BOX-1", 1, &private, Some(KEY)), &policy); assert!(reason.contains("no identity"), "{reason}"); } #[test] fn a_bundle_encrypted_for_another_box_is_reported_as_the_wrong_key_not_as_forged() { // Decryption comes first, so this is the answer for a stick that is // simply not for this box - see "Which error wins" in the docs. let (private, public) = keypair(); let archive = signed_bundle(GOOD, "BOX-A", 1, &private, Some(b"box A's own key")); let reason = rejection_with(&archive, &box_policy(&public, "BOX-B")); assert!(reason.contains("not for this box") || reason.contains("key is wrong"), "{reason}"); assert!(!reason.contains("does not verify"), "{reason}"); } #[test] fn a_malformed_signature_file_is_rejected_with_its_own_reason() { let (_, public) = keypair(); let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes())); for bad in ["not toml = =", "box_id = \"x\"\nconfig_version = 1\nsignature = \"abcd\"\n"] { let archive = aes_zip(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes()), (SIG_ENTRY, bad.as_bytes())], KEY); assert!(rejection_with(&archive, &box_policy(&public, "x")).contains("malformed"), "{bad}"); } } #[test] fn a_plaintext_bundle_is_refused_on_a_proxy_box_even_when_correctly_signed() { let (private, public) = keypair(); let reason = rejection_with(&signed_bundle(GOOD, "BOX-1", 1, &private, None), &box_policy(&public, "BOX-1")); assert!(reason.contains("requires encrypted"), "{reason}"); } #[test] fn a_box_with_no_signing_key_ignores_a_signature_entry_and_installs_it_marked_unsigned() { let (private, _) = keypair(); let scratch = Scratch::new(); let bundle = scratch.path("stick.zip"); fs::write(&bundle, signed_bundle(GOOD, "BOX-1", 1, &private, Some(KEY))).unwrap(); let policy = BundlePolicy { keys: vec![BundleKey::new(KEY)], ..BundlePolicy::unencrypted_only() }; let outcome = import_bundle(&bundle, &scratch.path("config.toml"), &policy); assert!(matches!(outcome, Outcome::Installed { signed: false, .. }), "{outcome:?}"); } #[test] fn a_signature_entry_that_is_plain_in_an_encrypted_bundle_is_rejected_as_mixed() { let (private, public) = keypair(); let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes())); let sig = sig_file("BOX-1", 1, &sign(&private, "BOX-1", 1, 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); let aes = plain.with_aes_encryption_bytes(zip::AesMode::Aes256, KEY); w.start_file(CONFIG_ENTRY, aes).unwrap(); w.write_all(GOOD.as_bytes()).unwrap(); w.start_file(HASH_ENTRY, aes).unwrap(); w.write_all(hash.as_bytes()).unwrap(); w.start_file(SIG_ENTRY, plain).unwrap(); w.write_all(sig.as_bytes()).unwrap(); let archive = w.finish().unwrap().into_inner(); assert!(rejection_with(&archive, &box_policy(&public, "BOX-1")).contains("not encrypted alike")); } #[test] fn the_signed_message_cannot_be_made_to_collide_by_shifting_bytes_between_box_id_and_config() { assert_ne!(signed_message("ab", 1, b"c"), signed_message("a", 1, b"bc")); assert_ne!(signed_message("a", 1, b"x"), signed_message("a", 2, b"x")); } fn installed_on_box(scratch: &Scratch, policy: &BundlePolicy, bundle_bytes: Vec) -> (Outcome, PathBuf) { let bundle = scratch.path("stick.zip"); fs::write(&bundle, bundle_bytes).unwrap(); let installed = scratch.path("config.toml"); (import_bundle(&bundle, &installed, policy), installed) } #[test] fn installing_a_signed_bundle_records_its_version_beside_the_config() { let (private, public) = keypair(); let scratch = Scratch::new(); let (outcome, installed) = installed_on_box(&scratch, &box_policy(&public, "BOX-1"), signed_bundle(GOOD, "BOX-1", 5, &private, Some(KEY))); assert!(matches!(outcome, Outcome::Installed { signed: true, encrypted: true, .. }), "{outcome:?}"); assert_eq!(fs::read_to_string(version_path(&installed)).unwrap(), "5"); } #[test] fn an_older_config_is_refused_after_a_newer_one_is_installed_and_the_newer_one_stays() { let (private, public) = keypair(); let policy = box_policy(&public, "BOX-1"); let scratch = Scratch::new(); let newer = GOOD.replace("\"f\"", "\"new-farm\""); let (first, installed) = installed_on_box(&scratch, &policy, signed_bundle(&newer, "BOX-1", 6, &private, Some(KEY))); assert!(matches!(first, Outcome::Installed { .. })); // A perfectly genuine bundle for this box, from before. let (replay, _) = installed_on_box(&scratch, &policy, signed_bundle(GOOD, "BOX-1", 5, &private, Some(KEY))); match replay { Outcome::Rejected { reason, .. } => assert!(reason.contains("older than the installed version 6"), "{reason}"), other => panic!("expected a rejection, got {other:?}"), } assert_eq!(fs::read_to_string(&installed).unwrap(), newer); assert_eq!(fs::read_to_string(version_path(&installed)).unwrap(), "6"); } #[test] fn the_same_version_is_accepted_so_a_stick_can_finish_an_interrupted_install() { let (private, public) = keypair(); let policy = box_policy(&public, "BOX-1"); let scratch = Scratch::new(); let (_, installed) = installed_on_box(&scratch, &policy, signed_bundle(GOOD, "BOX-1", 6, &private, Some(KEY))); // The crash: version recorded, config lost. fs::write(&installed, "damaged").unwrap(); let (again, _) = installed_on_box(&scratch, &policy, signed_bundle(GOOD, "BOX-1", 6, &private, Some(KEY))); assert!(matches!(again, Outcome::Installed { .. }), "{again:?}"); assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD); } #[test] fn a_newer_version_installs() { let (private, public) = keypair(); let policy = box_policy(&public, "BOX-1"); let scratch = Scratch::new(); let (_, installed) = installed_on_box(&scratch, &policy, signed_bundle(GOOD, "BOX-1", 1, &private, Some(KEY))); let newer = GOOD.replace("\"f\"", "\"g2\""); let (second, _) = installed_on_box(&scratch, &policy, signed_bundle(&newer, "BOX-1", 2, &private, Some(KEY))); assert!(matches!(second, Outcome::Installed { .. })); assert_eq!(fs::read_to_string(&installed).unwrap(), newer); } #[test] fn reissuing_the_same_config_at_a_higher_version_raises_the_recorded_version() { let (private, public) = keypair(); let policy = box_policy(&public, "BOX-1"); let scratch = Scratch::new(); let (_, installed) = installed_on_box(&scratch, &policy, signed_bundle(GOOD, "BOX-1", 1, &private, Some(KEY))); let (again, _) = installed_on_box(&scratch, &policy, signed_bundle(GOOD, "BOX-1", 9, &private, Some(KEY))); assert!(matches!(again, Outcome::Unchanged { .. }), "{again:?}"); assert_eq!(fs::read_to_string(version_path(&installed)).unwrap(), "9"); } #[test] fn a_rejected_forgery_leaves_the_installed_config_and_its_version_alone() { let (private, public) = keypair(); let (impostor, _) = keypair(); let policy = box_policy(&public, "BOX-1"); let scratch = Scratch::new(); let (_, installed) = installed_on_box(&scratch, &policy, signed_bundle(GOOD, "BOX-1", 3, &private, Some(KEY))); let evil = GOOD.replace("\"t\"", "\"stolen\""); let (outcome, _) = installed_on_box(&scratch, &policy, signed_bundle(&evil, "BOX-1", 100, &impostor, Some(KEY))); assert!(matches!(outcome, Outcome::Rejected { .. })); assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD); assert_eq!(fs::read_to_string(version_path(&installed)).unwrap(), "3"); } #[test] fn during_a_key_rotation_a_bundle_under_either_the_old_or_the_new_key_opens() { let (private, public) = keypair(); let mut policy = box_policy(&public, "BOX-1"); policy.keys = vec![BundleKey::new(b"new key".as_slice()), BundleKey::new(b"old key".as_slice())]; for key in [b"new key".as_slice(), b"old key".as_slice()] { let archive = signed_bundle(GOOD, "BOX-1", 1, &private, Some(key)); assert!(verify_bundle(&archive, &policy, 0).is_ok(), "{:?}", String::from_utf8_lossy(key)); } let unknown = signed_bundle(GOOD, "BOX-1", 1, &private, Some(b"retired key")); assert!(verify_bundle(&unknown, &policy, 0).is_err()); } #[test] fn during_a_signing_key_rotation_a_signature_from_either_public_key_verifies() { let (old_private, old_public) = keypair(); let (new_private, new_public) = keypair(); let mut policy = box_policy(&new_public, "BOX-1"); policy.signing_keys.push(old_public); for private in [&old_private, &new_private] { assert!(verify_bundle(&signed_bundle(GOOD, "BOX-1", 1, private, Some(KEY)), &policy, 0).is_ok()); } } #[test] fn the_provisioning_files_parse_comments_and_lists_and_reject_garbage() { assert_eq!(parse_box_id(" BOX-0001\n").unwrap(), "BOX-0001"); assert!(parse_box_id("two words").is_err()); assert!(parse_box_id("\n").is_err()); let keys = parse_bundle_keys("# current\nnew\n\nold\n").unwrap(); assert_eq!(keys.len(), 2); assert!(parse_bundle_keys("# nothing\n").is_err()); let (_, public) = keypair(); let hex: String = public.0.iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(parse_signing_keys(&format!("# fleet key\n{hex}\n")).unwrap(), vec![public]); assert!(parse_signing_keys("not hex").is_err()); assert!(parse_signing_keys(&"ab".repeat(31)).is_err(), "a 31-byte key is not an Ed25519 public key"); } }