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

nordisk/holger

sync/src/lib.rs

//! **Holger's sync protocol** (Holger v2.0 phase H4, `docs/HOLGER-V2.md`).
//!
//! ```text
//!  UPSTREAM HOLGER                                         DOWNSTREAM HOLGER
//!  ledger: generation N, invalidations,                    applied: last generation per upstream
//!          per downstream: acked gen, reached
//!       │                                                        ▲
//!       │  SyncManifest (generation, added, invalidations)       │ verify under its trust
//!       ├── signed by the upstream's key ──► pull: GET /-/sync/manifest
//!       │                                    USB: a file on the stick, or evidence/holger-sync.json
//!       │                                         inside the airgap bundle
//!       │                                                        │ apply: every invalidated
//!       │                                                        │ package is QUARANTINED at once
//!       │  SyncAck (generation, counts, invalidations applied)   │
//!       ◄── signed by the downstream's key ── pull: POST /-/sync/ack
//!                                             USB: an ack file back on the stick
//! ```
//!
//! This crate is the protocol only: the documents, their signatures, the upstream's
//! [`ledger::Ledger`] (per downstream: last generation applied, lag, invalidations
//! reached, the alarm) and the downstream's [`applied::Applied`] record. It holds no
//! server, opens no socket and knows no package format; `holger-server-lib`'s `sync`
//! module runs it against the intake chain.
//!
//! # The signature
//!
//! Every document travels as a [`Signed`] envelope: the document's JSON bytes
//! (base64), the signer's name, its Ed25519 public key and the signature over
//! `holger-sync/v1 \n <kind> \n <payload bytes>`. A verifier accepts an envelope only
//! when the signer's NAME is in its trust list with exactly that KEY, the signature
//! verifies, and the document inside names the same signer (a manifest's
//! `upstream`, an ack's `downstream`). The kind is in the signed bytes, so a
//! manifest is never accepted as an ack or the other way round.
//!
//! # Invalidations are cumulative
//!
//! A manifest carries EVERY invalidation the upstream ever issued, each with the
//! generation it was issued in. A downstream that missed a stick, or pulled after a
//! long gap, applies them all in one step, and a re-applied invalidation is a no-op.

use std::path::Path;

use base64::Engine as _;
use ed25519_dalek::pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _};
use ed25519_dalek::{Signer as _, SigningKey, Verifier as _, VerifyingKey};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::Digest as _;

pub mod applied;
/// The signed invite a WireGuard downstream enrols with, once (lane HOL-WGDS).
pub mod invite;
pub mod ledger;
/// A downstream's sync report, signed with its sync key (lane OX-WGDS2).
pub mod report;

/// The manifest's schema.
pub const MANIFEST_SCHEMA: &str = "holger.sync-manifest/v1";
/// The acknowledgement's schema.
pub const ACK_SCHEMA: &str = "holger.sync-ack/v1";
/// The signed envelope's schema.
pub const SIGNED_SCHEMA: &str = "holger.sync-signed/v1";
/// The board's schema.
pub const BOARD_SCHEMA: &str = "holger.sync-board/v1";
/// Where an airgap bundle carries its manifest, relative to the bundle's `evidence/`.
pub const BUNDLE_MANIFEST: &str = "holger-sync.json";
/// The domain separator at the head of every signed message.
const DOMAIN: &[u8] = b"holger-sync/v1\n";

/// Unix seconds now.
pub fn now_secs() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

/// Why a package is invalidated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Reason {
    /// A vulnerability was published after the package was approved.
    CveAfter,
    /// Malware was found in it after it was approved.
    Malware,
    /// Its publisher pulled (yanked, unpublished) the release.
    Pulled,
    /// A policy changed and no longer allows it.
    Policy,
}

impl Reason {
    pub const ALL: [Reason; 4] = [
        Reason::CveAfter,
        Reason::Malware,
        Reason::Pulled,
        Reason::Policy,
    ];

    pub fn as_str(self) -> &'static str {
        match self {
            Reason::CveAfter => "cve-after",
            Reason::Malware => "malware",
            Reason::Pulled => "pulled",
            Reason::Policy => "policy",
        }
    }

    /// `cve-after`, `malware`, `pulled` or `policy`.
    pub fn parse(raw: &str) -> Option<Reason> {
        Reason::ALL
            .into_iter()
            .find(|r| r.as_str() == raw.trim().to_ascii_lowercase())
    }

    /// The reason in words, for an audit line.
    pub fn words(self) -> &'static str {
        match self {
            Reason::CveAfter => "a vulnerability published after its approval",
            Reason::Malware => "malware found after its approval",
            Reason::Pulled => "the release was pulled by its publisher",
            Reason::Policy => "a policy no longer allows it",
        }
    }
}

