The bundle is authored by Continuum Cloud's configurator and downloaded by
the customer, so it travels over the internet and then on a USB stick while
holding the gateway token and possibly a wifi PSK. That makes it a secret at
rest, and confidentiality is the benefit encryption actually delivers here.
WinZip AES-256 ("AE-x"), via the zip crate's `aes-crypto` feature. That
pulls in aes, hmac, pbkdf2, sha1, constant_time_eq and zeroize - all
permissive (MIT/Apache-2.0, and CC0-1.0 OR MIT-0 OR Apache-2.0 for
constant_time_eq), each checked against docs/LICENSES.md, and
`cargo deny check licenses` passes.
**Legacy ZipCrypto is refused outright**, whatever key is configured: it
falls to a known-plaintext attack. The test is on metadata, not on a failed
decrypt - `encrypted()` is set for ZipCrypto and AES alike, but only AES
carries the AE-x extra field, so encrypted-without-it is ZipCrypto and the
whole bundle is rejected. Judged in the first pass, before any decrypt call,
so the crate's ZipCrypto path never runs on our input. Mixed bundles - one
entry encrypted, the other not - are rejected too.
The caps now bound *decrypted* output, so a small ciphertext that inflates
is capped exactly as a plaintext bomb is. Everything else holds unchanged
for an encrypted bundle: zip-slip, the inner config.toml.sha256, the
no-environment validation, atomic install, and a rejected bundle never
touching the installed config.
Failures are distinguished, because "wrong key" and "damaged" need different
answers: no key on the box and a wrong key both say "not for this box", while
tampered or damaged ciphertext says it failed its AES integrity check. A
wrong key passes AES's 16-bit password check 1 time in 65536 and then fails
the HMAC, so that message names a wrong key as a possibility.
**The limitation to be clear about: with plaintext bundles still accepted,
encryption does nothing against tampering.** An attacker supplies a
plaintext bundle and never touches the crypto. The tamper goal only begins to
hold once `accept_unencrypted` is false, which is one line in main.rs and is
deliberately not flipped yet - doing so breaks every stick made before the
configurator encrypts, and every hand-made development stick. That is a
product decision, not a code one.
More generally, encryption is not authenticity: the AES HMAC detects
modification only by someone without the password, and the password has to
live on the box, so extracting it from one image breaks every box sharing it.
It raises the bar from "anyone with a text editor" to "anyone who can read a
firmware image". The module docs say exactly that and do not call it
tamper-proof. `check_authenticity` is a named no-op where an Ed25519 check
over the config, verified against a *public* key in the box image, belongs -
it verifies nothing and says so, so the missing authenticity is a searchable
gap rather than an implicit one.
`load_bundle_key` is the single marked seam where provisioning lands. Its
current `$CONTINUUM_BUNDLE_KEY_FILE` / `/etc/continuum/bundle.key` lookup is
a placeholder, not a decision: shared-vs-per-box keys, where the key lives,
and rotation are open product questions and the mechanism depends on none of
them.
Encrypted fixtures are genuine AES ZIPs written by the zip crate itself, and
one test confirms the config text is absent from the ciphertext. The
ZipCrypto fixture sets the encrypted flag without an AE-x field, which is the
metadata shape this must refuse. Unproven: interop with whatever ZIP writer
the cloud actually uses (only archives written by this same crate version
were read back), AES-128/192, and `load_bundle_key` itself, which reads
process env and so has no test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1232 lines
55 KiB
Rust
1232 lines
55 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`.
|
|
//!
|
|
//! # What the hash does and does not buy - read this before relying on it
|
|
//!
|
|
//! 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.
|
|
//!
|
|
//! 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.
|
|
//!
|
|
//! 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.
|
|
//!
|
|
//! # Encryption: what it adds, and what it does not
|
|
//!
|
|
//! The bundle is produced by Continuum Cloud's configurator and carried on a
|
|
//! stick, so it is a **secret at rest** - it holds the gateway token and
|
|
//! possibly a wifi PSK - and may be encrypted (WinZip AES-256, "AE-x").
|
|
//!
|
|
//! **Confidentiality: yes, and that is the real new benefit.** A stick left
|
|
//! in a drawer or posted to a customer no longer discloses the token to
|
|
//! whoever picks it up.
|
|
//!
|
|
//! **Authenticity: not by itself.** WinZip-AES carries an HMAC, so a
|
|
//! modification made by someone *without the password* is detected. But the
|
|
//! password must live on the box, so extracting it from one box's image
|
|
//! yields it for every box that shares it, and with it the ability to build
|
|
//! bundles that decrypt, verify and install. Encryption therefore raises the
|
|
//! bar from "anyone with a text editor" to "anyone who can read a firmware
|
|
//! image" - possibly adequate for the stated goal of keeping customers on the
|
|
//! configurator - and it is **not tamper-proof**. Nothing here calls it that.
|
|
//!
|
|
//! A second, blunter caveat: while [`BundlePolicy::accept_unencrypted`] is
|
|
//! true (the current choice, see below) an attacker does not need to break
|
|
//! the encryption at all - they just hand over a *plaintext* bundle. Until
|
|
//! plaintext is refused, encryption protects the token's confidentiality
|
|
//! but does not discourage tampering one bit.
|
|
//!
|
|
//! **What would resist tampering is a signature** (Ed25519 or similar) with
|
|
//! only the *public* key in the box image: a public key can check but cannot
|
|
//! forge, so reading a box's image gains nothing. That is the follow-up and is
|
|
//! deliberately not implemented. `verify_bundle` is staged so it slots in
|
|
//! without reshaping the flow - see `check_authenticity`.
|
|
//!
|
|
//! **Only AES.** Legacy ZipCrypto is refused outright, whatever key is
|
|
//! configured: it falls to a known-plaintext attack with a handful of bytes,
|
|
//! and a config file starts with a very guessable prefix. An entry that is
|
|
//! encrypted without the AES extra field rejects the bundle before any
|
|
//! decryption is attempted, so ZipCrypto code is never run on our input.
|
|
//!
|
|
//! **Unencrypted bundles are still accepted, loudly.** Installing one logs at
|
|
//! `warn`. The alternative (refusing them) breaks every stick made before the
|
|
//! configurator encrypts, and any hand-made one used to provision a box
|
|
//! during development, with nothing to fall back on in the field. Accepting
|
|
//! both but logging keeps the transition possible while making the weaker
|
|
//! state visible; `accept_unencrypted = false` is the one-line switch for
|
|
//! when it is not wanted, and is what makes encryption mean anything for
|
|
//! tamper-resistance (previous paragraph).
|
|
//!
|
|
//! **Wrong key vs corruption.** An AES entry carries a 16-bit password
|
|
//! check, so a wrong or missing key is reported as its own reason - "this
|
|
//! bundle is not for this box" - not as a generic failure. The check has a
|
|
//! 1-in-65536 false-accept rate; a wrong key that slips through is then
|
|
//! caught by the HMAC at the end of the entry and reported as an integrity
|
|
//! failure that says a wrong key is one possible cause. A bundle that *does*
|
|
//! decrypt then goes through exactly the same chain as a plaintext one.
|
|
//!
|
|
//! The key is not designed here: see [`BundleKey`] and [`load_bundle_key`],
|
|
//! the single seam where provisioning lands.
|
|
//!
|
|
//! # Hostile archives
|
|
//!
|
|
//! The stick is untrusted input even though its author is trusted: it can be
|
|
//! 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 two 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 two expected entries are ever decompressed, so output is
|
|
//! bounded by twice 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 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";
|
|
|
|
/// 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>)")
|
|
}
|
|
}
|
|
|
|
/// Default location of the key on a box. A **placeholder**, not a decision.
|
|
const BUNDLE_KEY_PATH: &str = "/etc/continuum/bundle.key";
|
|
|
|
/// THE SEAM WHERE KEY PROVISIONING LANDS. Everything else in this module
|
|
/// takes a [`BundleKey`] as a value and does not care where it came from.
|
|
///
|
|
/// This reads a file (`$CONTINUUM_BUNDLE_KEY_FILE`, else
|
|
/// `/etc/continuum/bundle.key`), trailing newline stripped, because something
|
|
/// had to be here to make the mechanism runnable. It is NOT the answer to
|
|
/// where a box's key should live or whether it is shared, per-box, or bound
|
|
/// to the box - that is a product decision still to be made, and replacing
|
|
/// this function (a TPM, a secure element, a per-box key fetched at
|
|
/// manufacture) should not require touching anything else.
|
|
///
|
|
/// `Ok(None)` (no key file) is normal: the box then can only take unencrypted
|
|
/// bundles. A file that exists but is empty or unreadable is an error - a box
|
|
/// that quietly treated a broken key file as "no key" would reject every
|
|
/// encrypted stick with a misleading reason.
|
|
pub fn load_bundle_key() -> anyhow::Result<Option<BundleKey>> {
|
|
let path = std::env::var("CONTINUUM_BUNDLE_KEY_FILE").unwrap_or_else(|_| BUNDLE_KEY_PATH.to_string());
|
|
match fs::read(&path) {
|
|
Ok(mut bytes) => {
|
|
while matches!(bytes.last(), Some(b'\n' | b'\r')) {
|
|
bytes.pop();
|
|
}
|
|
anyhow::ensure!(!bytes.is_empty(), "bundle key file {path:?} is empty");
|
|
Ok(Some(BundleKey::new(bytes)))
|
|
}
|
|
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
|
Err(err) => anyhow::bail!("failed to read bundle key file {path:?}: {err}"),
|
|
}
|
|
}
|
|
|
|
/// What a box will accept. Passed in rather than read from globals so the
|
|
/// whole flow is testable and the policy is visible at the call site.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BundlePolicy {
|
|
/// Opens encrypted bundles. `None`: every encrypted bundle is rejected
|
|
/// as having no key.
|
|
pub key: Option<BundleKey>,
|
|
/// Whether a bundle that is not encrypted is acceptable. See the module
|
|
/// docs for why this is `true` today and what that costs.
|
|
pub accept_unencrypted: bool,
|
|
}
|
|
|
|
impl BundlePolicy {
|
|
/// Plaintext accepted, no key. What an unprovisioned box does.
|
|
pub fn unencrypted_only() -> Self {
|
|
Self { key: None, accept_unencrypted: true }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Outcome {
|
|
/// PC mode, or no directories configured: nothing was looked at.
|
|
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.
|
|
/// `encrypted` is false for a plaintext bundle, which is logged at warn.
|
|
Installed { bundle: PathBuf, sha256: String, encrypted: bool },
|
|
/// The bundle was refused; the installed config is untouched.
|
|
Rejected { bundle: PathBuf, reason: String },
|
|
}
|
|
|
|
#[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("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,
|
|
}
|
|
|
|
/// 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) -> 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;
|
|
// 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)] {
|
|
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.
|
|
let config_index = config_at.ok_or(BundleError::MissingEntry(CONFIG_ENTRY))?;
|
|
let hash_index = hash_at.ok_or(BundleError::MissingEntry(HASH_ENTRY))?;
|
|
let encrypted = encrypted_at[&config_index];
|
|
if encrypted != encrypted_at[&hash_index] {
|
|
return Err(BundleError::MixedEncryption);
|
|
}
|
|
let key = if encrypted {
|
|
Some(policy.key.as_ref().ok_or(BundleError::NoKey)?.0.as_slice())
|
|
} else if policy.accept_unencrypted {
|
|
None
|
|
} else {
|
|
return Err(BundleError::UnencryptedRefused);
|
|
};
|
|
|
|
// Stage: read, bounded on the *output*. For an encrypted entry that is
|
|
// the decrypted plaintext, so a small ciphertext that inflates is capped
|
|
// exactly as a plaintext bomb is. The AES HMAC is only checked when the
|
|
// stream reaches its end, which `read_bounded` does for anything within
|
|
// the cap - an entry over the cap is rejected before that matters.
|
|
let mut read = |index: usize, name: &'static str| -> Result<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 })
|
|
};
|
|
let config = read(config_index, CONFIG_ENTRY)?;
|
|
let hash_text = read(hash_index, HASH_ENTRY)?;
|
|
|
|
// The hash is checked before the bytes are parsed or trusted for
|
|
// anything: the TOML parser should not be the first thing to look at
|
|
// 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 });
|
|
}
|
|
|
|
check_authenticity(&config)?;
|
|
|
|
// Validated as the box will actually run it: with no environment. On a
|
|
// box the file has to be complete by itself, so a config that parses but
|
|
// lacks `gateway_id` is refused now rather than installed and then
|
|
// 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 })
|
|
}
|
|
|
|
/// **NOT IMPLEMENTED - the place a signature check goes.**
|
|
///
|
|
/// By this stage the bundle has been opened (decrypted if need be) and the
|
|
/// config bytes have matched their hash: the checks that catch damage and
|
|
/// casual edits. What is missing is the check that the bytes were made by
|
|
/// Continuum Cloud: an Ed25519 signature over `config` (or over its hash),
|
|
/// shipped as a third entry, verified against a *public* key in the box
|
|
/// image. It belongs here - after integrity, before the config is parsed or
|
|
/// trusted - and would add one `BundleError` variant and one policy field.
|
|
/// This function intentionally verifies nothing; it exists so that the lack
|
|
/// of authenticity is a named, searchable gap rather than an implicit one.
|
|
fn check_authenticity(_config: &[u8]) -> Result<(), BundleError> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Replaces `dest` with `bytes` so that no observer, and no crash, ever sees
|
|
/// 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 verified = match verify_bundle(&archive, policy) {
|
|
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) {
|
|
return Outcome::Unchanged { bundle: bundle.to_path_buf() };
|
|
}
|
|
|
|
match install_atomically(installed, &verified.bytes) {
|
|
Ok(()) => Outcome::Installed { bundle: bundle.to_path_buf(), sha256: verified.sha256, encrypted: verified.encrypted },
|
|
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 } => {
|
|
tracing::warn!(bundle = %bundle.display(), %sha256, "installed a new config from the pendrive; a restart is needed to use it");
|
|
// Visible on purpose: an unencrypted bundle is accepted, but
|
|
// it is the weaker state (token readable by anyone holding the
|
|
// stick, and no tamper discouragement at all).
|
|
if !encrypted {
|
|
tracing::warn!(bundle = %bundle.display(), "the installed bundle was NOT encrypted");
|
|
}
|
|
}
|
|
// `error`, not `warn`: someone inserted a stick expecting something
|
|
// to happen, and "nothing happened" must not be silent.
|
|
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()).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();
|
|
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()).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 { key: Some(BundleKey::new(key)), accept_unencrypted: true }
|
|
}
|
|
|
|
fn rejection_with(archive: &[u8], policy: &BundlePolicy) -> String {
|
|
verify_bundle(archive, policy).err().expect("bundle should be rejected").to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn an_aes_encrypted_bundle_opens_with_the_right_key_and_is_marked_encrypted() {
|
|
let verified = verify_bundle(&encrypted_bundle(GOOD, KEY), &keyed(KEY)).unwrap();
|
|
assert_eq!(verified.bytes, GOOD.as_bytes());
|
|
assert!(verified.encrypted);
|
|
}
|
|
|
|
#[test]
|
|
fn the_encrypted_fixture_really_does_hide_the_config_text() {
|
|
let archive = encrypted_bundle(GOOD, KEY);
|
|
assert!(!archive.windows(b"gateway_token".len()).any(|w| w == b"gateway_token"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bundle_on_a_box_with_no_key_says_it_is_not_for_this_box() {
|
|
let reason = rejection_with(&encrypted_bundle(GOOD, KEY), &BundlePolicy::unencrypted_only());
|
|
assert!(reason.contains("no bundle key") && reason.contains("not for this box"), "{reason}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bundle_made_for_a_different_key_says_it_is_not_for_this_box() {
|
|
let reason = rejection_with(&encrypted_bundle(GOOD, KEY), &keyed(b"some other box's key"));
|
|
// The 16-bit password check lets 1 wrong key in 65536 through to the
|
|
// HMAC, which then says so itself; either wording names the key.
|
|
assert!(reason.contains("not for this box") || reason.contains("key is wrong"), "{reason}");
|
|
assert!(!reason.contains("hash mismatch"), "a wrong key must not look like a corrupt stick: {reason}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_flipped_ciphertext_byte_is_caught_by_the_aes_integrity_check_not_trusted() {
|
|
let mut archive = encrypted_bundle(GOOD, KEY);
|
|
// First entry's data starts after the local header (30 bytes + name +
|
|
// extra); skip the 16-byte AES-256 salt and 2-byte password check.
|
|
let name_len = u16::from_le_bytes([archive[26], archive[27]]) as usize;
|
|
let extra_len = u16::from_le_bytes([archive[28], archive[29]]) as usize;
|
|
archive[30 + name_len + extra_len + 18] ^= 0xff;
|
|
|
|
let reason = rejection_with(&archive, &keyed(KEY));
|
|
assert!(reason.contains("AES integrity check"), "{reason}");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_zipcrypto_is_refused_even_when_a_key_is_configured() {
|
|
// Flag bit 0 = encrypted, with no AE-x extra field: that is ZipCrypto.
|
|
let mut archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, b"x")]);
|
|
let mut at = 0;
|
|
while at + 4 <= archive.len() {
|
|
match &archive[at..at + 4] {
|
|
b"PK\x03\x04" => archive[at + 6] |= 1,
|
|
b"PK\x01\x02" => archive[at + 8] |= 1,
|
|
_ => {}
|
|
}
|
|
at += 1;
|
|
}
|
|
let reason = rejection_with(&archive, &keyed(KEY));
|
|
assert!(reason.contains("ZipCrypto"), "{reason}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_with_one_encrypted_entry_and_one_plain_entry_is_rejected() {
|
|
let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes()));
|
|
let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
|
|
let plain = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
|
w.start_file(CONFIG_ENTRY, plain.with_aes_encryption_bytes(zip::AesMode::Aes256, KEY)).unwrap();
|
|
w.write_all(GOOD.as_bytes()).unwrap();
|
|
w.start_file(HASH_ENTRY, plain).unwrap();
|
|
w.write_all(hash.as_bytes()).unwrap();
|
|
let archive = w.finish().unwrap().into_inner();
|
|
|
|
assert!(rejection_with(&archive, &keyed(KEY)).contains("not encrypted alike"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bundle_that_decrypts_to_a_wrong_hash_is_rejected_like_a_plaintext_one() {
|
|
let hash = format!("{} config.toml", sha256_hex(b"something else"));
|
|
let archive = aes_zip(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())], KEY);
|
|
assert!(rejection_with(&archive, &keyed(KEY)).contains("hash mismatch"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bundle_that_decrypts_to_invalid_toml_is_rejected() {
|
|
assert!(rejection_with(&encrypted_bundle("not = = toml", KEY), &keyed(KEY)).contains("not valid"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bundle_with_a_zip_slip_entry_is_rejected_before_any_decryption() {
|
|
let archive = aes_zip(&[("../../evil", b"x"), (CONFIG_ENTRY, GOOD.as_bytes())], KEY);
|
|
// Rejected with no key at all: the name check is on metadata.
|
|
assert!(rejection_with(&archive, &BundlePolicy::unencrypted_only()).contains("unsafe entry"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_bomb_is_rejected_by_its_size_before_it_is_decrypted() {
|
|
let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
|
|
let opts = zip::write::SimpleFileOptions::default()
|
|
.compression_method(zip::CompressionMethod::Deflated)
|
|
.with_aes_encryption_bytes(zip::AesMode::Aes256, KEY);
|
|
w.start_file(CONFIG_ENTRY, opts).unwrap();
|
|
w.write_all(&vec![0u8; (MAX_ENTRY_BYTES as usize) * 40]).unwrap();
|
|
let archive = w.finish().unwrap().into_inner();
|
|
assert!(archive.len() < 64 * 1024, "{}", archive.len());
|
|
assert!(rejection_with(&archive, &keyed(KEY)).contains("at most"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_plaintext_bundle_is_refused_when_the_policy_requires_encryption() {
|
|
let policy = BundlePolicy { key: Some(BundleKey::new(KEY)), accept_unencrypted: false };
|
|
assert!(rejection_with(&good_bundle(GOOD), &policy).contains("requires encrypted"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_plaintext_bundle_is_still_accepted_by_default_and_flagged_unencrypted() {
|
|
let verified = verify_bundle(&good_bundle(GOOD), &keyed(KEY)).unwrap();
|
|
assert!(!verified.encrypted);
|
|
}
|
|
|
|
#[test]
|
|
fn installing_an_encrypted_bundle_stores_the_decrypted_config_and_reports_it_encrypted() {
|
|
let scratch = Scratch::new();
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, encrypted_bundle(GOOD, KEY)).unwrap();
|
|
let installed = scratch.path("config.toml");
|
|
|
|
let outcome = import_bundle(&bundle, &installed, &keyed(KEY));
|
|
assert!(matches!(outcome, Outcome::Installed { encrypted: true, .. }), "{outcome:?}");
|
|
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
|
|
}
|
|
|
|
#[test]
|
|
fn installing_a_plaintext_bundle_reports_it_unencrypted_so_it_can_be_warned_about() {
|
|
let scratch = Scratch::new();
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, good_bundle(GOOD)).unwrap();
|
|
let outcome = import_bundle(&bundle, &scratch.path("config.toml"), &keyed(KEY));
|
|
assert!(matches!(outcome, Outcome::Installed { encrypted: false, .. }), "{outcome:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_for_another_box_leaves_this_boxs_existing_config_untouched() {
|
|
let scratch = Scratch::new();
|
|
let bundle = scratch.path("stick.zip");
|
|
fs::write(&bundle, encrypted_bundle(GOOD, b"another box")).unwrap();
|
|
let installed = scratch.path("config.toml");
|
|
fs::write(&installed, "previous").unwrap();
|
|
|
|
assert!(matches!(import_bundle(&bundle, &installed, &keyed(KEY)), Outcome::Rejected { .. }));
|
|
assert_eq!(fs::read_to_string(&installed).unwrap(), "previous");
|
|
}
|
|
|
|
#[test]
|
|
fn a_bundle_key_never_appears_in_debug_output() {
|
|
let shown = format!("{:?}", keyed(KEY));
|
|
assert!(!shown.contains("horse") && shown.contains("redacted"), "{shown}");
|
|
}
|
|
}
|