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.
1831 lines
85 KiB
Rust
1831 lines
85 KiB
Rust
//! Installing a Proxy Box's config from a pendrive.
|
|
//!
|
|
//! A bundle is a ZIP (`continuum-config.zip`) holding `config.toml` and
|
|
//! `config.toml.sha256`. On boot, and while running when a stick appears,
|
|
//! a Proxy Box looks for one, checks it, and replaces its installed config
|
|
//! only if the new one differs from what it already has. This module is
|
|
//! everything between "there is a file on the stick" and "the installed
|
|
//! config is now different"; *which* installation this is lives in
|
|
//! `bootstrap.rs`, and what the config means in `config.rs`.
|
|
//!
|
|
//! # The layers, and what each one is for
|
|
//!
|
|
//! A bundle passes four independent checks. They answer different questions,
|
|
//! and conflating them is how a config scheme ends up trusted for something
|
|
//! it never established.
|
|
//!
|
|
//! | Layer | Catches | Does NOT stop |
|
|
//! |---|---|---|
|
|
//! | SHA-256 (`config.toml.sha256`) | corruption, a hand edit | forgery - the hash rides next to the file |
|
|
//! | AES-256 encryption (WinZip AE-x) | disclosure of the token/PSK on a lost stick | forgery by anyone holding the key |
|
|
//! | **Ed25519 signature** (`config.toml.sig`) | **forgery and tampering**, by anyone without the cloud's private key | a compromised cloud signing key |
|
|
//! | box id + `config_version` (inside the signature) | a bundle meant for another box; replay of an older config | a signing-key compromise |
|
|
//!
|
|
//! ## Hash: corruption, not forgery
|
|
//!
|
|
//! The hash sits in the same archive as the file it hashes, so anyone able to
|
|
//! write the pendrive can write a matching pair. It is a checksum, not
|
|
//! authentication. SHA-256 rather than CRC/MD5: not for strength but because
|
|
//! it is what `sha256sum` already produces, and `openssl` is a direct
|
|
//! dependency (no `sha2` crate).
|
|
//!
|
|
//! ## Encryption: confidentiality, not authenticity
|
|
//!
|
|
//! The bundle is produced by Continuum Cloud and carried on a stick, so it is
|
|
//! a **secret at rest** - it holds the gateway token and possibly a wifi PSK.
|
|
//! Encryption (WinZip AES-256) means a lost or posted stick discloses
|
|
//! nothing. That is its job.
|
|
//!
|
|
//! It is not a tamper defence. WinZip-AES carries an HMAC, so a change by
|
|
//! someone *without the key* is detected - but the key has to be on the box,
|
|
//! so whoever extracts it from a box's image can build bundles that decrypt
|
|
//! and verify. Per-box keys limit that to one box; they do not prevent it.
|
|
//! Nothing here calls encryption tamper-proof.
|
|
//!
|
|
//! **Only AES.** Legacy ZipCrypto is refused outright, whatever key is
|
|
//! configured: it falls to a known-plaintext attack with a handful of bytes,
|
|
//! and a config starts with a very guessable prefix. An entry that is
|
|
//! encrypted without the AES extra field rejects the bundle before any
|
|
//! decryption is attempted, so ZipCrypto code never runs on our input.
|
|
//!
|
|
//! **Plaintext is refused on a Proxy Box** (`accept_unencrypted = false`,
|
|
//! [`BundlePolicy::proxy_box`]). While it was accepted an attacker did not
|
|
//! need to break the encryption - they just handed over a plaintext bundle -
|
|
//! so encryption discouraged nothing. The cost, accepted: sticks made before
|
|
//! the configurator encrypts, and hand-made development sticks, stop working
|
|
//! on a Box. The PC path is unaffected; it takes a plain file and never
|
|
//! reaches this module.
|
|
//!
|
|
//! ## Signature: the thing that resists tampering
|
|
//!
|
|
//! Continuum Cloud signs; the box verifies with only the **public** key in
|
|
//! its image. A public key can check a signature but cannot make one, so
|
|
//! reading a box's image - or every box's - yields nothing that forges a
|
|
//! bundle. This is what the encryption could not give.
|
|
//!
|
|
//! **What is signed** is a message built by [`signed_message`]: a fixed
|
|
//! domain-separation tag, the box id, the `config_version`, and the **raw
|
|
//! `config.toml` bytes**. The config bytes (not the ZIP, not the ciphertext),
|
|
//! so the signature is independent of ZIP framing, compression, entry
|
|
//! order, and of whether the bundle is encrypted; re-zipping or re-encrypting
|
|
//! a bundle for another transport cannot invalidate it. The box id and
|
|
//! version are inside the signed message, not beside it, so they cannot be
|
|
//! swapped without breaking the signature; they travel in
|
|
//! `config.toml.sig` (a small TOML) rather than as fields of `config.toml`
|
|
//! because the config schema describes proxy *settings* and is strict about
|
|
//! its keys. The domain tag stops a signature made for some other Continuum
|
|
//! purpose being replayed as a config signature; the box id is
|
|
//! length-prefixed so `("ab","c...")` and `("a","bc...")` cannot collide.
|
|
//!
|
|
//! **Mandatory once a public key is present.** If the box has any signing
|
|
//! key, an unsigned bundle is rejected as its own reason, as is a bad
|
|
//! signature. With *no* key the check cannot run and is skipped - an
|
|
//! unprovisioned box - and the install is logged at `warn` as unsigned.
|
|
//! Several keys may be listed so the cloud's signing key can be rotated with
|
|
//! a transition window; any one verifying is enough.
|
|
//!
|
|
//! **Box binding.** The box checks the signed box id against its own
|
|
//! identity ([`load_provisioning`]). A bundle for box A is refused on box B
|
|
//! with "this bundle is for box A, this is box B" even if the encryption key
|
|
//! were somehow shared. A box with no identity cannot confirm a bundle is for
|
|
//! it and refuses signed bundles.
|
|
//!
|
|
//! **Replay.** The signed `config_version` is a monotonic number the cloud
|
|
//! increments per box. The box records the version it installed in a sidecar
|
|
//! (`<config>.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` (`<hex> config.toml`);
|
|
//! 3. sign `signed_message(ID, N, config_bytes)` with the fleet **Ed25519
|
|
//! private key** (pure Ed25519, no prehash) and write
|
|
//! `config.toml.sig` as TOML: `box_id`, `config_version`, `signature`
|
|
//! (128 lowercase hex characters);
|
|
//! 4. ZIP all three, each entry encrypted with **WinZip AES-256 (AE-2)**
|
|
//! under box `ID`'s own key - never ZipCrypto, never a mix;
|
|
//! 5. keep `N` strictly increasing per box.
|
|
//!
|
|
//! The key is not designed here: [`load_provisioning`] is the single seam
|
|
//! where a box's id, decryption keys and signing public keys come from.
|
|
//!
|
|
//! # Hostile archives
|
|
//!
|
|
//! The stick is untrusted input even though its author is trusted: it can be
|
|
//! mistyped, mis-made by a script, or simply the wrong stick.
|
|
//!
|
|
//! - **Zip-slip.** Entry names with a leading `/`, a `..` component, a drive
|
|
//! prefix or a backslash separator reject the whole bundle. This code never
|
|
//! writes to a path derived from an entry name - only the expected
|
|
//! entries are read, into memory, and the only file written is the
|
|
//! configured destination - so the check is defence in depth, kept because
|
|
//! a later change that starts extracting would otherwise be one line from a
|
|
//! path-traversal bug, and because a bundle with such a name is wrong
|
|
//! enough to refuse on its own.
|
|
//! - **Zip bomb.** (For an encrypted bundle the caps apply to the
|
|
//! *decrypted* output: the read is bounded on what comes out of the
|
|
//! cipher, and the encrypted entry's size header is as untrusted as a
|
|
//! plaintext one's.) At most [`MAX_ENTRIES`] entries and [`MAX_ENTRY_BYTES`]
|
|
//! per entry, checked against the *declared* size before decompressing and
|
|
//! again against the *actual* output while reading, because a header can
|
|
//! lie. Only the three expected entries are ever decompressed, so output is
|
|
//! bounded by three times the per-entry cap whatever the archive claims. The
|
|
//! file read off the stick is itself capped at [`MAX_BUNDLE_BYTES`]
|
|
//! so a mis-named multi-gigabyte file cannot exhaust memory first.
|
|
//! - **Non-regular bundle files** (symlinks, devices) are skipped: a link
|
|
//! from a stick to a device node would otherwise be read as a bundle.
|
|
//!
|
|
//! # A bad stick never bricks the box
|
|
//!
|
|
//! Every failure here is an [`Outcome::Rejected`], not an error that
|
|
//! propagates: the installed config is untouched, the reason is logged at
|
|
//! `error` level, and the box carries on running with what it has. Install
|
|
//! is atomic - temp file in the destination directory, `fsync`, `rename` -
|
|
//! so a stick yanked mid-copy, or power lost mid-write, leaves either the old
|
|
//! config or the new one and never a truncated mix. The temp file lives in
|
|
//! the destination's directory (not `/tmp`) because `rename` is only atomic
|
|
//! within one filesystem.
|
|
|
|
use std::collections::HashSet;
|
|
use std::fs;
|
|
use std::io::{self, Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use openssl::pkey::{Id, PKey};
|
|
use openssl::sign::Verifier;
|
|
|
|
use crate::bootstrap::{Bootstrap, Mode};
|
|
use crate::config::{Config, ProxyFile};
|
|
|
|
/// The file an operator puts on the stick.
|
|
pub const BUNDLE_NAME: &str = "continuum-config.zip";
|
|
const CONFIG_ENTRY: &str = "config.toml";
|
|
const HASH_ENTRY: &str = "config.toml.sha256";
|
|
const SIG_ENTRY: &str = "config.toml.sig";
|
|
|
|
/// A real config is a few hundred bytes; 256 KiB is three orders of
|
|
/// magnitude of headroom and still small enough to hold in memory twice.
|
|
pub const MAX_ENTRY_BYTES: u64 = 256 * 1024;
|
|
/// Two expected entries plus a little for whatever a ZIP tool adds
|
|
/// (`__MACOSX/` resource forks, a README). A bundle with dozens of entries
|
|
/// is not a config bundle.
|
|
pub const MAX_ENTRIES: usize = 16;
|
|
/// The compressed file. Generous against `MAX_ENTRY_BYTES * MAX_ENTRIES`
|
|
/// worth of incompressible junk; its job is only to bound the read.
|
|
pub const MAX_BUNDLE_BYTES: u64 = 1024 * 1024;
|
|
|
|
/// The secret that opens an encrypted bundle (a WinZip-AES password).
|
|
///
|
|
/// `Debug` is hand-written so the key cannot reach a log line by accident.
|
|
#[derive(Clone, PartialEq, Eq)]
|
|
pub struct BundleKey(Vec<u8>);
|
|
|
|
impl BundleKey {
|
|
pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
|
|
Self(bytes.into())
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for BundleKey {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str("BundleKey(<redacted>)")
|
|
}
|
|
}
|
|
|
|
/// An Ed25519 *public* key the cloud's signatures are checked against. Public,
|
|
/// so printing it is harmless - it is the one thing in this module that is.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SigningKey([u8; 32]);
|
|
|
|
impl SigningKey {
|
|
pub fn from_raw(bytes: [u8; 32]) -> Self {
|
|
Self(bytes)
|
|
}
|
|
|
|
fn parse_hex(text: &str) -> anyhow::Result<Self> {
|
|
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<u8> {
|
|
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<BundlePolicy> {
|
|
let read = |var: &str, default: &str| -> anyhow::Result<Option<String>> {
|
|
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<String> {
|
|
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<Vec<BundleKey>> {
|
|
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<Vec<SigningKey>> {
|
|
let keys = significant_lines(text).map(SigningKey::parse_hex).collect::<anyhow::Result<Vec<_>>>()?;
|
|
anyhow::ensure!(!keys.is_empty(), "the signing keys file holds no keys");
|
|
Ok(keys)
|
|
}
|
|
|
|
fn significant_lines(text: &str) -> impl Iterator<Item = &str> {
|
|
text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#'))
|
|
}
|
|
|
|
/// What a box will accept. Passed in rather than read from globals so the
|
|
/// whole flow is testable and the policy is visible at the call site.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BundlePolicy {
|
|
/// Keys that may open an encrypted bundle, tried in order. Several so a
|
|
/// rotation has a window in which both old and new work. Empty: every
|
|
/// encrypted bundle is rejected as having no key.
|
|
pub keys: Vec<BundleKey>,
|
|
/// 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<SigningKey>,
|
|
/// This box's own identity, which a signed bundle must name.
|
|
pub box_id: Option<String>,
|
|
}
|
|
|
|
impl BundlePolicy {
|
|
/// Plaintext accepted, no key, no signatures. What an unprovisioned
|
|
/// development setup does - never what a Proxy Box runs with.
|
|
pub fn unencrypted_only() -> Self {
|
|
Self { keys: Vec::new(), accept_unencrypted: true, signing_keys: Vec::new(), box_id: None }
|
|
}
|
|
|
|
/// The policy of a Proxy Box: plaintext refused.
|
|
pub fn proxy_box(box_id: Option<String>, keys: Vec<BundleKey>, signing_keys: Vec<SigningKey>) -> Self {
|
|
Self { keys, accept_unencrypted: false, signing_keys, box_id }
|
|
}
|
|
}
|
|
|
|
/// `config.toml.sig`: the claims that are covered by the signature, and the
|
|
/// signature itself. Strict, like every other file this crate reads.
|
|
#[derive(Debug, serde::Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct SignatureFile {
|
|
box_id: String,
|
|
config_version: u64,
|
|
signature: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Outcome {
|
|
/// PC mode, or no directories configured: nothing was looked at.
|
|
Disabled,
|
|
/// No bundle on any scanned medium.
|
|
NoBundle,
|
|
/// A valid bundle whose config is byte-identical to the installed one.
|
|
Unchanged { bundle: PathBuf },
|
|
/// The installed config was replaced. `sha256` is the new config's hash.
|
|
/// `signed` is false only when the box had no signing key to check
|
|
/// against; that, like a plaintext bundle, is logged at warn.
|
|
Installed { bundle: PathBuf, sha256: String, encrypted: bool, signed: bool },
|
|
/// The bundle was refused; the installed config is untouched.
|
|
Rejected { bundle: PathBuf, reason: String },
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
enum BundleError {
|
|
#[error("not a readable ZIP archive: {0}")]
|
|
NotAnArchive(String),
|
|
#[error("unsafe entry name {0:?} (absolute path, `..`, drive prefix or backslash)")]
|
|
UnsafeEntry(String),
|
|
#[error("{0} entries; at most {MAX_ENTRIES} are allowed")]
|
|
TooManyEntries(usize),
|
|
#[error("entry {name:?} is {size} bytes; at most {MAX_ENTRY_BYTES} are allowed")]
|
|
EntryTooLarge { name: String, size: u64 },
|
|
#[error("entry {0:?} uses legacy ZipCrypto encryption, which is broken and refused; only WinZip AES is accepted")]
|
|
LegacyEncryption(String),
|
|
#[error("{CONFIG_ENTRY} and {HASH_ENTRY} are not encrypted alike; a bundle is wholly encrypted or not at all")]
|
|
MixedEncryption,
|
|
#[error("the bundle is encrypted but this box has no bundle key, so it cannot be opened - this bundle is not for this box")]
|
|
NoKey,
|
|
#[error("the bundle is encrypted with a key this box does not have - this bundle is not for this box")]
|
|
WrongKey,
|
|
#[error("entry {0:?} failed its AES integrity check after decryption: the data was damaged or modified, or (1 in 65536) the key is wrong")]
|
|
EncryptedIntegrity(&'static str),
|
|
#[error("the bundle is not encrypted, and this box requires encrypted bundles")]
|
|
UnencryptedRefused,
|
|
#[error("{SIG_ENTRY} is missing, and this box requires signed bundles")]
|
|
MissingSignature,
|
|
#[error("{SIG_ENTRY} is malformed: {0}")]
|
|
MalformedSignature(String),
|
|
#[error("the signature does not verify against any of this box's signing keys: the bundle was not made by Continuum Cloud, or the config was changed after signing")]
|
|
BadSignature,
|
|
#[error("this box has no identity configured, so it cannot confirm the bundle is meant for it")]
|
|
NoBoxId,
|
|
#[error("this bundle is for box {bundle:?}, I am box {this:?}")]
|
|
WrongBox { bundle: String, this: String },
|
|
#[error("config_version {bundle} is older than the installed version {installed}: replaying an older config is refused")]
|
|
OlderConfig { bundle: u64, installed: u64 },
|
|
#[error("missing entry {0:?}")]
|
|
MissingEntry(&'static str),
|
|
#[error("entry {0:?} appears more than once")]
|
|
DuplicateEntry(&'static str),
|
|
#[error("entry {0:?} is not a regular file")]
|
|
NotARegularFile(&'static str),
|
|
#[error("unreadable entry {name:?}: {detail}")]
|
|
Unreadable { name: &'static str, detail: String },
|
|
#[error("{HASH_ENTRY} is malformed: {0}")]
|
|
MalformedHash(String),
|
|
#[error("hash mismatch: {HASH_ENTRY} says {claimed}, {CONFIG_ENTRY} is {actual} - the stick is corrupt or the config was edited without regenerating the hash")]
|
|
HashMismatch { claimed: String, actual: String },
|
|
#[error("{CONFIG_ENTRY} is not valid: {0}")]
|
|
InvalidConfig(String),
|
|
}
|
|
|
|
/// A config that passed every check and is safe to install.
|
|
struct Verified {
|
|
bytes: Vec<u8>,
|
|
sha256: String,
|
|
encrypted: bool,
|
|
/// `Some` only if a signature was checked and passed.
|
|
version: Option<u64>,
|
|
}
|
|
|
|
/// Rejects an entry name that could escape a directory it were joined onto.
|
|
///
|
|
/// Checked on the raw string rather than via `Path::components`, because the
|
|
/// platform's idea of a separator is the wrong test for an archive: a ZIP
|
|
/// made on Windows uses `\`, and on Linux `..\evil` is a legal single file
|
|
/// name that becomes a traversal the moment anything extracts it on the
|
|
/// other platform. Treating both as separators is stricter than needed on
|
|
/// Linux and costs nothing.
|
|
fn entry_name_is_safe(name: &str) -> bool {
|
|
if name.is_empty() || name.contains('\0') || name.starts_with('/') || name.starts_with('\\') {
|
|
return false;
|
|
}
|
|
// `C:foo` and `C:\foo`.
|
|
let mut chars = name.chars();
|
|
if let (Some(first), Some(':')) = (chars.next(), chars.next())
|
|
&& first.is_ascii_alphabetic()
|
|
{
|
|
return false;
|
|
}
|
|
!name.split(['/', '\\']).any(|component| component == "..")
|
|
}
|
|
|
|
/// Reads at most `limit` bytes, failing if the source has more. Separate from
|
|
/// the archive so the cap on *actual* output - the one that survives a lying
|
|
/// header - can be tested with a source that never ends.
|
|
fn read_bounded(source: impl Read, limit: u64) -> io::Result<Option<Vec<u8>>> {
|
|
let mut out = Vec::new();
|
|
source.take(limit + 1).read_to_end(&mut out)?;
|
|
Ok((out.len() as u64 <= limit).then_some(out))
|
|
}
|
|
|
|
pub fn sha256_hex(bytes: &[u8]) -> String {
|
|
openssl::sha::sha256(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
/// Accepts `sha256sum`'s own output (`<hex> config.toml`, with or without
|
|
/// the `*` binary marker) as well as a bare hex digest, because the first is
|
|
/// what an operator will actually produce and the second is what a script
|
|
/// will. A filename, if present, must name our config: a hash file copied
|
|
/// from some other project is a mistake worth catching.
|
|
fn parse_hash_file(text: &str) -> Result<String, BundleError> {
|
|
let mut tokens = text.split_whitespace();
|
|
let digest = tokens.next().ok_or_else(|| BundleError::MalformedHash("empty".into()))?;
|
|
if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
return Err(BundleError::MalformedHash(format!("expected 64 hex characters, got {digest:?}")));
|
|
}
|
|
if let Some(name) = tokens.next()
|
|
&& name.trim_start_matches('*') != CONFIG_ENTRY
|
|
{
|
|
return Err(BundleError::MalformedHash(format!("it hashes {name:?}, not {CONFIG_ENTRY:?}")));
|
|
}
|
|
if tokens.next().is_some() {
|
|
return Err(BundleError::MalformedHash("unexpected trailing text".into()));
|
|
}
|
|
Ok(digest.to_ascii_lowercase())
|
|
}
|
|
|
|
/// Everything that decides whether bytes from a stick are acceptable, with no
|
|
/// filesystem effects - so the whole policy is testable on in-memory ZIPs.
|
|
fn verify_bundle(archive: &[u8], policy: &BundlePolicy, installed_version: u64) -> Result<Verified, BundleError> {
|
|
let mut zip = zip::ZipArchive::new(io::Cursor::new(archive)).map_err(|e| BundleError::NotAnArchive(e.to_string()))?;
|
|
|
|
if zip.len() > MAX_ENTRIES {
|
|
return Err(BundleError::TooManyEntries(zip.len()));
|
|
}
|
|
|
|
// First pass: names and declared sizes of every entry, expected or not.
|
|
// Nothing is decompressed yet, so a bomb is refused before it costs
|
|
// anything.
|
|
let mut config_at = None;
|
|
let mut hash_at = None;
|
|
let mut sig_at = None;
|
|
// Whether each expected entry is AES-encrypted. Set in the loop below.
|
|
let mut encrypted_at = std::collections::HashMap::new();
|
|
for index in 0..zip.len() {
|
|
let entry = zip.by_index_raw(index).map_err(|e| BundleError::NotAnArchive(e.to_string()))?;
|
|
// A name that is not valid UTF-8 cannot be vetted component by
|
|
// component, so it is refused like any other unsafe name.
|
|
let Ok(name) = entry.name().map(|n| n.to_string()) else {
|
|
return Err(BundleError::UnsafeEntry(String::from_utf8_lossy(entry.name_raw()).into_owned()));
|
|
};
|
|
if !entry_name_is_safe(&name) {
|
|
return Err(BundleError::UnsafeEntry(name));
|
|
}
|
|
// Judged on the metadata, before anything is decrypted. `encrypted()`
|
|
// is set for ZipCrypto and AES alike; only AES also carries the AE-x
|
|
// extra field. Encrypted-without-it is ZipCrypto, and the whole
|
|
// bundle is refused - for *every* entry, expected or not, so no
|
|
// ZipCrypto decryptor ever runs on our input.
|
|
let encrypted = entry.encrypted();
|
|
let aes = entry.extra_data_fields().any(|f| matches!(f, zip::ExtraField::AeXEncryption(_)));
|
|
if encrypted && !aes {
|
|
return Err(BundleError::LegacyEncryption(name));
|
|
}
|
|
encrypted_at.insert(index, encrypted);
|
|
if entry.size() > MAX_ENTRY_BYTES {
|
|
return Err(BundleError::EntryTooLarge { size: entry.size(), name });
|
|
}
|
|
for (wanted, slot) in [(CONFIG_ENTRY, &mut config_at), (HASH_ENTRY, &mut hash_at), (SIG_ENTRY, &mut sig_at)] {
|
|
if name == wanted && slot.replace(index).is_some() {
|
|
return Err(BundleError::DuplicateEntry(wanted));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stage: decide encryption state. Every expected entry present must
|
|
// agree, and the policy must allow it, before any key is used.
|
|
let config_index = config_at.ok_or(BundleError::MissingEntry(CONFIG_ENTRY))?;
|
|
let hash_index = hash_at.ok_or(BundleError::MissingEntry(HASH_ENTRY))?;
|
|
let encrypted = encrypted_at[&config_index];
|
|
if [Some(hash_index), sig_at].into_iter().flatten().any(|i| encrypted_at[&i] != encrypted) {
|
|
return Err(BundleError::MixedEncryption);
|
|
}
|
|
if !encrypted && !policy.accept_unencrypted {
|
|
return Err(BundleError::UnencryptedRefused);
|
|
}
|
|
|
|
// Stage: read, bounded on the *output*. For an encrypted entry that is
|
|
// the decrypted plaintext, so a small ciphertext that inflates is capped
|
|
// exactly as a plaintext bomb is. The AES HMAC is only checked when the
|
|
// stream reaches its end, which `read_bounded` does for anything within
|
|
// the cap - an entry over the cap is rejected before that matters.
|
|
let mut read = |index: usize, name: &'static str, key: Option<&[u8]>| -> Result<Vec<u8>, BundleError> {
|
|
let entry = match key {
|
|
Some(key) => zip.by_index_decrypt(index, key),
|
|
None => zip.by_index(index),
|
|
}
|
|
.map_err(|e| match e {
|
|
zip::result::ZipError::InvalidPassword => BundleError::WrongKey,
|
|
other => BundleError::Unreadable { name, detail: other.to_string() },
|
|
})?;
|
|
if entry.is_dir() || entry.is_symlink() {
|
|
return Err(BundleError::NotARegularFile(name));
|
|
}
|
|
read_bounded(entry, MAX_ENTRY_BYTES)
|
|
.map_err(|e| {
|
|
if key.is_some() { BundleError::EncryptedIntegrity(name) } else { BundleError::Unreadable { name, detail: e.to_string() } }
|
|
})?
|
|
.ok_or_else(|| BundleError::EntryTooLarge { name: name.into(), size: MAX_ENTRY_BYTES + 1 })
|
|
};
|
|
|
|
// Stage: find the key. Each configured key is tried on the config entry
|
|
// and the first whose output passes the AES HMAC is used for the rest -
|
|
// that is the rotation window. The error kept if none works is the most
|
|
// informative one seen: an HMAC failure means a key got past the 16-bit
|
|
// password check, so the data (not only the key) is suspect.
|
|
let (config, key) = if encrypted {
|
|
if policy.keys.is_empty() {
|
|
return Err(BundleError::NoKey);
|
|
}
|
|
let mut failure = BundleError::WrongKey;
|
|
let mut found = None;
|
|
for candidate in &policy.keys {
|
|
match read(config_index, CONFIG_ENTRY, Some(&candidate.0)) {
|
|
Ok(bytes) => {
|
|
found = Some((bytes, Some(candidate.0.as_slice())));
|
|
break;
|
|
}
|
|
Err(BundleError::WrongKey) => {}
|
|
Err(err @ BundleError::EncryptedIntegrity(_)) => failure = err,
|
|
Err(other) => return Err(other),
|
|
}
|
|
}
|
|
found.ok_or(failure)?
|
|
} else {
|
|
(read(config_index, CONFIG_ENTRY, None)?, None)
|
|
};
|
|
let hash_text = read(hash_index, HASH_ENTRY, key)?;
|
|
// Only read when signatures are required. A box with no signing key
|
|
// ignores the entry rather than decompressing something it cannot use.
|
|
let sig_text = match (policy.signing_keys.is_empty(), sig_at) {
|
|
(true, _) => None,
|
|
(false, None) => None,
|
|
(false, Some(index)) => Some(read(index, SIG_ENTRY, key)?),
|
|
};
|
|
|
|
// The hash is checked before the bytes are parsed or trusted for
|
|
// anything: the TOML parser should not be the first thing to look at
|
|
// possibly-corrupt input. (Corruption only - see the module docs.)
|
|
let claimed = parse_hash_file(&String::from_utf8_lossy(&hash_text))?;
|
|
let actual = sha256_hex(&config);
|
|
if claimed != actual {
|
|
return Err(BundleError::HashMismatch { claimed, actual });
|
|
}
|
|
|
|
let version = check_authenticity(&config, sig_text.as_deref(), policy, installed_version)?;
|
|
|
|
// Validated as the box will actually run it: with no environment. On a
|
|
// box the file has to be complete by itself, so a config that parses but
|
|
// lacks `gateway_id` is refused now rather than installed and then
|
|
// failing to start on the next boot - which would be a bricked box
|
|
// caused by exactly the stick we were being careful about.
|
|
let text = std::str::from_utf8(&config).map_err(|e| BundleError::InvalidConfig(e.to_string()))?;
|
|
let parsed = ProxyFile::parse(text).map_err(|e| BundleError::InvalidConfig(e.to_string()))?;
|
|
Config::layered(Some(&parsed), &|_| None).map_err(|e| BundleError::InvalidConfig(e.to_string()))?;
|
|
|
|
Ok(Verified { bytes: config, sha256: actual, encrypted, version })
|
|
}
|
|
|
|
/// Authenticity: was this config issued by Continuum Cloud, for this box, and
|
|
/// is it newer than what is installed? Runs after the hash (cheap damage
|
|
/// check first) and before the config is parsed or believed.
|
|
///
|
|
/// Returns the signed `config_version` if a signature was checked, `None`
|
|
/// when the box has no signing key and so cannot check one.
|
|
///
|
|
/// Order within: signature, then box id, then version. The claims in
|
|
/// `config.toml.sig` are attacker-controlled text until the signature over
|
|
/// them verifies, so nothing is concluded from them - and nothing about this
|
|
/// box's identity is revealed - before then.
|
|
fn check_authenticity(
|
|
config: &[u8],
|
|
sig_text: Option<&[u8]>,
|
|
policy: &BundlePolicy,
|
|
installed_version: u64,
|
|
) -> Result<Option<u64>, 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<Vec<u8>> {
|
|
let text = text.trim();
|
|
if !text.len().is_multiple_of(2) || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
return None;
|
|
}
|
|
(0..text.len()).step_by(2).map(|i| u8::from_str_radix(&text[i..i + 2], 16).ok()).collect()
|
|
}
|
|
|
|
/// Where the installed config's version is recorded: beside it, so it moves
|
|
/// with the config's directory and needs no other writable place.
|
|
fn version_path(installed: &Path) -> PathBuf {
|
|
let mut name = installed.as_os_str().to_owned();
|
|
name.push(".version");
|
|
PathBuf::from(name)
|
|
}
|
|
|
|
/// Missing means 0 (nothing signed has been installed). An unreadable or
|
|
/// non-numeric file is also treated as 0, with a warning: failing closed
|
|
/// would make a damaged sidecar a permanent block on every update, and
|
|
/// whoever can damage it can already edit the config.
|
|
fn read_installed_version(installed: &Path) -> u64 {
|
|
match fs::read_to_string(version_path(installed)) {
|
|
Ok(text) => text.trim().parse().unwrap_or_else(|_| {
|
|
tracing::warn!(path = ?version_path(installed), "installed config version is not a number; treating it as 0");
|
|
0
|
|
}),
|
|
Err(_) => 0,
|
|
}
|
|
}
|
|
|
|
/// Replaces `dest` with `bytes` so that no observer, and no crash, ever sees
|
|
/// anything but the old contents or the new ones.
|
|
pub fn install_atomically(dest: &Path, bytes: &[u8]) -> io::Result<()> {
|
|
install_atomically_with(dest, bytes, |file, bytes| file.write_all(bytes))
|
|
}
|
|
|
|
/// `install_atomically` with the write step injectable, so a test can make it
|
|
/// fail halfway the way a pulled stick or a full disk would - the situation
|
|
/// the atomicity exists for, and not one a happy-path test ever reaches.
|
|
fn install_atomically_with(
|
|
dest: &Path,
|
|
bytes: &[u8],
|
|
write: impl FnOnce(&mut fs::File, &[u8]) -> io::Result<()>,
|
|
) -> io::Result<()> {
|
|
let dir = match dest.parent() {
|
|
Some(p) if !p.as_os_str().is_empty() => p,
|
|
_ => Path::new("."),
|
|
};
|
|
let name = dest
|
|
.file_name()
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "destination has no file name"))?;
|
|
fs::create_dir_all(dir)?;
|
|
|
|
let mut temp_name = std::ffi::OsString::from(".");
|
|
temp_name.push(name);
|
|
temp_name.push(format!(".tmp-{}", std::process::id()));
|
|
let temp = dir.join(temp_name);
|
|
// A leftover from a previous crash with a recycled pid would make
|
|
// `create_new` fail forever; it is ours by name, so clear it.
|
|
let _ = fs::remove_file(&temp);
|
|
|
|
let result = (|| {
|
|
let mut options = fs::OpenOptions::new();
|
|
options.write(true).create_new(true);
|
|
// The config holds the gateway token (and maybe a wifi password), so
|
|
// it is owner-only from the first byte rather than chmod'd after.
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
|
let mut file = options.open(&temp)?;
|
|
write(&mut file, bytes)?;
|
|
// Data to disk before the rename makes it visible: without this a
|
|
// power cut can leave a renamed-but-empty file on some filesystems.
|
|
file.sync_all()?;
|
|
drop(file);
|
|
fs::rename(&temp, dest)
|
|
})();
|
|
|
|
if result.is_err() {
|
|
let _ = fs::remove_file(&temp);
|
|
} else if let Ok(dir_handle) = fs::File::open(dir) {
|
|
// Best effort: persists the rename itself. Failure here does not
|
|
// undo it, so it is not reported as an install failure.
|
|
let _ = dir_handle.sync_all();
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Verifies one bundle and installs it if it differs from `installed`.
|
|
/// Never panics and never returns an error: every outcome, including every
|
|
/// refusal, is a value the caller reports, and none of them touch the
|
|
/// installed config except `Installed`.
|
|
pub fn import_bundle(bundle: &Path, installed: &Path, policy: &BundlePolicy) -> Outcome {
|
|
let reject = |reason: String| Outcome::Rejected { bundle: bundle.to_path_buf(), reason };
|
|
|
|
let archive = match read_bundle_file(bundle) {
|
|
Ok(bytes) => bytes,
|
|
Err(err) => return reject(format!("could not read the bundle: {err}")),
|
|
};
|
|
let installed_version = read_installed_version(installed);
|
|
let verified = match verify_bundle(&archive, policy, installed_version) {
|
|
Ok(v) => v,
|
|
Err(err) => return reject(err.to_string()),
|
|
};
|
|
|
|
// Compared against the bytes actually on disk, not a remembered hash:
|
|
// if someone did edit the installed file, the next stick restores it,
|
|
// which is the "changed only by a new pendrive" rule enforced and not
|
|
// just asserted. An unreadable or missing installed file counts as
|
|
// different.
|
|
if fs::read(installed).is_ok_and(|current| sha256_hex(¤t) == verified.sha256) {
|
|
// Same config re-issued at a higher version: record the version, or
|
|
// an older-but-different config at a version in between would later
|
|
// pass the replay check.
|
|
if let Some(version) = verified.version
|
|
&& version > installed_version
|
|
&& let Err(err) = install_atomically(&version_path(installed), version.to_string().as_bytes())
|
|
{
|
|
return reject(format!("could not record config version {version}: {err}"));
|
|
}
|
|
return Outcome::Unchanged { bundle: bundle.to_path_buf() };
|
|
}
|
|
|
|
// Version before config, so a crash between them leaves "new version, old
|
|
// config" - which re-inserting the same stick repairs, since an equal
|
|
// version is accepted - and never "new config, old version", which would
|
|
// leave a replay window open.
|
|
if let Some(version) = verified.version
|
|
&& let Err(err) = install_atomically(&version_path(installed), version.to_string().as_bytes())
|
|
{
|
|
return reject(format!("could not record config version {version}: {err}"));
|
|
}
|
|
match install_atomically(installed, &verified.bytes) {
|
|
Ok(()) => Outcome::Installed {
|
|
bundle: bundle.to_path_buf(),
|
|
sha256: verified.sha256,
|
|
encrypted: verified.encrypted,
|
|
signed: verified.version.is_some(),
|
|
},
|
|
Err(err) => reject(format!("could not install into {installed:?}: {err}")),
|
|
}
|
|
}
|
|
|
|
fn read_bundle_file(path: &Path) -> io::Result<Vec<u8>> {
|
|
// `symlink_metadata`, not `metadata`: do not follow a link off the stick.
|
|
let meta = fs::symlink_metadata(path)?;
|
|
if !meta.is_file() {
|
|
return Err(io::Error::other("not a regular file"));
|
|
}
|
|
read_bounded(fs::File::open(path)?, MAX_BUNDLE_BYTES)?
|
|
.ok_or_else(|| io::Error::other(format!("larger than {MAX_BUNDLE_BYTES} bytes")))
|
|
}
|
|
|
|
/// Bundles under `roots`, looking at `root`, `root/*` and `root/*/*` - the
|
|
/// shapes of `/mnt/stick`, `/media/LABEL` and `/media/<user>/LABEL`, which
|
|
/// between them are where udisks and a hand `mount` put a stick. Sorted, so
|
|
/// two sticks are always tried in the same order.
|
|
fn find_bundles(roots: &[PathBuf]) -> Vec<PathBuf> {
|
|
fn subdirs(dir: &Path) -> Vec<PathBuf> {
|
|
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
|
|
// `file_type` does not follow links, so a symlinked directory is not
|
|
// descended into.
|
|
entries
|
|
.flatten()
|
|
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
|
.map(|e| e.path())
|
|
.collect()
|
|
}
|
|
|
|
let mut found = Vec::new();
|
|
for root in roots {
|
|
let mut dirs = vec![root.clone()];
|
|
let level1 = subdirs(root);
|
|
dirs.extend(level1.iter().flat_map(|d| subdirs(d)));
|
|
dirs.extend(level1);
|
|
for dir in dirs {
|
|
let candidate = dir.join(BUNDLE_NAME);
|
|
if fs::symlink_metadata(&candidate).is_ok() {
|
|
found.push(candidate);
|
|
}
|
|
}
|
|
}
|
|
found.sort();
|
|
found.dedup();
|
|
found
|
|
}
|
|
|
|
/// One pass over every configured medium. Returns what happened to each
|
|
/// bundle; stops after the first install, since a second stick would
|
|
/// otherwise immediately overwrite the first with whichever sorts last.
|
|
///
|
|
/// This is the gate for PC mode: anything but a Proxy Box returns
|
|
/// `Disabled` without opening a directory.
|
|
pub fn import_from_pendrives(bootstrap: &Bootstrap, policy: &BundlePolicy) -> Vec<Outcome> {
|
|
if bootstrap.mode != Mode::ProxyBox || bootstrap.pendrive_dirs.is_empty() {
|
|
return vec![Outcome::Disabled];
|
|
}
|
|
let bundles = find_bundles(&bootstrap.pendrive_dirs);
|
|
if bundles.is_empty() {
|
|
return vec![Outcome::NoBundle];
|
|
}
|
|
let mut outcomes = Vec::new();
|
|
for bundle in bundles {
|
|
let outcome = import_bundle(&bundle, &bootstrap.config_path, policy);
|
|
let installed = matches!(outcome, Outcome::Installed { .. });
|
|
outcomes.push(outcome);
|
|
if installed {
|
|
break;
|
|
}
|
|
}
|
|
outcomes
|
|
}
|
|
|
|
pub fn log_outcome(outcome: &Outcome) {
|
|
match outcome {
|
|
Outcome::Disabled | Outcome::NoBundle => {}
|
|
Outcome::Unchanged { bundle } => {
|
|
tracing::info!(bundle = %bundle.display(), "config bundle matches the installed config; nothing to do");
|
|
}
|
|
Outcome::Installed { bundle, sha256, encrypted, signed } => {
|
|
tracing::warn!(bundle = %bundle.display(), %sha256, "installed a new config from the pendrive; a restart is needed to use it");
|
|
// Visible on purpose: an unencrypted bundle is accepted, but
|
|
// it is the weaker state (token readable by anyone holding the
|
|
// stick, and no tamper discouragement at all).
|
|
if !encrypted {
|
|
tracing::warn!(bundle = %bundle.display(), "the installed bundle was NOT encrypted");
|
|
}
|
|
if !signed {
|
|
tracing::warn!(bundle = %bundle.display(), "the installed bundle was NOT signature-checked: this box has no signing key");
|
|
}
|
|
}
|
|
// `error`, not `warn`: someone inserted a stick expecting something
|
|
// to happen, and "nothing happened" must not be silent.
|
|
Outcome::Rejected { bundle, reason } => {
|
|
tracing::error!(
|
|
bundle = %bundle.display(),
|
|
%reason,
|
|
"REJECTED the config bundle; the box keeps running on its existing config"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Polls for a stick while the proxy runs. Returns only after installing a
|
|
/// new config, because settings are read once at startup: the caller is
|
|
/// expected to let the process exit so its supervisor (systemd
|
|
/// `Restart=always` on a box) starts it on the new config. Never returns in
|
|
/// PC mode.
|
|
///
|
|
/// Polling, not inotify/udev: a mount appearing under `/media` is not a
|
|
/// reliable inotify event across desktop and headless setups, and a 5s scan
|
|
/// of a handful of directories is cheap. A rejected stick is reported once
|
|
/// per distinct reason, not every five seconds for as long as it stays in.
|
|
pub async fn watch(bootstrap: Bootstrap, policy: BundlePolicy) {
|
|
if bootstrap.mode != Mode::ProxyBox {
|
|
std::future::pending::<()>().await;
|
|
}
|
|
let mut reported: HashSet<(PathBuf, String)> = HashSet::new();
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
let (scan, policy) = (bootstrap.clone(), policy.clone());
|
|
let Ok(outcomes) = tokio::task::spawn_blocking(move || import_from_pendrives(&scan, &policy)).await else {
|
|
continue;
|
|
};
|
|
for outcome in &outcomes {
|
|
if let Outcome::Rejected { bundle, reason } = outcome
|
|
&& !reported.insert((bundle.clone(), reason.clone()))
|
|
{
|
|
continue;
|
|
}
|
|
log_outcome(outcome);
|
|
}
|
|
if outcomes.iter().any(|o| matches!(o, Outcome::Installed { .. })) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const GOOD: &str = "gateway_id = \"g\"\nfarm_id = \"f\"\ngateway_token = \"t\"\n";
|
|
|
|
/// A self-deleting scratch directory, as in tests/discovery_to_fleet.rs -
|
|
/// a dependency for a dozen lines would be the wrong trade here.
|
|
struct Scratch(PathBuf);
|
|
|
|
impl Scratch {
|
|
fn new() -> Self {
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
|
let path = std::env::temp_dir()
|
|
.join(format!("continuum-import-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
|
fs::create_dir_all(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
fn path(&self, rel: &str) -> PathBuf {
|
|
self.0.join(rel)
|
|
}
|
|
}
|
|
|
|
impl Drop for Scratch {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn zip_of(entries: &[(&str, &[u8])], method: zip::CompressionMethod) -> Vec<u8> {
|
|
let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
|
|
let opts = zip::write::SimpleFileOptions::default().compression_method(method);
|
|
for (name, body) in entries {
|
|
w.start_file(*name, opts).unwrap();
|
|
w.write_all(body).unwrap();
|
|
}
|
|
w.finish().unwrap().into_inner()
|
|
}
|
|
|
|
fn stored(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
|
zip_of(entries, zip::CompressionMethod::Stored)
|
|
}
|
|
|
|
fn good_bundle(config: &str) -> Vec<u8> {
|
|
let hash = format!("{} config.toml\n", sha256_hex(config.as_bytes()));
|
|
stored(&[(CONFIG_ENTRY, config.as_bytes()), (HASH_ENTRY, hash.as_bytes())])
|
|
}
|
|
|
|
fn rejection(archive: &[u8]) -> String {
|
|
verify_bundle(archive, &BundlePolicy::unencrypted_only(), 0).err().expect("bundle should be rejected").to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_whose_hash_matches_is_accepted() {
|
|
let verified = verify_bundle(&good_bundle(GOOD), &BundlePolicy::unencrypted_only(), 0).unwrap();
|
|
assert_eq!(verified.bytes, GOOD.as_bytes());
|
|
assert_eq!(verified.sha256, sha256_hex(GOOD.as_bytes()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_bare_hex_digest_with_no_filename_is_also_accepted() {
|
|
let hash = sha256_hex(GOOD.as_bytes());
|
|
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())]);
|
|
assert!(verify_bundle(&archive, &BundlePolicy::unencrypted_only(), 0).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn a_config_edited_after_its_hash_was_made_is_rejected_as_a_mismatch() {
|
|
let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes()));
|
|
let edited = GOOD.replace("\"g\"", "\"other\"");
|
|
let archive = stored(&[(CONFIG_ENTRY, edited.as_bytes()), (HASH_ENTRY, hash.as_bytes())]);
|
|
assert!(rejection(&archive).contains("hash mismatch"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_hash_file_that_names_some_other_file_is_rejected() {
|
|
let hash = format!("{} other.toml", sha256_hex(GOOD.as_bytes()));
|
|
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())]);
|
|
assert!(rejection(&archive).contains("other.toml"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_hash_that_is_not_sixty_four_hex_characters_is_rejected() {
|
|
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, b"deadbeef")]);
|
|
assert!(rejection(&archive).contains("malformed"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_missing_its_hash_is_rejected_rather_than_trusted() {
|
|
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes())]);
|
|
assert!(rejection(&archive).contains("missing entry"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_correctly_hashed_config_that_is_not_valid_toml_is_rejected() {
|
|
assert!(rejection(&good_bundle("this is = = not toml")).contains("not valid"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_correctly_hashed_config_missing_required_settings_is_rejected_so_it_cannot_brick_the_next_boot() {
|
|
assert!(rejection(&good_bundle("uplink_url = \"wss://x\"\n")).contains("gateway_id"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_correctly_hashed_config_with_a_misspelled_key_is_rejected() {
|
|
let config = format!("{GOOD}uplink_ulr = \"x\"\n");
|
|
assert!(rejection(&good_bundle(&config)).contains("uplink_ulr"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_that_is_not_a_zip_is_rejected() {
|
|
assert!(rejection(b"definitely not a zip").contains("not a readable ZIP"));
|
|
}
|
|
|
|
#[test]
|
|
fn entry_names_that_could_escape_the_target_directory_are_all_unsafe() {
|
|
for name in ["/etc/passwd", "../evil", "a/../../evil", "a/..", "..\\evil", "\\abs", "C:\\evil", "C:evil", ""] {
|
|
assert!(!entry_name_is_safe(name), "{name:?} should be unsafe");
|
|
}
|
|
for name in ["config.toml", "dir/config.toml", "..config", "a..b/c", "__MACOSX/._x"] {
|
|
assert!(entry_name_is_safe(name), "{name:?} should be safe");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_zip_slip_entry_rejects_the_whole_bundle_even_when_the_rest_is_valid() {
|
|
let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes()));
|
|
let archive = stored(&[
|
|
(CONFIG_ENTRY, GOOD.as_bytes()),
|
|
(HASH_ENTRY, hash.as_bytes()),
|
|
("../../../../tmp/evil.toml", b"pwned"),
|
|
]);
|
|
assert!(rejection(&archive).contains("unsafe entry"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_absolute_path_entry_rejects_the_bundle() {
|
|
let archive = stored(&[("/etc/cron.d/evil", b"x")]);
|
|
assert!(rejection(&archive).contains("unsafe entry"));
|
|
}
|
|
|
|
#[test]
|
|
fn importing_a_zip_slip_bundle_writes_nothing_outside_the_target_and_keeps_the_old_config() {
|
|
let scratch = Scratch::new();
|
|
let installed = scratch.path("box/config.toml");
|
|
fs::create_dir_all(installed.parent().unwrap()).unwrap();
|
|
fs::write(&installed, GOOD).unwrap();
|
|
|
|
let hash = format!("{} config.toml", sha256_hex(b"new"));
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, stored(&[(CONFIG_ENTRY, b"new"), (HASH_ENTRY, hash.as_bytes()), ("../evil.toml", b"pwned")])).unwrap();
|
|
|
|
assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. }));
|
|
assert!(!scratch.path("evil.toml").exists(), "a file escaped the target directory");
|
|
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_with_too_many_entries_is_rejected() {
|
|
let names: Vec<String> = (0..=MAX_ENTRIES).map(|i| format!("junk{i}")).collect();
|
|
let entries: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), &b"x"[..])).collect();
|
|
assert!(rejection(&stored(&entries)).contains("entries"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_highly_compressed_entry_that_would_inflate_past_the_cap_is_rejected_before_it_is_inflated() {
|
|
let bomb = vec![0u8; (MAX_ENTRY_BYTES as usize) * 40];
|
|
let archive = zip_of(&[(CONFIG_ENTRY, &bomb), (HASH_ENTRY, b"x")], zip::CompressionMethod::Deflated);
|
|
assert!(archive.len() < 64 * 1024, "the fixture should be small on disk: {}", archive.len());
|
|
assert!(rejection(&archive).contains("at most"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_bounded_reader_stops_an_endless_source_instead_of_filling_memory() {
|
|
// The cap that survives a header which lies about the size.
|
|
assert_eq!(read_bounded(io::repeat(0), 1000).unwrap(), None);
|
|
assert_eq!(read_bounded(&b"abc"[..], 1000).unwrap(), Some(b"abc".to_vec()));
|
|
}
|
|
|
|
#[test]
|
|
fn an_oversized_file_on_the_stick_is_refused_without_being_read_whole() {
|
|
let scratch = Scratch::new();
|
|
let bundle = scratch.path("big.zip");
|
|
fs::write(&bundle, vec![0u8; MAX_BUNDLE_BYTES as usize + 1]).unwrap();
|
|
assert!(matches!(import_bundle(&bundle, &scratch.path("config.toml"), &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn a_new_config_is_installed_when_none_exists_yet() {
|
|
let scratch = Scratch::new();
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, good_bundle(GOOD)).unwrap();
|
|
let installed = scratch.path("var/lib/continuum/config.toml");
|
|
|
|
assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Installed { .. }));
|
|
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn the_installed_config_is_readable_only_by_its_owner() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let scratch = Scratch::new();
|
|
let installed = scratch.path("config.toml");
|
|
install_atomically(&installed, b"secret").unwrap();
|
|
assert_eq!(fs::metadata(&installed).unwrap().permissions().mode() & 0o777, 0o600);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_identical_to_the_installed_config_changes_nothing() {
|
|
let scratch = Scratch::new();
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, good_bundle(GOOD)).unwrap();
|
|
let installed = scratch.path("config.toml");
|
|
fs::write(&installed, GOOD).unwrap();
|
|
let before = fs::metadata(&installed).unwrap().modified().unwrap();
|
|
|
|
assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Unchanged { .. }));
|
|
assert_eq!(fs::metadata(&installed).unwrap().modified().unwrap(), before, "an unchanged config was rewritten");
|
|
}
|
|
|
|
#[test]
|
|
fn a_config_hand_edited_on_the_box_is_restored_by_the_next_stick() {
|
|
let scratch = Scratch::new();
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, good_bundle(GOOD)).unwrap();
|
|
let installed = scratch.path("config.toml");
|
|
fs::write(&installed, format!("{GOOD}uplink_url = \"wss://tampered\"\n")).unwrap();
|
|
|
|
assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Installed { .. }));
|
|
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
|
|
}
|
|
|
|
#[test]
|
|
fn a_pendrive_yanked_mid_copy_leaves_the_old_config_intact() {
|
|
let scratch = Scratch::new();
|
|
let installed = scratch.path("config.toml");
|
|
fs::write(&installed, "old config").unwrap();
|
|
|
|
let result = install_atomically_with(&installed, b"the whole new config", |file, bytes| {
|
|
file.write_all(&bytes[..5])?;
|
|
Err(io::Error::other("stick removed"))
|
|
});
|
|
|
|
assert!(result.is_err());
|
|
assert_eq!(fs::read_to_string(&installed).unwrap(), "old config");
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_install_leaves_no_temp_file_behind() {
|
|
let scratch = Scratch::new();
|
|
let installed = scratch.path("config.toml");
|
|
let _ = install_atomically_with(&installed, b"new", |_, _| Err(io::Error::other("disk full")));
|
|
|
|
let leftovers: Vec<_> = fs::read_dir(&scratch.0).unwrap().flatten().collect();
|
|
assert!(leftovers.is_empty(), "left behind: {leftovers:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_rejected_bundle_never_touches_the_installed_config() {
|
|
let scratch = Scratch::new();
|
|
let installed = scratch.path("config.toml");
|
|
fs::write(&installed, GOOD).unwrap();
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, good_bundle("not = = toml")).unwrap();
|
|
|
|
assert!(matches!(import_bundle(&bundle, &installed, &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. }));
|
|
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
|
|
}
|
|
|
|
fn box_with_stick(scratch: &Scratch, mode: Mode) -> Bootstrap {
|
|
let stick = scratch.path("media/USB");
|
|
fs::create_dir_all(&stick).unwrap();
|
|
fs::write(stick.join(BUNDLE_NAME), good_bundle(GOOD)).unwrap();
|
|
Bootstrap {
|
|
mode,
|
|
config_path: scratch.path("installed.toml"),
|
|
config_path_is_explicit: true,
|
|
pendrive_dirs: vec![scratch.path("media")],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_proxy_box_finds_a_stick_mounted_one_level_down_and_installs_from_it() {
|
|
let scratch = Scratch::new();
|
|
let bootstrap = box_with_stick(&scratch, Mode::ProxyBox);
|
|
let outcomes = import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only());
|
|
assert!(matches!(outcomes.as_slice(), [Outcome::Installed { .. }]), "{outcomes:?}");
|
|
assert_eq!(fs::read_to_string(scratch.path("installed.toml")).unwrap(), GOOD);
|
|
}
|
|
|
|
#[test]
|
|
fn a_proxy_box_finds_a_stick_at_the_media_user_label_depth() {
|
|
let scratch = Scratch::new();
|
|
let deep = scratch.path("media/pi/LABEL");
|
|
fs::create_dir_all(&deep).unwrap();
|
|
fs::write(deep.join(BUNDLE_NAME), good_bundle(GOOD)).unwrap();
|
|
let bootstrap = Bootstrap {
|
|
mode: Mode::ProxyBox,
|
|
config_path: scratch.path("installed.toml"),
|
|
config_path_is_explicit: true,
|
|
pendrive_dirs: vec![scratch.path("media")],
|
|
};
|
|
assert!(matches!(import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()).as_slice(), [Outcome::Installed { .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn pc_mode_ignores_a_pendrive_even_when_one_with_a_valid_bundle_is_mounted() {
|
|
let scratch = Scratch::new();
|
|
let mut bootstrap = box_with_stick(&scratch, Mode::Pc);
|
|
// Force the dirs back in, as if some future code path forgot that
|
|
// `Bootstrap` already empties them: the mode check must hold alone.
|
|
bootstrap.pendrive_dirs = vec![scratch.path("media")];
|
|
|
|
assert_eq!(import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()), vec![Outcome::Disabled]);
|
|
assert!(!scratch.path("installed.toml").exists(), "PC mode imported from removable media");
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_pendrive_directory_is_not_an_error() {
|
|
let scratch = Scratch::new();
|
|
fs::create_dir_all(scratch.path("media")).unwrap();
|
|
let bootstrap = Bootstrap {
|
|
mode: Mode::ProxyBox,
|
|
config_path: scratch.path("installed.toml"),
|
|
config_path_is_explicit: true,
|
|
pendrive_dirs: vec![scratch.path("media")],
|
|
};
|
|
assert_eq!(import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only()), vec![Outcome::NoBundle]);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_bundle_that_is_a_symlink_is_not_followed() {
|
|
let scratch = Scratch::new();
|
|
let real = scratch.path("real.zip");
|
|
fs::write(&real, good_bundle(GOOD)).unwrap();
|
|
let link = scratch.path("link.zip");
|
|
std::os::unix::fs::symlink(&real, &link).unwrap();
|
|
assert!(matches!(import_bundle(&link, &scratch.path("c.toml"), &BundlePolicy::unencrypted_only()), Outcome::Rejected { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn a_bad_stick_is_reported_and_the_box_keeps_the_config_it_had() {
|
|
let scratch = Scratch::new();
|
|
let bootstrap = box_with_stick(&scratch, Mode::ProxyBox);
|
|
fs::write(&bootstrap.config_path, "previous").unwrap();
|
|
fs::write(scratch.path("media/USB").join(BUNDLE_NAME), b"garbage").unwrap();
|
|
|
|
let outcomes = import_from_pendrives(&bootstrap, &BundlePolicy::unencrypted_only());
|
|
assert!(matches!(outcomes.as_slice(), [Outcome::Rejected { .. }]));
|
|
assert_eq!(fs::read_to_string(&bootstrap.config_path).unwrap(), "previous");
|
|
}
|
|
|
|
// ---- encrypted bundles -------------------------------------------------
|
|
//
|
|
// Fixtures are written by the `zip` crate's own AES writer
|
|
// (`with_aes_encryption`), so they are real WinZip-AES archives, not
|
|
// hand-rolled bytes. The one exception is the ZipCrypto fixture, which
|
|
// the crate will not write without its `unstable` feature: it is made by
|
|
// setting the "encrypted" flag on a plain archive's headers, which is
|
|
// exactly the shape this code must refuse on metadata alone.
|
|
|
|
const KEY: &[u8] = b"correct horse battery staple";
|
|
|
|
fn aes_zip(entries: &[(&str, &[u8])], key: &[u8]) -> Vec<u8> {
|
|
let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
|
|
for (name, body) in entries {
|
|
let opts = zip::write::SimpleFileOptions::default()
|
|
.compression_method(zip::CompressionMethod::Stored)
|
|
.with_aes_encryption_bytes(zip::AesMode::Aes256, key);
|
|
w.start_file(*name, opts).unwrap();
|
|
w.write_all(body).unwrap();
|
|
}
|
|
w.finish().unwrap().into_inner()
|
|
}
|
|
|
|
fn encrypted_bundle(config: &str, key: &[u8]) -> Vec<u8> {
|
|
let hash = format!("{} config.toml\n", sha256_hex(config.as_bytes()));
|
|
aes_zip(&[(CONFIG_ENTRY, config.as_bytes()), (HASH_ENTRY, hash.as_bytes())], key)
|
|
}
|
|
|
|
fn keyed(key: &[u8]) -> BundlePolicy {
|
|
BundlePolicy { keys: vec![BundleKey::new(key)], ..BundlePolicy::unencrypted_only() }
|
|
}
|
|
|
|
fn rejection_with(archive: &[u8], policy: &BundlePolicy) -> String {
|
|
verify_bundle(archive, policy, 0).err().expect("bundle should be rejected").to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn an_aes_encrypted_bundle_opens_with_the_right_key_and_is_marked_encrypted() {
|
|
let verified = verify_bundle(&encrypted_bundle(GOOD, KEY), &keyed(KEY), 0).unwrap();
|
|
assert_eq!(verified.bytes, GOOD.as_bytes());
|
|
assert!(verified.encrypted);
|
|
}
|
|
|
|
#[test]
|
|
fn the_encrypted_fixture_really_does_hide_the_config_text() {
|
|
let archive = encrypted_bundle(GOOD, KEY);
|
|
assert!(!archive.windows(b"gateway_token".len()).any(|w| w == b"gateway_token"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bundle_on_a_box_with_no_key_says_it_is_not_for_this_box() {
|
|
let reason = rejection_with(&encrypted_bundle(GOOD, KEY), &BundlePolicy::unencrypted_only());
|
|
assert!(reason.contains("no bundle key") && reason.contains("not for this box"), "{reason}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bundle_made_for_a_different_key_says_it_is_not_for_this_box() {
|
|
let reason = rejection_with(&encrypted_bundle(GOOD, KEY), &keyed(b"some other box's key"));
|
|
// The 16-bit password check lets 1 wrong key in 65536 through to the
|
|
// HMAC, which then says so itself; either wording names the key.
|
|
assert!(reason.contains("not for this box") || reason.contains("key is wrong"), "{reason}");
|
|
assert!(!reason.contains("hash mismatch"), "a wrong key must not look like a corrupt stick: {reason}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_flipped_ciphertext_byte_is_caught_by_the_aes_integrity_check_not_trusted() {
|
|
let mut archive = encrypted_bundle(GOOD, KEY);
|
|
// First entry's data starts after the local header (30 bytes + name +
|
|
// extra); skip the 16-byte AES-256 salt and 2-byte password check.
|
|
let name_len = u16::from_le_bytes([archive[26], archive[27]]) as usize;
|
|
let extra_len = u16::from_le_bytes([archive[28], archive[29]]) as usize;
|
|
archive[30 + name_len + extra_len + 18] ^= 0xff;
|
|
|
|
let reason = rejection_with(&archive, &keyed(KEY));
|
|
assert!(reason.contains("AES integrity check"), "{reason}");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_zipcrypto_is_refused_even_when_a_key_is_configured() {
|
|
// Flag bit 0 = encrypted, with no AE-x extra field: that is ZipCrypto.
|
|
let mut archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, b"x")]);
|
|
let mut at = 0;
|
|
while at + 4 <= archive.len() {
|
|
match &archive[at..at + 4] {
|
|
b"PK\x03\x04" => archive[at + 6] |= 1,
|
|
b"PK\x01\x02" => archive[at + 8] |= 1,
|
|
_ => {}
|
|
}
|
|
at += 1;
|
|
}
|
|
let reason = rejection_with(&archive, &keyed(KEY));
|
|
assert!(reason.contains("ZipCrypto"), "{reason}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_with_one_encrypted_entry_and_one_plain_entry_is_rejected() {
|
|
let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes()));
|
|
let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
|
|
let plain = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
|
w.start_file(CONFIG_ENTRY, plain.with_aes_encryption_bytes(zip::AesMode::Aes256, KEY)).unwrap();
|
|
w.write_all(GOOD.as_bytes()).unwrap();
|
|
w.start_file(HASH_ENTRY, plain).unwrap();
|
|
w.write_all(hash.as_bytes()).unwrap();
|
|
let archive = w.finish().unwrap().into_inner();
|
|
|
|
assert!(rejection_with(&archive, &keyed(KEY)).contains("not encrypted alike"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bundle_that_decrypts_to_a_wrong_hash_is_rejected_like_a_plaintext_one() {
|
|
let hash = format!("{} config.toml", sha256_hex(b"something else"));
|
|
let archive = aes_zip(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())], KEY);
|
|
assert!(rejection_with(&archive, &keyed(KEY)).contains("hash mismatch"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bundle_that_decrypts_to_invalid_toml_is_rejected() {
|
|
assert!(rejection_with(&encrypted_bundle("not = = toml", KEY), &keyed(KEY)).contains("not valid"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bundle_with_a_zip_slip_entry_is_rejected_before_any_decryption() {
|
|
let archive = aes_zip(&[("../../evil", b"x"), (CONFIG_ENTRY, GOOD.as_bytes())], KEY);
|
|
// Rejected with no key at all: the name check is on metadata.
|
|
assert!(rejection_with(&archive, &BundlePolicy::unencrypted_only()).contains("unsafe entry"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bomb_is_rejected_by_its_size_before_it_is_decrypted() {
|
|
let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
|
|
let opts = zip::write::SimpleFileOptions::default()
|
|
.compression_method(zip::CompressionMethod::Deflated)
|
|
.with_aes_encryption_bytes(zip::AesMode::Aes256, KEY);
|
|
w.start_file(CONFIG_ENTRY, opts).unwrap();
|
|
w.write_all(&vec![0u8; (MAX_ENTRY_BYTES as usize) * 40]).unwrap();
|
|
let archive = w.finish().unwrap().into_inner();
|
|
assert!(archive.len() < 64 * 1024, "{}", archive.len());
|
|
assert!(rejection_with(&archive, &keyed(KEY)).contains("at most"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_plaintext_bundle_is_refused_when_the_policy_requires_encryption() {
|
|
let policy = BundlePolicy { keys: vec![BundleKey::new(KEY)], accept_unencrypted: false, ..BundlePolicy::unencrypted_only() };
|
|
assert!(rejection_with(&good_bundle(GOOD), &policy).contains("requires encrypted"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_plaintext_bundle_is_still_accepted_by_default_and_flagged_unencrypted() {
|
|
let verified = verify_bundle(&good_bundle(GOOD), &keyed(KEY), 0).unwrap();
|
|
assert!(!verified.encrypted);
|
|
}
|
|
|
|
#[test]
|
|
fn installing_an_encrypted_bundle_stores_the_decrypted_config_and_reports_it_encrypted() {
|
|
let scratch = Scratch::new();
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, encrypted_bundle(GOOD, KEY)).unwrap();
|
|
let installed = scratch.path("config.toml");
|
|
|
|
let outcome = import_bundle(&bundle, &installed, &keyed(KEY));
|
|
assert!(matches!(outcome, Outcome::Installed { encrypted: true, .. }), "{outcome:?}");
|
|
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
|
|
}
|
|
|
|
#[test]
|
|
fn installing_a_plaintext_bundle_reports_it_unencrypted_so_it_can_be_warned_about() {
|
|
let scratch = Scratch::new();
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, good_bundle(GOOD)).unwrap();
|
|
let outcome = import_bundle(&bundle, &scratch.path("config.toml"), &keyed(KEY));
|
|
assert!(matches!(outcome, Outcome::Installed { encrypted: false, .. }), "{outcome:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_for_another_box_leaves_this_boxs_existing_config_untouched() {
|
|
let scratch = Scratch::new();
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, encrypted_bundle(GOOD, b"another box")).unwrap();
|
|
let installed = scratch.path("config.toml");
|
|
fs::write(&installed, "previous").unwrap();
|
|
|
|
assert!(matches!(import_bundle(&bundle, &installed, &keyed(KEY)), Outcome::Rejected { .. }));
|
|
assert_eq!(fs::read_to_string(&installed).unwrap(), "previous");
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_key_never_appears_in_debug_output() {
|
|
let shown = format!("{:?}", keyed(KEY));
|
|
assert!(!shown.contains("horse") && shown.contains("redacted"), "{shown}");
|
|
}
|
|
|
|
// ---- signatures, box binding, replay, key rotation ---------------------
|
|
//
|
|
// The cloud's real keypair is not available, so each test makes its own
|
|
// Ed25519 keypair with OpenSSL and signs fixtures with it exactly as the
|
|
// cloud would, through the same `signed_message`. That proves the box
|
|
// verifies what this code defines as correct; it cannot prove the cloud
|
|
// signs the same bytes (see the module docs for what it must do).
|
|
|
|
use openssl::pkey::Private;
|
|
use openssl::sign::Signer;
|
|
|
|
fn keypair() -> (PKey<Private>, 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<Private>, 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<u8> {
|
|
match key {
|
|
Some(key) => aes_zip(entries, key),
|
|
None => stored(entries),
|
|
}
|
|
}
|
|
|
|
fn signed_bundle(config: &str, box_id: &str, version: u64, private: &PKey<Private>, key: Option<&[u8]>) -> Vec<u8> {
|
|
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<u8>) -> (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");
|
|
}
|
|
}
|