impl std::fmt::Display for Reason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// One package, by its coordinate (the intake label, `ns/name@version` or a file
/// path) and the sha256 of its bytes.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct PackageRef {
    pub coordinate: String,
    pub sha256: String,
}

/// **One invalidation**: "this package is blocked, for this reason".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Invalidation {
    /// Stable id: `inv-<generation>-<12 hex of sha256(coordinate, sha256, reason)>`.
    pub id: String,
    pub coordinate: String,
    /// The bytes it names. `None`: every package at the coordinate.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sha256: Option<String>,
    pub reason: Reason,
    /// One sentence: the CVE, the scanner hit, the publisher's notice.
    #[serde(default)]
    pub detail: String,
    /// The generation it was issued in.
    pub generation: u64,
    /// Unix seconds.
    pub issued_at: i64,
    /// The principal who issued it on the upstream.
    #[serde(default)]
    pub issued_by: String,
}

impl Invalidation {
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        coordinate: impl Into<String>,
        sha256: Option<String>,
        reason: Reason,
        detail: impl Into<String>,
        generation: u64,
        issued_at: i64,
        issued_by: impl Into<String>,
    ) -> Self {
        let coordinate = coordinate.into();
        let sha256 = sha256
            .map(|s| s.trim().to_ascii_lowercase())
            .filter(|s| !s.is_empty());
        let mut h = sha2::Sha256::new();
        h.update(coordinate.as_bytes());
        h.update(b"\0");
        h.update(sha256.as_deref().unwrap_or("").as_bytes());
        h.update(b"\0");
        h.update(reason.as_str().as_bytes());
        let digest = hex::encode(h.finalize());
        Self {
            id: format!("inv-{generation}-{}", &digest[..12]),
            coordinate,
            sha256,
            reason,
            detail: detail.into(),
            generation,
            issued_at,
            issued_by: issued_by.into(),
        }
    }

    /// Does this invalidation name the package `coordinate` with bytes `sha256`?
    /// By the bytes when it names bytes, else by the coordinate.
    pub fn matches(&self, coordinate: &str, sha256: &str) -> bool {
        match &self.sha256 {
            Some(s) => s.eq_ignore_ascii_case(sha256.trim()),
            None => self.coordinate == coordinate,
        }
    }

    /// `coordinate (reason)`, for a log line.
    pub fn label(&self) -> String {
        format!("{} ({})", self.coordinate, self.reason)
    }
}

/// **The sync manifest**: what one generation of an upstream says.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncManifest {
    pub schema: String,
    /// The upstream's name; the envelope's signer.
    pub upstream: String,
    /// Monotonic. A downstream refuses a generation below the one it applied.
    pub generation: u64,
    /// Unix seconds.
    pub issued_at: i64,
    /// What carries it: `pull`, or `bundle <id> seq <n>`.
    #[serde(default)]
    pub carrier: String,
    /// The packages this sync adds.
    #[serde(default)]
    pub added: Vec<PackageRef>,
    /// EVERY invalidation the upstream issued (cumulative).
    #[serde(default)]
    pub invalidations: Vec<Invalidation>,
}

/// **The acknowledgement** a downstream sends for each applied sync.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncAck {
    pub schema: String,
    /// The downstream's name; the envelope's signer.
    pub downstream: String,
    /// The upstream whose manifest was applied.
    pub upstream: String,
    /// The generation applied.
    pub generation: u64,
    /// Unix seconds.
    pub applied_at: i64,
    /// How many packages the applied sync added.
    pub packages_added: u64,
    /// The ids of every invalidation in force on the downstream after the apply.
    pub invalidations_applied: Vec<String>,
    /// The coordinates the apply quarantined just now.
    #[serde(default)]
    pub quarantined: Vec<String>,
    /// `pull` or `usb`.
    #[serde(default)]
    pub carrier: String,
}

/// What a [`Signed`] envelope holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    Manifest,
    Ack,
    /// A WireGuard downstream's invite ([`invite`]).
    Invite,
    /// A downstream's sync report ([`report`]).
    Report,
}

impl Kind {
    pub fn as_str(self) -> &'static str {
        match self {
            Kind::Manifest => "manifest",
            Kind::Ack => "ack",
            Kind::Invite => "invite",
            Kind::Report => "report",
        }
    }
}

/// A peer this side trusts: its name and its Ed25519 public key (hex, 32 bytes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedPeer {
    pub name: String,
    pub key: String,
}

