Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

update-bundle/src/schema.rs

//! Schema identifiers, well-known member paths, and hostile-input resource limits.

/// The bundle schema this crate produces and accepts.
pub const BUNDLE_SCHEMA: &str = "holger.update-bundle/v1";

/// The trust-policy schema this crate produces and accepts.
pub const POLICY_SCHEMA: &str = "holger.bundle-trust-policy/v1";

// Well-known member paths inside the znippy container.
pub const MANIFEST_JSON: &str = "manifest.json";
pub const CORE_JSON: &str = "core.json";
pub const RELEASE_MANIFEST_JSON: &str = "signatures/release-manifest.json";
pub const RELEASE_SIG: &str = "signatures/release.sig";
pub const RELEASE_CRT: &str = "signatures/release.crt";

pub const APPROVALS_PREFIX: &str = "approvals/";
pub const SIGNATURES_PREFIX: &str = "signatures/";
pub const PAYLOAD_PREFIX: &str = "payload/";
pub const EVIDENCE_PREFIX: &str = "evidence/";

/// The recommended file extension for a finalized bundle.
pub const BUNDLE_EXTENSION: &str = "holger.znippy";

// ---- Hostile-input limits (a bundle is always treated as untrusted) ----

/// Maximum number of members in a bundle archive.
pub const MAX_MEMBERS: usize = 100_000;

/// Maximum uncompressed size of a single member (decompression-bomb guard, per member).
pub const MAX_MEMBER_BYTES: u64 = 8 * 1024 * 1024 * 1024; // 8 GiB

/// Maximum aggregate uncompressed size across all members (bomb guard, total).
pub const MAX_TOTAL_BYTES: u64 = 32 * 1024 * 1024 * 1024; // 32 GiB

/// Maximum size of a JSON control document (core.json / manifest.json / release-manifest.json).
pub const MAX_CONTROL_BYTES: u64 = 16 * 1024 * 1024; // 16 MiB

/// Maximum number of approval statements considered.
pub const MAX_APPROVALS: usize = 64;

// ---- Bomb-shape limits (the doors that close BEFORE anything is authenticated) ----
//
// `MAX_MEMBERS`, `MAX_MEMBER_BYTES` and `MAX_TOTAL_BYTES` above are deliberately
// UNCHANGED: they are the ceiling for an *authenticated* bundle, whose signed
// member sizes are known in advance and can therefore be budgeted before a byte
// is decompressed (see `container::load_bundle_verified`). The constants below are
// the separate, much tighter set that bounds what an *unauthenticated* archive can
// make this process allocate — which is the number that decides whether an
// appliance node survives a hostile bundle.

/// Maximum size of the bundle FILE on disk, refused by `stat` before the archive
/// is even opened.
///
/// 4 GiB: a compressed update bundle larger than this is not a firmware release,
/// it is a delivery problem. The cap is on the compressed container, so it also
/// bounds the input to every decompressor below it, and it is the one check that
/// costs a single syscall and no allocation at all.
pub const MAX_BUNDLE_FILE_BYTES: u64 = 4 * 1024 * 1024 * 1024; // 4 GiB

/// Maximum COMBINED size of the control members (`core.json`, `manifest.json`,
/// `signatures/**`, `approvals/**`) — the only members read before the release
/// signature is checked.
///
/// 64 MiB: large enough for one maxed-out release manifest (`MAX_CONTROL_BYTES`)
/// plus the envelope, the core, and `MAX_APPROVALS` × (statement + signature +
/// certificate), and small enough that the worst case an unauthenticated archive
/// can hold in RAM is a rounding error on any appliance. This is the constant that
/// answers the decompression-bomb finding: before this existed, 32 GiB of
/// attacker-chosen bytes were resident before anyone checked a signature.
pub const MAX_CONTROL_TOTAL_BYTES: u64 = 64 * 1024 * 1024; // 64 MiB

/// Maximum ratio of extracted bytes to the archive's on-disk size, enforced while
/// extracting an UNAUTHENTICATED archive (`container::load_bundle`).
///
/// 100:1 separates a bomb from a real bundle by shape rather than by size. Real
/// firmware and already-compressed evidence expand 1–5×; JSON and text 10–20×; a
/// decompression bomb is 1000:1 and upwards. A legitimately large but
/// poorly-compressing bundle has a ratio near 1 and never approaches this, which
/// is exactly the case the cap must not punish.
pub const MAX_EXPANSION_RATIO: u64 = 100;

/// The floor below which [`MAX_EXPANSION_RATIO`] is not enforced at all.
///
/// 64 MiB: a small archive of highly compressible members (a few hundred bytes of
/// JSON packed into a kilobyte) legitimately shows a huge ratio, and refusing it
/// would break real bundles to prevent an allocation no appliance would notice.
/// The ratio only starts deciding once the extraction is big enough to matter.
pub const EXPANSION_RATIO_FLOOR_BYTES: u64 = 64 * 1024 * 1024; // 64 MiB

/// True if `path` is a CONTROL member: one of the small, fixed-shape documents
/// that carry the bundle's trust decision (`core.json`, the envelope, every
/// signature/certificate, every approval statement).
///
/// This is the phase-one selector for `container::load_bundle_verified`: exactly
/// these members are extracted before the release signature is checked, and
/// nothing else is. Everything a bundle actually *delivers* — `payload/**`,
/// `evidence/**` — is outside this set by construction, which is what keeps the
/// pre-authentication working set bounded by [`MAX_CONTROL_TOTAL_BYTES`].
pub fn is_control_member(path: &str) -> bool {
    path == CORE_JSON
        || path == MANIFEST_JSON
        || path.starts_with(SIGNATURES_PREFIX)
        || path.starts_with(APPROVALS_PREFIX)
}