Authenticate config bundles: Ed25519, per-box identity, no plaintext

The hash in a bundle only proved it was undamaged. Anyone who could write
the stick could write both files, so a Proxy Box would install a config
handed to it by whoever got physical access - including a config pointing the
box at a different cloud.

Encryption alone does not fix that either. While plaintext was accepted an
attacker simply did not encrypt, and the box took it. So a Box now refuses
plaintext outright, and the bundle carries a signature.

A third entry, `config.toml.sig`: strict TOML with `box_id`, `config_version`
and a raw Ed25519 signature. What is signed is `signed_message()` - a domain
tag, the length-prefixed box id, the version big-endian, then the raw
`config.toml` bytes. Raw bytes rather than the archive, so the signature is
independent of ZIP framing, compression, entry order and encryption. The box
id and version are inside the signed message rather than beside it, so
neither can be edited without breaking it, and the length prefix stops a
byte-shifting collision between the id and the config.

Past decryption the order is hash, signature, box id, version, then parse and
validate. The claims in the `.sig` are attacker-controlled text until the
signature over them verifies, so nothing is concluded from them before then -
and nothing about this box's identity is revealed before then either, which
is why unsigned junk cannot be used to probe which box it is talking to.

Which error wins when a box cannot even open a bundle: a signature over the
plaintext is only checkable after decryption, so a box with the wrong key
cannot distinguish "meant for another box" from "forged". Wrong key wins.
Neither bundle installs either way, it is nearly always the true cause, and
calling something forged that we could not open would be an accusation we
cannot support.

Replay is enforced rather than documented away: `config_version` must not go
backwards, recorded in a sidecar beside the installed config. An equal
version is accepted on purpose, because the version is written before the
config, so a power cut between the two leaves "new version, old config" and
re-inserting the same stick repairs it. The other write order would leave a
replay window instead.

Both key sets are lists, so the cloud's signing key and a box's own key can
be rotated. `load_provisioning()` is the single seam where a box's identity
and keys come from; a TPM or secure element replaces that function and
nothing else. A missing file means unprovisioned, and the box then says
loudly what it cannot check - but an empty or malformed one is an error,
because quietly reading a broken key file as absent would turn a
provisioning mistake into a box that accepts unsigned bundles.

Not verified: interop with the real cloud signer. These tests prove the box
accepts what this code defines as correct, not that the cloud will produce
the same bytes - the message layout, the pure-Ed25519-no-prehash choice and
the `.sig` shape all need a cross-check against the configurator's own crypto
library once it exists. A compromised cloud signing key still breaks every
box trusting it; revocation is not built.
This commit is contained in:
2026-10-19 08:08:27 +00:00
parent ad55645ec9
commit 1cd8ce25c3
3 changed files with 772 additions and 160 deletions
+8 -10
View File
@@ -5,16 +5,14 @@
# #
# PC / server: copy to continuum.toml next to the proxy and edit it. Nothing # PC / server: copy to continuum.toml next to the proxy and edit it. Nothing
# else is needed; removable media is never read. # else is needed; removable media is never read.
# Proxy Box: put this file in a ZIP named continuum-config.zip together # Proxy Box: this file is NOT written by hand. Continuum Cloud's
# with its SHA-256, and insert the stick: # configurator produces continuum-config.zip: config.toml,
# # config.toml.sha256 and config.toml.sig (an Ed25519 signature
# cp continuum.example.toml config.toml # edit it # naming this box and a config_version), every entry
# sha256sum config.toml > config.toml.sha256 # AES-256-encrypted under the box's own key. A Box refuses
# zip continuum-config.zip config.toml config.toml.sha256 # plaintext, unsigned, other-box and older-version bundles.
# # See the module docs of src/config_import.rs for the exact
# The hash catches a damaged or hand-edited stick. It does NOT # format the cloud must produce.
# prove who made it - anyone who can write the stick can write
# both files. See src/config_import.rs.
# #
# Unknown keys are rejected on purpose, so a typo cannot silently fall back to # Unknown keys are rejected on purpose, so a typo cannot silently fall back to
# a default. On a Proxy Box the file must be complete by itself. # a default. On a Proxy Box the file must be complete by itself.
+743 -144
View File
File diff suppressed because it is too large Load Diff
+21 -6
View File
@@ -9,7 +9,7 @@ mod go2rtc;
use tracing::info; use tracing::info;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use continuum_proxy::bootstrap::Bootstrap; use continuum_proxy::bootstrap::{self, Bootstrap};
use continuum_proxy::config::Config; use continuum_proxy::config::Config;
use continuum_proxy::config_import; use continuum_proxy::config_import;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
@@ -27,12 +27,27 @@ async fn main() -> anyhow::Result<()> {
// the installed config by the time it is read. A rejected stick is // the installed config by the time it is read. A rejected stick is
// logged inside and does not stop startup - the box runs on what it has. // logged inside and does not stop startup - the box runs on what it has.
let bootstrap = Bootstrap::discover()?; let bootstrap = Bootstrap::discover()?;
let policy = config_import::BundlePolicy { // Plaintext bundles are refused on a Proxy Box and signatures are
key: config_import::load_bundle_key()?, // mandatory once a signing key is provisioned; see the config_import
// Plaintext bundles are accepted (and logged at warn on install); // module docs. An unprovisioned box is allowed to start - it runs on its
// see the config_import module docs for what that costs. // installed config - but says loudly what it cannot check.
accept_unencrypted: true, //
// Loaded only on a box: on a PC nothing imports from removable media, and
// a stray or malformed file under /etc/continuum must not stop a PC user's
// proxy starting over provisioning it never uses.
let policy = if bootstrap.mode == bootstrap::Mode::ProxyBox {
config_import::load_provisioning()?
} else {
config_import::BundlePolicy::unencrypted_only()
}; };
if bootstrap.mode == bootstrap::Mode::ProxyBox {
if policy.signing_keys.is_empty() {
tracing::warn!("no signing key provisioned: config bundles will NOT be authenticated");
}
if policy.box_id.is_none() {
tracing::warn!("no box id provisioned: signed config bundles cannot be accepted");
}
}
for outcome in config_import::import_from_pendrives(&bootstrap, &policy) { for outcome in config_import::import_from_pendrives(&bootstrap, &policy) {
config_import::log_outcome(&outcome); config_import::log_outcome(&outcome);
} }