/// Why a sync document was not accepted, or a record could not be kept.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncError {
    /// Not a sync document, or one that contradicts itself.
    Malformed(String),
    /// The signer is not in the trust list with this key.
    UntrustedSigner { signer: String, why: String },
    /// The signature does not verify.
    BadSignature(String),
    /// A manifest older than the generation already applied.
    Rollback {
        upstream: String,
        applied: u64,
        offered: u64,
    },
    /// An ack from a downstream this upstream has not enrolled.
    UnknownDownstream(String),
    /// An ack for a generation this upstream never issued.
    FutureGeneration { offered: u64, issued: u64 },
    /// A state file could not be read or written.
    Io(String),
}

impl SyncError {
    /// The number of this error's `holger-sync-<n>` code.
    pub fn number(&self) -> u32 {
        match self {
            SyncError::Malformed(_) => 1,
            SyncError::UntrustedSigner { .. } => 2,
            SyncError::BadSignature(_) => 3,
            SyncError::Rollback { .. } => 4,
            SyncError::UnknownDownstream(_) => 5,
            SyncError::FutureGeneration { .. } => 6,
            SyncError::Io(_) => 7,
        }
    }
}

impl std::fmt::Display for SyncError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SyncError::Malformed(m) => write!(f, "the sync document is malformed: {m}"),
            SyncError::UntrustedSigner { signer, why } => write!(f, "the sync document's signer '{signer}' is not trusted here: {why}"),
            SyncError::BadSignature(m) => write!(f, "the sync document's signature does not verify: {m}"),
            SyncError::Rollback { upstream, applied, offered } => write!(
                f,
                "the manifest from '{upstream}' is generation {offered}, older than generation {applied} already applied here (a rollback)"
            ),
            SyncError::UnknownDownstream(d) => write!(f, "'{d}' is not a downstream this upstream has enrolled"),
            SyncError::FutureGeneration { offered, issued } => {
                write!(f, "the ack names generation {offered}, but this upstream has issued only up to {issued}")
            }
            SyncError::Io(m) => write!(f, "the sync state could not be kept: {m}"),
        }
    }
}

impl std::error::Error for SyncError {}

/// **This Holger's sync key** (Ed25519). An upstream signs its manifests with it, a
/// downstream its acks.
pub struct SyncKey {
    key: SigningKey,
}

impl SyncKey {
    /// A fresh key from the operating system's randomness.
    pub fn mint() -> Result<Self, SyncError> {
        let mut seed = [0u8; 32];
        getrandom::fill(&mut seed)
            .map_err(|e| SyncError::Io(format!("no randomness for a new sync key: {e}")))?;
        Ok(Self {
            key: SigningKey::from_bytes(&seed),
        })
    }

    /// A key from its 32 seed bytes (tests).
    pub fn from_seed(seed: [u8; 32]) -> Self {
        Self {
            key: SigningKey::from_bytes(&seed),
        }
    }

    /// The key in `path` (PKCS#8 DER); minted and written (mode 0600) when absent.
    pub fn open_or_mint(path: &Path) -> Result<Self, SyncError> {
        if path.exists() {
            let der = std::fs::read(path)
                .map_err(|e| SyncError::Io(format!("read {}: {e}", path.display())))?;
            let key = SigningKey::from_pkcs8_der(&der).map_err(|e| {
                SyncError::Malformed(format!(
                    "{} is not a PKCS#8 Ed25519 key: {e}",
                    path.display()
                ))
            })?;
            return Ok(Self { key });
        }
        let k = Self::mint()?;
        let der = k
            .key
            .to_pkcs8_der()
            .map_err(|e| SyncError::Io(format!("encode the sync key: {e}")))?;
        if let Some(dir) = path.parent() {
            std::fs::create_dir_all(dir)
                .map_err(|e| SyncError::Io(format!("create {}: {e}", dir.display())))?;
        }
        write_private(path, der.as_bytes())?;
        Ok(k)
    }

    /// The public key, hex: what the peer puts in its trust list.
    pub fn public_hex(&self) -> String {
        hex::encode(self.key.verifying_key().to_bytes())
    }

    /// Sign `manifest` as `manifest.upstream`.
    pub fn sign_manifest(&self, manifest: &SyncManifest) -> Result<Signed, SyncError> {
        Signed::seal(Kind::Manifest, &manifest.upstream, self, manifest)
    }

    /// Sign `ack` as `ack.downstream`.
    pub fn sign_ack(&self, ack: &SyncAck) -> Result<Signed, SyncError> {
        Signed::seal(Kind::Ack, &ack.downstream, self, ack)
    }
}

