From 062686b1a70c50ce4f16f282e8d11bf2ab79098e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Mon, 19 Oct 2026 08:08:27 +0000 Subject: [PATCH] Authenticate config bundles: Ed25519, per-box identity, no plaintext The hash in a bundle only proved it was undamaged. Anyone who could write the stick could write both files, so a Proxy Box would install a config handed to it by whoever got physical access - including a config pointing the box at a different cloud. Encryption alone does not fix that either. While plaintext was accepted an attacker simply did not encrypt, and the box took it. So a Box now refuses plaintext outright, and the bundle carries a signature. A third entry, `config.toml.sig`: strict TOML with `box_id`, `config_version` and a raw Ed25519 signature. What is signed is `signed_message()` - a domain tag, the length-prefixed box id, the version big-endian, then the raw `config.toml` bytes. Raw bytes rather than the archive, so the signature is independent of ZIP framing, compression, entry order and encryption. The box id and version are inside the signed message rather than beside it, so neither can be edited without breaking it, and the length prefix stops a byte-shifting collision between the id and the config. Past decryption the order is hash, signature, box id, version, then parse and validate. The claims in the `.sig` are attacker-controlled text until the signature over them verifies, so nothing is concluded from them before then - and nothing about this box's identity is revealed before then either, which is why unsigned junk cannot be used to probe which box it is talking to. Which error wins when a box cannot even open a bundle: a signature over the plaintext is only checkable after decryption, so a box with the wrong key cannot distinguish "meant for another box" from "forged". Wrong key wins. Neither bundle installs either way, it is nearly always the true cause, and calling something forged that we could not open would be an accusation we cannot support. Replay is enforced rather than documented away: `config_version` must not go backwards, recorded in a sidecar beside the installed config. An equal version is accepted on purpose, because the version is written before the config, so a power cut between the two leaves "new version, old config" and re-inserting the same stick repairs it. The other write order would leave a replay window instead. Both key sets are lists, so the cloud's signing key and a box's own key can be rotated. `load_provisioning()` is the single seam where a box's identity and keys come from; a TPM or secure element replaces that function and nothing else. A missing file means unprovisioned, and the box then says loudly what it cannot check - but an empty or malformed one is an error, because quietly reading a broken key file as absent would turn a provisioning mistake into a box that accepts unsigned bundles. Not verified: interop with the real cloud signer. These tests prove the box accepts what this code defines as correct, not that the cloud will produce the same bytes - the message layout, the pure-Ed25519-no-prehash choice and the `.sig` shape all need a cross-check against the configurator's own crypto library once it exists. A compromised cloud signing key still breaks every box trusting it; revocation is not built. Co-Authored-By: Claude Opus 5 --- continuum.example.toml | 18 +- src/config_import.rs | 887 ++++++++++++++++++++++++++++++++++------- src/main.rs | 27 +- 3 files changed, 772 insertions(+), 160 deletions(-) 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); }