diff --git a/continuum.example.toml b/continuum.example.toml index fde401e..57ad0bf 100644 --- a/continuum.example.toml +++ b/continuum.example.toml @@ -5,16 +5,14 @@ # # PC / server: copy to continuum.toml next to the proxy and edit it. Nothing # else is needed; removable media is never read. -# Proxy Box: put this file in a ZIP named continuum-config.zip together -# with its SHA-256, and insert the stick: -# -# cp continuum.example.toml config.toml # edit it -# sha256sum config.toml > config.toml.sha256 -# zip continuum-config.zip config.toml config.toml.sha256 -# -# The hash catches a damaged or hand-edited stick. It does NOT -# prove who made it - anyone who can write the stick can write -# both files. See src/config_import.rs. +# Proxy Box: this file is NOT written by hand. Continuum Cloud's +# configurator produces continuum-config.zip: config.toml, +# config.toml.sha256 and config.toml.sig (an Ed25519 signature +# naming this box and a config_version), every entry +# AES-256-encrypted under the box's own key. A Box refuses +# plaintext, unsigned, other-box and older-version bundles. +# See the module docs of src/config_import.rs for the exact +# format the cloud must produce. # # Unknown keys are rejected on purpose, so a typo cannot silently fall back to # a default. On a Proxy Box the file must be complete by itself. diff --git a/src/config_import.rs b/src/config_import.rs index b4de34d..7542528 100644 --- a/src/config_import.rs +++ b/src/config_import.rs @@ -8,84 +8,138 @@ //! config is now different"; *which* installation this is lives in //! `bootstrap.rs`, and what the config means in `config.rs`. //! -//! # What the hash does and does not buy - read this before relying on it +//! # The layers, and what each one is for //! -//! The SHA-256 in the bundle detects **corruption and change**: a stick that -//! was half-copied, a file edited by hand without regenerating the hash, a -//! bit flipped in flash. It does **not** detect **forgery**. The hash sits in -//! the same archive as the file it hashes, so anyone able to write the -//! pendrive can write a matching pair. It is a checksum, not authentication, -//! and nothing here calls it that. +//! 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. //! -//! What would give authenticity is a signature (e.g. Ed25519 over the config -//! bytes, public key baked into the box image) - the follow-up, not done -//! here. Until then the **trust assumption is: whoever can physically put a -//! stick in the box is trusted to configure it.** That is the same trust a -//! person with physical access already has, and it is why PC mode refuses -//! removable media entirely: there the assumption does not hold. +//! | 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 | //! -//! SHA-256 over MD5/CRC32: not for strength (forgery is out of scope as -//! above) but because it is the hash every operator's tooling already has -//! (`sha256sum`), it costs nothing here (`openssl` is already a direct -//! dependency; no `sha2` crate added), and it will not need changing when a -//! signature is layered on. +//! ## Hash: corruption, not forgery //! -//! # Encryption: what it adds, and what it does not +//! 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). //! -//! 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"). +//! ## Encryption: confidentiality, not authenticity //! -//! **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. +//! 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. //! -//! **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`. +//! 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 file starts with a very guessable prefix. An entry that is +//! 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 is never run on our input. +//! decryption is attempted, so ZipCrypto code never runs 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). +//! **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. //! -//! **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. +//! ## Signature: the thing that resists tampering //! -//! The key is not designed here: see [`BundleKey`] and [`load_bundle_key`], -//! the single seam where provisioning lands. +//! 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 //! @@ -94,7 +148,7 @@ //! //! - **Zip-slip.** Entry names with a leading `/`, a `..` component, a drive //! prefix or a backslash separator reject the whole bundle. This code never -//! writes to a path derived from an entry name - only the two expected +//! 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 @@ -106,8 +160,8 @@ //! plaintext one's.) At most [`MAX_ENTRIES`] entries and [`MAX_ENTRY_BYTES`] //! per entry, checked against the *declared* size before decompressing and //! again against the *actual* output while reading, because a header can -//! lie. Only the two expected entries are ever decompressed, so output is -//! bounded by twice the per-entry cap whatever the archive claims. The +//! 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 @@ -130,6 +184,9 @@ 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}; @@ -137,6 +194,7 @@ use crate::config::{Config, ProxyFile}; 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. @@ -167,56 +225,150 @@ impl std::fmt::Debug for BundleKey { } } -/// Default location of the key on a box. A **placeholder**, not a decision. -const BUNDLE_KEY_PATH: &str = "/etc/continuum/bundle.key"; +/// 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]); -/// 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}"), +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 { - /// 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. + /// 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. What an unprovisioned box does. + /// 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 { key: None, accept_unencrypted: true } + 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)] @@ -228,8 +380,9 @@ 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. - /// `encrypted` is false for a plaintext bundle, which is logged at warn. - Installed { bundle: PathBuf, sha256: String, encrypted: bool }, + /// `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 }, } @@ -256,6 +409,18 @@ enum BundleError { 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")] @@ -277,6 +442,8 @@ 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. @@ -338,7 +505,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], policy: &BundlePolicy) -> Result { +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 { @@ -350,6 +517,7 @@ fn verify_bundle(archive: &[u8], policy: &BundlePolicy) -> Result Result 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)] { + 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. Both expected entries must agree, and - // the policy must allow it, before any key is used. + // 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 encrypted != encrypted_at[&hash_index] { + if [Some(hash_index), sig_at].into_iter().flatten().any(|i| encrypted_at[&i] != encrypted) { 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 { + 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| -> Result, BundleError> { + 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), @@ -422,8 +586,41 @@ fn verify_bundle(archive: &[u8], policy: &BundlePolicy) -> Result { + 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 @@ -434,7 +631,7 @@ fn verify_bundle(archive: &[u8], policy: &BundlePolicy) -> Result Result Result<(), BundleError> { - Ok(()) +/// 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 @@ -531,7 +786,8 @@ pub fn import_bundle(bundle: &Path, installed: &Path, policy: &BundlePolicy) -> Ok(bytes) => bytes, Err(err) => return reject(format!("could not read the bundle: {err}")), }; - let verified = match verify_bundle(&archive, policy) { + 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()), }; @@ -542,11 +798,34 @@ pub fn import_bundle(bundle: &Path, installed: &Path, policy: &BundlePolicy) -> // 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 }, + 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}")), } } @@ -627,7 +906,7 @@ 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, encrypted } => { + 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 @@ -635,6 +914,9 @@ pub fn log_outcome(outcome: &Outcome) { 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. @@ -733,12 +1015,12 @@ mod tests { } fn rejection(archive: &[u8]) -> String { - verify_bundle(archive, &BundlePolicy::unencrypted_only()).err().expect("bundle should be rejected").to_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()).unwrap(); + 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())); } @@ -747,7 +1029,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, &BundlePolicy::unencrypted_only()).is_ok()); + assert!(verify_bundle(&archive, &BundlePolicy::unencrypted_only(), 0).is_ok()); } #[test] @@ -1067,16 +1349,16 @@ mod tests { } fn keyed(key: &[u8]) -> BundlePolicy { - BundlePolicy { key: Some(BundleKey::new(key)), accept_unencrypted: true } + BundlePolicy { keys: vec![BundleKey::new(key)], ..BundlePolicy::unencrypted_only() } } fn rejection_with(archive: &[u8], policy: &BundlePolicy) -> String { - verify_bundle(archive, policy).err().expect("bundle should be rejected").to_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)).unwrap(); + let verified = verify_bundle(&encrypted_bundle(GOOD, KEY), &keyed(KEY), 0).unwrap(); assert_eq!(verified.bytes, GOOD.as_bytes()); assert!(verified.encrypted); } @@ -1180,13 +1462,13 @@ mod tests { #[test] fn a_plaintext_bundle_is_refused_when_the_policy_requires_encryption() { - let policy = BundlePolicy { key: Some(BundleKey::new(KEY)), accept_unencrypted: false }; + 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)).unwrap(); + let verified = verify_bundle(&good_bundle(GOOD), &keyed(KEY), 0).unwrap(); assert!(!verified.encrypted); } @@ -1228,4 +1510,321 @@ mod tests { 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"); + } } diff --git a/src/main.rs b/src/main.rs index b275783..65608c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ mod go2rtc; use tracing::info; use tracing_subscriber::EnvFilter; -use continuum_proxy::bootstrap::Bootstrap; +use continuum_proxy::bootstrap::{self, Bootstrap}; use continuum_proxy::config::Config; use continuum_proxy::config_import; use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; @@ -27,12 +27,27 @@ 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()?; - 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, + // Plaintext bundles are refused on a Proxy Box and signatures are + // mandatory once a signing key is provisioned; see the config_import + // module docs. An unprovisioned box is allowed to start - it runs on its + // installed config - but says loudly what it cannot check. + // + // Loaded only on a box: on a PC nothing imports from removable media, and + // a stray or malformed file under /etc/continuum must not stop a PC user's + // proxy starting over provisioning it never uses. + let policy = if bootstrap.mode == bootstrap::Mode::ProxyBox { + config_import::load_provisioning()? + } else { + config_import::BundlePolicy::unencrypted_only() }; + if bootstrap.mode == bootstrap::Mode::ProxyBox { + if policy.signing_keys.is_empty() { + tracing::warn!("no signing key provisioned: config bundles will NOT be authenticated"); + } + if policy.box_id.is_none() { + tracing::warn!("no box id provisioned: signed config bundles cannot be accepted"); + } + } for outcome in config_import::import_from_pendrives(&bootstrap, &policy) { config_import::log_outcome(&outcome); }