#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), SyncError> {
    use std::io::Write as _;
    use std::os::unix::fs::OpenOptionsExt as _;
    let mut f = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(path)
        .map_err(|e| SyncError::Io(format!("create {}: {e}", path.display())))?;
    f.write_all(bytes)
        .and_then(|()| f.sync_all())
        .map_err(|e| SyncError::Io(format!("write {}: {e}", path.display())))
}

#[cfg(not(unix))]
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), SyncError> {
    std::fs::write(path, bytes).map_err(|e| SyncError::Io(format!("write {}: {e}", path.display())))
}

/// **A signed sync document.**
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signed {
    pub schema: String,
    /// `manifest` or `ack`.
    pub kind: String,
    /// The signer's name.
    pub signer: String,
    /// The signer's Ed25519 public key, hex.
    pub key: String,
    /// The document's JSON bytes, base64.
    pub payload: String,
    /// The Ed25519 signature, hex.
    pub signature: String,
}

fn message(kind: Kind, payload: &[u8]) -> Vec<u8> {
    let mut m = Vec::with_capacity(DOMAIN.len() + 16 + payload.len());
    m.extend_from_slice(DOMAIN);
    m.extend_from_slice(kind.as_str().as_bytes());
    m.push(b'\n');
    m.extend_from_slice(payload);
    m
}

impl Signed {
    pub(crate) fn seal<T: Serialize>(
        kind: Kind,
        signer: &str,
        key: &SyncKey,
        body: &T,
    ) -> Result<Self, SyncError> {
        let payload = serde_json::to_vec(body).map_err(|e| SyncError::Malformed(e.to_string()))?;
        let sig = key.key.sign(&message(kind, &payload));
        Ok(Self {
            schema: SIGNED_SCHEMA.into(),
            kind: kind.as_str().into(),
            signer: signer.into(),
            key: key.public_hex(),
            payload: base64::engine::general_purpose::STANDARD.encode(&payload),
            signature: hex::encode(sig.to_bytes()),
        })
    }

    /// The envelope's JSON.
    pub fn to_bytes(&self) -> Vec<u8> {
        serde_json::to_vec_pretty(self).unwrap_or_default()
    }

    /// An envelope from its JSON.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SyncError> {
        let s: Signed = serde_json::from_slice(bytes)
            .map_err(|e| SyncError::Malformed(format!("not a signed sync document: {e}")))?;
        if s.schema != SIGNED_SCHEMA {
            return Err(SyncError::Malformed(format!(
                "schema '{}', expected '{SIGNED_SCHEMA}'",
                s.schema
            )));
        }
        Ok(s)
    }

    /// **Verify and open.** The signer must be in `trusted` with this key, the kind
    /// must be `kind`, and the signature must verify over the payload.
    pub fn open<T: DeserializeOwned>(
        &self,
        kind: Kind,
        trusted: &[TrustedPeer],
    ) -> Result<T, SyncError> {
        if self.kind != kind.as_str() {
            return Err(SyncError::Malformed(format!(
                "a '{}' document where a '{}' was expected",
                self.kind,
                kind.as_str()
            )));
        }
        let Some(peer) = trusted.iter().find(|p| p.name == self.signer) else {
            return Err(SyncError::UntrustedSigner {
                signer: self.signer.clone(),
                why: "no peer of that name is in the trust list".into(),
            });
        };
        if !peer.key.trim().eq_ignore_ascii_case(self.key.trim()) {
            return Err(SyncError::UntrustedSigner {
                signer: self.signer.clone(),
                why: format!(
                    "it signed with key {}, and the trust list holds {}",
                    short(&self.key),
                    short(&peer.key)
                ),
            });
        }
        let key_bytes: [u8; 32] = hex::decode(peer.key.trim())
            .ok()
            .and_then(|b| b.try_into().ok())
            .ok_or_else(|| SyncError::UntrustedSigner {
                signer: self.signer.clone(),
                why: "the trusted key is not 32 hex bytes".into(),
            })?;
        let vk = VerifyingKey::from_bytes(&key_bytes).map_err(|e| SyncError::UntrustedSigner {
            signer: self.signer.clone(),
            why: e.to_string(),
        })?;
        let payload = base64::engine::general_purpose::STANDARD
            .decode(self.payload.trim())
            .map_err(|e| SyncError::Malformed(format!("the payload is not base64: {e}")))?;
        let sig_bytes: [u8; 64] = hex::decode(self.signature.trim())
            .ok()
            .and_then(|b| b.try_into().ok())
            .ok_or_else(|| SyncError::BadSignature("the signature is not 64 hex bytes".into()))?;
        vk.verify(
            &message(kind, &payload),
            &ed25519_dalek::Signature::from_bytes(&sig_bytes),
        )
        .map_err(|e| SyncError::BadSignature(e.to_string()))?;
        serde_json::from_slice(&payload)
            .map_err(|e| SyncError::Malformed(format!("the signed payload: {e}")))
    }
}

fn short(key: &str) -> String {
    key.chars().take(16).collect::<String>() + "…"
}

/// **Open a signed manifest** from its bytes under `trusted` (the downstream's
/// trusted upstreams). The manifest must name the envelope's signer.
pub fn open_manifest(bytes: &[u8], trusted: &[TrustedPeer]) -> Result<SyncManifest, SyncError> {
    let s = Signed::from_bytes(bytes)?;
    let m: SyncManifest = s.open(Kind::Manifest, trusted)?;
    if m.schema != MANIFEST_SCHEMA {
        return Err(SyncError::Malformed(format!(
            "manifest schema '{}', expected '{MANIFEST_SCHEMA}'",
            m.schema
        )));
    }
    if m.upstream != s.signer {
        return Err(SyncError::Malformed(format!(
            "the manifest names upstream '{}' but '{}' signed it",
            m.upstream, s.signer
        )));
    }
    Ok(m)
}

/// **Open a signed ack** from its bytes under `enrolled` (the upstream's enrolled
/// downstreams). An unknown signer is [`SyncError::UnknownDownstream`].
pub fn open_ack(bytes: &[u8], enrolled: &[TrustedPeer]) -> Result<SyncAck, SyncError> {
    let s = Signed::from_bytes(bytes)?;
    if !enrolled.iter().any(|p| p.name == s.signer) {
        return Err(SyncError::UnknownDownstream(s.signer));
    }
    let a: SyncAck = s.open(Kind::Ack, enrolled)?;
    if a.schema != ACK_SCHEMA {
        return Err(SyncError::Malformed(format!(
            "ack schema '{}', expected '{ACK_SCHEMA}'",
            a.schema
        )));
    }
    if a.downstream != s.signer {
        return Err(SyncError::Malformed(format!(
            "the ack names downstream '{}' but '{}' signed it",
            a.downstream, s.signer
        )));
    }
    Ok(a)
}

/// The file name of an ack on the stick: `holger-sync-ack-<downstream>-<generation>.json`.
pub fn ack_file_name(ack: &SyncAck) -> String {
    format!(
        "holger-sync-ack-{}-{}.json",
        safe(&ack.downstream),
        ack.generation
    )
}

/// The file name of a manifest on the stick: `holger-sync-<upstream>-<generation>.json`.
pub fn manifest_file_name(m: &SyncManifest) -> String {
    format!("holger-sync-{}-{}.json", safe(&m.upstream), m.generation)
}

fn safe(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect()
}

/// Read a JSON state file; its default when absent.
pub fn read_state<T: DeserializeOwned + Default>(path: &Path) -> Result<T, SyncError> {
    match std::fs::read(path) {
        Ok(b) => serde_json::from_slice(&b)
            .map_err(|e| SyncError::Io(format!("{} is not readable state: {e}", path.display()))),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
        Err(e) => Err(SyncError::Io(format!("read {}: {e}", path.display()))),
    }
}

/// Write a JSON state file atomically (a temporary file renamed over it).
pub fn write_state<T: Serialize>(path: &Path, value: &T) -> Result<(), SyncError> {
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir)
            .map_err(|e| SyncError::Io(format!("create {}: {e}", dir.display())))?;
    }
    let body = serde_json::to_vec_pretty(value).map_err(|e| SyncError::Io(e.to_string()))?;
    let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
    std::fs::write(&tmp, &body)
        .and_then(|()| std::fs::rename(&tmp, path))
        .map_err(|e| SyncError::Io(format!("write {}: {e}", path.display())))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn manifest(upstream: &str, generation: u64) -> SyncManifest {
        SyncManifest {
            schema: MANIFEST_SCHEMA.into(),
            upstream: upstream.into(),
            generation,
            issued_at: 100,
            carrier: "pull".into(),
            added: vec![PackageRef {
                coordinate: "pkg@1".into(),
                sha256: "aa".into(),
            }],
            invalidations: vec![Invalidation::new(
                "pkg@0",
                Some("BB".into()),
                Reason::Malware,
                "eicar",
                generation,
                99,
                "sec",
            )],
        }
    }

    #[test]
    fn a_manifest_signed_by_a_trusted_upstream_opens() {
        let k = SyncKey::from_seed([1; 32]);
        let signed = k.sign_manifest(&manifest("dmz", 3)).unwrap();
        let trust = [TrustedPeer {
            name: "dmz".into(),
            key: k.public_hex(),
        }];
        let m = open_manifest(&signed.to_bytes(), &trust).unwrap();
        assert_eq!(m.generation, 3);
        assert_eq!(
            m.invalidations[0].sha256.as_deref(),
            Some("bb"),
            "sha256 is kept lower-case"
        );
    }

    #[test]
    fn an_untrusted_or_changed_manifest_is_refused() {
        let k = SyncKey::from_seed([1; 32]);
        let other = SyncKey::from_seed([2; 32]);
        let signed = k.sign_manifest(&manifest("dmz", 3)).unwrap();
        // Unknown name.
        let e = open_manifest(
            &signed.to_bytes(),
            &[TrustedPeer {
                name: "elsewhere".into(),
                key: k.public_hex(),
            }],
        )
        .unwrap_err();
        assert_eq!(e.number(), 2, "{e}");
        // Known name, other key.
        let e = open_manifest(
            &signed.to_bytes(),
            &[TrustedPeer {
                name: "dmz".into(),
                key: other.public_hex(),
            }],
        )
        .unwrap_err();
        assert_eq!(e.number(), 2, "{e}");
        // A payload changed after signing.
        let trust = [TrustedPeer {
            name: "dmz".into(),
            key: k.public_hex(),
        }];
        let mut forged = signed.clone();
        let mut m = manifest("dmz", 3);
        m.invalidations.clear();
        forged.payload =
            base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&m).unwrap());
        assert_eq!(
            open_manifest(&forged.to_bytes(), &trust)
                .unwrap_err()
                .number(),
            3
        );
        // Signed by another key that claims the trusted key.
        let mut swapped = other.sign_manifest(&manifest("dmz", 3)).unwrap();
        swapped.key = k.public_hex();
        assert_eq!(
            open_manifest(&swapped.to_bytes(), &trust)
                .unwrap_err()
                .number(),
            3
        );
    }

    #[test]
    fn a_manifest_is_never_an_ack_and_must_name_its_signer() {
        let k = SyncKey::from_seed([1; 32]);
        let trust = [TrustedPeer {
            name: "dmz".into(),
            key: k.public_hex(),
        }];
        let signed = k.sign_manifest(&manifest("dmz", 3)).unwrap();
        assert_eq!(
            open_ack(&signed.to_bytes(), &trust).unwrap_err().number(),
            1
        );
        // A manifest naming another upstream, signed under "dmz".
        let lie = Signed::seal(Kind::Manifest, "dmz", &k, &manifest("other", 3)).unwrap();
        assert_eq!(
            open_manifest(&lie.to_bytes(), &trust).unwrap_err().number(),
            1
        );
    }

    #[test]
    fn an_ack_from_an_unknown_downstream_is_refused_by_name() {
        let k = SyncKey::from_seed([3; 32]);
        let ack = SyncAck {
            schema: ACK_SCHEMA.into(),
            downstream: "onprem".into(),
            upstream: "dmz".into(),
            generation: 1,
            applied_at: 1,
            packages_added: 0,
            invalidations_applied: vec![],
            quarantined: vec![],
            carrier: "usb".into(),
        };
        let signed = k.sign_ack(&ack).unwrap();
        let e = open_ack(&signed.to_bytes(), &[]).unwrap_err();
        assert_eq!(e, SyncError::UnknownDownstream("onprem".into()));
        let ok = open_ack(
            &signed.to_bytes(),
            &[TrustedPeer {
                name: "onprem".into(),
                key: k.public_hex(),
            }],
        )
        .unwrap();
        assert_eq!(ok, ack);
    }

    #[test]
    fn the_key_file_is_minted_once_and_read_back() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("k/sync-key.pk8");
        let a = SyncKey::open_or_mint(&p).unwrap();
        let b = SyncKey::open_or_mint(&p).unwrap();
        assert_eq!(a.public_hex(), b.public_hex());
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            assert_eq!(
                std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
                0o600
            );
        }
    }

    #[test]
    fn an_invalidation_matches_by_bytes_or_by_coordinate() {
        let by_bytes = Invalidation::new("a@1", Some("AB".into()), Reason::CveAfter, "", 1, 0, "");
        assert!(by_bytes.matches("renamed@1", "ab"));
        assert!(!by_bytes.matches("a@1", "cd"));
        let by_coord = Invalidation::new("a@1", None, Reason::Pulled, "", 1, 0, "");
        assert!(by_coord.matches("a@1", "anything"));
        assert!(!by_coord.matches("a@2", "anything"));
        assert_eq!(Reason::parse("CVE-after"), Some(Reason::CveAfter));
        assert_eq!(Reason::parse("nope"), None);
    }

    fn ack(downstream: &str) -> SyncAck {
        SyncAck {
            schema: ACK_SCHEMA.into(),
            downstream: downstream.into(),
            upstream: "dmz".into(),
            generation: 4,
            applied_at: 5,
            packages_added: 1,
            invalidations_applied: vec!["inv-1-abc".into()],
            quarantined: vec![],
            carrier: "pull".into(),
        }
    }

    fn trusted(name: &str, k: &SyncKey) -> Vec<TrustedPeer> {
        vec![TrustedPeer {
            name: name.into(),
            key: k.public_hex(),
        }]
    }

    #[test]
    fn an_envelope_that_is_not_one_is_refused_before_any_key_is_read() {
        let k = SyncKey::from_seed([4; 32]);
        let trust = trusted("dmz", &k);
        assert_eq!(open_manifest(b"not json", &trust).unwrap_err().number(), 1);
        let mut s = k.sign_manifest(&manifest("dmz", 1)).unwrap();
        s.schema = "holger.sync-signed/v0".into();
        let e = open_manifest(&s.to_bytes(), &trust).unwrap_err();
        assert_eq!(e.number(), 1);
        assert!(
            e.to_string().contains("expected 'holger.sync-signed/v1'"),
            "{e}"
        );
    }

    #[test]
    fn every_field_of_the_envelope_is_checked() {
        let k = SyncKey::from_seed([5; 32]);
        let trust = trusted("dmz", &k);
        let good = k.sign_manifest(&manifest("dmz", 2)).unwrap();
        assert!(open_manifest(&good.to_bytes(), &trust).is_ok());

        // A trust list whose key is not 32 hex bytes trusts nobody.
        let mut s = good.clone();
        s.key = "zz".into();
        let bad_trust = vec![TrustedPeer {
            name: "dmz".into(),
            key: "zz".into(),
        }];
        let e = open_manifest(&s.to_bytes(), &bad_trust).unwrap_err();
        assert_eq!(e.number(), 2, "{e}");
        assert!(e.to_string().contains("not 32 hex bytes"), "{e}");
        // The key comparison ignores case and spaces around it.
        let shouty = vec![TrustedPeer {
            name: "dmz".into(),
            key: format!(" {} ", k.public_hex().to_uppercase()),
        }];
        assert!(open_manifest(&good.to_bytes(), &shouty).is_ok());

        let mut s = good.clone();
        s.payload = "***".into();
        let e = open_manifest(&s.to_bytes(), &trust).unwrap_err();
        assert_eq!(e.number(), 1);
        assert!(e.to_string().contains("not base64"), "{e}");

        let mut s = good.clone();
        s.signature = "abcd".into();
        let e = open_manifest(&s.to_bytes(), &trust).unwrap_err();
        assert_eq!(
            e,
            SyncError::BadSignature("the signature is not 64 hex bytes".into())
        );

        // A signed payload that is not a manifest.
        let junk =
            Signed::seal(Kind::Manifest, "dmz", &k, &serde_json::json!({"hello": 1})).unwrap();
        let e = open_manifest(&junk.to_bytes(), &trust).unwrap_err();
        assert!(
            matches!(e, SyncError::Malformed(ref m) if m.starts_with("the signed payload")),
            "{e}"
        );

        // A manifest of another schema version, signed correctly.
        let mut old = manifest("dmz", 2);
        old.schema = "holger.sync-manifest/v0".into();
        let e = open_manifest(&k.sign_manifest(&old).unwrap().to_bytes(), &trust).unwrap_err();
        assert!(e.to_string().contains("manifest schema"), "{e}");
    }

    #[test]
    fn an_ack_must_be_an_ack_of_this_schema_naming_its_signer() {
        let k = SyncKey::from_seed([6; 32]);
        let enrolled = trusted("onprem", &k);
        let mut old = ack("onprem");
        old.schema = "holger.sync-ack/v0".into();
        let e = open_ack(&k.sign_ack(&old).unwrap().to_bytes(), &enrolled).unwrap_err();
        assert!(e.to_string().contains("ack schema"), "{e}");
        // An ack naming another downstream, signed as "onprem".
        let lie = Signed::seal(Kind::Ack, "onprem", &k, &ack("elsewhere")).unwrap();
        let e = open_ack(&lie.to_bytes(), &enrolled).unwrap_err();
        assert!(
            e.to_string().contains("names downstream 'elsewhere'"),
            "{e}"
        );
        // An enrolled name with another key.
        let other = SyncKey::from_seed([7; 32]);
        let e = open_ack(
            &other.sign_ack(&ack("onprem")).unwrap().to_bytes(),
            &enrolled,
        )
        .unwrap_err();
        assert_eq!(e.number(), 2);
        assert!(e.to_string().contains("signed with key"), "{e}");
    }

    #[test]
    fn every_error_has_its_own_code_and_says_what_happened() {
        let all = [
            SyncError::Malformed("m".into()),
            SyncError::UntrustedSigner {
                signer: "s".into(),
                why: "w".into(),
            },
            SyncError::BadSignature("b".into()),
            SyncError::Rollback {
                upstream: "dmz".into(),
                applied: 9,
                offered: 3,
            },
            SyncError::UnknownDownstream("d".into()),
            SyncError::FutureGeneration {
                offered: 8,
                issued: 2,
            },
            SyncError::Io("i".into()),
        ];
        let numbers: Vec<u32> = all.iter().map(SyncError::number).collect();
        assert_eq!(numbers, (1..=7).collect::<Vec<_>>());
        let words: Vec<String> = all.iter().map(|e| e.to_string()).collect();
        assert!(
            words[3].contains("generation 3")
                && words[3].contains("generation 9")
                && words[3].contains("rollback")
        );
        assert!(words[5].contains("generation 8") && words[5].contains("up to 2"));
        assert!(words[4].contains("'d'"));
        let mut uniq = words.clone();
        uniq.sort();
        uniq.dedup();
        assert_eq!(uniq.len(), words.len(), "{words:?}");
    }

    #[test]
    fn reasons_read_back_and_invalidations_have_stable_ids() {
        for r in Reason::ALL {
            assert_eq!(
                Reason::parse(&format!("  {}  ", r.as_str().to_uppercase())),
                Some(r)
            );
            assert_eq!(r.to_string(), r.as_str());
            assert!(!r.words().is_empty());
        }
        let a = Invalidation::new(
            "a@1",
            Some(" AB ".into()),
            Reason::Malware,
            "hit",
            7,
            1,
            "sec",
        );
        let b = Invalidation::new(
            "a@1",
            Some("ab".into()),
            Reason::Malware,
            "other words",
            7,
            2,
            "else",
        );
        assert_eq!(
            a.id, b.id,
            "the id is the coordinate, bytes and reason, not the prose"
        );
        assert!(
            a.id.starts_with("inv-7-") && a.id.len() == "inv-7-".len() + 12,
            "{}",
            a.id
        );
        assert_ne!(
            a.id,
            Invalidation::new("a@1", Some("ab".into()), Reason::Pulled, "", 7, 1, "").id
        );
        assert_eq!(a.label(), "a@1 (malware)");
        // An empty sha256 is no sha256: the invalidation names the coordinate.
        let c = Invalidation::new("a@1", Some("  ".into()), Reason::Policy, "", 1, 1, "");
        assert_eq!(c.sha256, None);
        assert!(c.matches("a@1", "ff"));
    }

    #[test]
    fn file_names_on_the_stick_are_safe() {
        let mut m = manifest("dmz/../evil name", 12);
        assert_eq!(
            manifest_file_name(&m),
            "holger-sync-dmz____evil_name-12.json"
        );
        m.upstream = "dmz".into();
        assert_eq!(manifest_file_name(&m), "holger-sync-dmz-12.json");
        assert_eq!(
            ack_file_name(&ack("on/prem")),
            "holger-sync-ack-on_prem-4.json"
        );
    }

    #[test]
    fn state_files_default_when_absent_round_trip_and_refuse_garbage() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("deep/dir/state.json");
        let empty: Vec<String> = read_state(&p).unwrap();
        assert!(empty.is_empty(), "an absent state file is the default");
        write_state(&p, &vec!["a".to_string(), "b".to_string()]).unwrap();
        let back: Vec<String> = read_state(&p).unwrap();
        assert_eq!(back, vec!["a", "b"]);
        let left: Vec<_> = std::fs::read_dir(p.parent().unwrap())
            .unwrap()
            .flatten()
            .map(|e| e.file_name())
            .collect();
        assert_eq!(left.len(), 1, "no temporary file is left behind: {left:?}");
        std::fs::write(&p, b"{ not json").unwrap();
        let e = read_state::<Vec<String>>(&p).unwrap_err();
        assert_eq!(e.number(), 7);
        assert!(e.to_string().contains("not readable state"), "{e}");
        // A directory where the file should be is an error, not a default.
        let e = read_state::<Vec<String>>(d.path()).unwrap_err();
        assert_eq!(e.number(), 7);
        // A key file that is not a key is refused, not replaced.
        let kp = d.path().join("sync-key.pk8");
        std::fs::write(&kp, b"garbage").unwrap();
        assert_eq!(
            SyncKey::open_or_mint(&kp).err().map(|e| e.number()),
            Some(1)
        );
        assert_eq!(std::fs::read(&kp).unwrap(), b"garbage");
    }
}