gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
//! **Build provenance, signed** — links 5 and 6 of the chain.
//!
//! A push attestation says *how a commit got into this server*. This says *what
//! was built from it, by whom, and what came out*. Together they are the join
//! that lets a verifier start at an artifact and finish at a line of source.
//!
//! ```text
//!   artifact sha256 ──▶ BuildRecord ──▶ source commit ──▶ push ──▶ log
//!        link 7            5 + 6            link 2       link 3   link 4
//! ```
//!
//! # Shape: SLSA's fields, gunnar's signature
//!
//! The field set is SLSA provenance's, deliberately, so an export to in-toto is
//! a rename and not a redesign: a **builder** identity, the **source** the build
//! was made from, the **recipe** it followed, the **materials** it consumed and
//! the **subjects** it produced. What is *not* SLSA is the signature envelope:
//! SLSA leans on DSSE and, in practice, on Sigstore's public trust root. The
//! roadmap's locked decision is the opposite — gunnar's own issuer and gunnar's
//! own log, so an on-prem install verifies with no third party — and the one
//! signature format in this repository is `sshsig`.
//!
//! So a record is signed exactly the way a commit and a tree head are, under
//! its own namespace, and a client can check it with `ssh-keygen -Y verify` and
//! no gunnar at all.
//!
//! # What "hermetic" would add, and what this does not claim
//!
//! Nothing here proves a build was hermetic. It records what the builder said
//! it did: the toolchain, the materials, whether the network was available. A
//! record that claims `network: false` is a claim by the builder about itself
//! and is worth exactly as much as the builder is trusted — which is why
//! [`BuildRecord::builder`] is a principal and not a hostname, and why the
//! signature binds it. Making the claim *enforceable* is the sandbox's job
//! (roadmap phase 3) and this type is what it will sign.

use sha2::Digest as _;

/// The `sshsig` namespace a build record is signed under.
///
/// Distinct from `git`, `gunnar-grpc` and `gunnar-transparency`: a signature
/// made under one namespace does not verify under another, so no commit
/// signature can ever be presented as a build attestation.
pub const PROVENANCE_NAMESPACE: &str = "gunnar-provenance";

/// One thing a build consumed or produced, named by its content.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Subject {
    /// What it is called — a path, an artifact name, an image reference.
    pub name: String,
    /// Lowercase-hex SHA-256 of its bytes.
    ///
    /// The **only** identity that matters. A name can be reused and a version
    /// can be re-tagged; a digest cannot, which is why every join in the chain
    /// is on one.
    pub sha256: String,
}

/// What a builder claims about one build.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BuildRecord {
    /// The ACL principal that ran the build. **A principal, not a hostname**:
    /// the question a verifier asks is *whose builder was this*, and a hostname
    /// answers a different one.
    pub builder: String,
    /// `<namespace>/<repo>` the source came from.
    pub source_repo: String,
    /// The commit that was built, hex. This is the join to links 2–4.
    pub source_commit: String,
    /// What the build was asked to make — a recipe, an atom, a variant name.
    pub recipe: String,
    /// The toolchain, as the builder resolved it (`rustc 1.85.0 (…)`).
    ///
    /// Free text on purpose: pinning a structure here would make a build with
    /// an unusual toolchain unrecordable, and an unrecordable build is one that
    /// happens anyway and is not attested.
    pub toolchain: String,
    /// Whether the build had network access.
    ///
    /// A **claim by the builder about itself**, and the module docs say what
    /// that is worth. It is recorded rather than omitted because a record that
    /// cannot express "this was not hermetic" makes every build look hermetic.
    pub network: bool,
    /// What went in: pinned sources, vendored dependencies, base images.
    pub materials: Vec<Subject>,
    /// What came out.
    pub subjects: Vec<Subject>,
    /// When it started, in Unix milliseconds.
    pub started_at_unix_ms: i64,
    /// When it finished, in Unix milliseconds.
    pub finished_at_unix_ms: i64,
}

impl BuildRecord {
    /// The exact bytes that are signed.
    ///
    /// A **canonical, line-oriented rendering** rather than the JSON, for the
    /// same reason a tree head signs one: JSON has no canonical form, so
    /// signing a serialisation makes the signature depend on the serialiser's
    /// field order. Every field appears once, in a fixed order, and the lists
    /// are rendered in the order the builder recorded them.
    pub fn signing_bytes(&self) -> Vec<u8> {
        let mut out = String::new();
        out.push_str("gunnar-provenance-v1\n");
        out.push_str(&format!("builder {}\n", self.builder));
        out.push_str(&format!("source-repo {}\n", self.source_repo));
        out.push_str(&format!("source-commit {}\n", self.source_commit));
        out.push_str(&format!("recipe {}\n", self.recipe));
        out.push_str(&format!("toolchain {}\n", self.toolchain));
        out.push_str(&format!("network {}\n", self.network));
        out.push_str(&format!("started {}\n", self.started_at_unix_ms));
        out.push_str(&format!("finished {}\n", self.finished_at_unix_ms));
        for material in &self.materials {
            out.push_str(&format!("material {} {}\n", material.sha256, material.name));
        }
        for subject in &self.subjects {
            out.push_str(&format!("subject {} {}\n", subject.sha256, subject.name));
        }
        out.into_bytes()
    }

    /// SHA-256 over [`Self::signing_bytes`], hex — the record's own identity.
    pub fn digest(&self) -> String {
        let digest: [u8; 32] = sha2::Sha256::digest(self.signing_bytes()).into();
        crate::transparency::hex(&digest)
    }

    /// Does this build claim to have produced `sha256`?
    pub fn produced(&self, sha256: &str) -> bool {
        self.subjects.iter().any(|s| s.sha256 == sha256)
    }
}

/// A [`BuildRecord`] with the signature that makes it evidence.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedBuild {
    /// The record the signature is over.
    #[serde(flatten)]
    pub record: BuildRecord,
    /// `SHA256:…` of the key that signed it.
    pub key_fingerprint: String,
    /// The `sshsig`, PEM-armoured.
    pub signature: String,
}

/// What went wrong verifying a build record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildProblem {
    /// The stored form could not be read at all.
    Malformed(String),
    /// The record names a key that is not the one offered.
    WrongKey {
        /// The fingerprint the record claims.
        signed_by: String,
        /// The fingerprint of the key given to check it.
        offered: String,
    },
    /// Well formed, right key, and the signature does not check out.
    NotVerified,
}

impl std::fmt::Display for BuildProblem {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            BuildProblem::Malformed(why) => {
                write!(
                    f,
                    "the build record's signature is not a well-formed sshsig: {why}"
                )
            }
            BuildProblem::WrongKey { signed_by, offered } => write!(
                f,
                "the build record says it was signed by {signed_by} and was checked against \
                 {offered}"
            ),
            BuildProblem::NotVerified => {
                write!(f, "the build record's signature does not verify")
            }
        }
    }
}

/// Sign a build record under [`PROVENANCE_NAMESPACE`].
pub fn sign_build(
    key: &ssh_key::PrivateKey,
    record: BuildRecord,
) -> Result<SignedBuild, ssh_key::Error> {
    let signature = key.sign(
        PROVENANCE_NAMESPACE,
        ssh_key::HashAlg::Sha512,
        &record.signing_bytes(),
    )?;
    Ok(SignedBuild {
        key_fingerprint: key
            .public_key()
            .fingerprint(ssh_key::HashAlg::Sha256)
            .to_string(),
        signature: signature.to_pem(ssh_key::LineEnding::LF)?,
        record,
    })
}

/// Verify a signed build record against a public key.
///
/// The fingerprint is checked before the signature, and the namespace is
/// checked before the verify — both for the same reason a tree head checks
/// them: a signature made by another key, or for another purpose, must never be
/// reported as this one verifying.
pub fn verify_build(signed: &SignedBuild, key: &ssh_key::PublicKey) -> Result<(), BuildProblem> {
    let offered = key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
    if offered != signed.key_fingerprint {
        return Err(BuildProblem::WrongKey {
            signed_by: signed.key_fingerprint.clone(),
            offered,
        });
    }
    let signature = ssh_key::SshSig::from_pem(signed.signature.as_bytes())
        .map_err(|err| BuildProblem::Malformed(err.to_string()))?;
    if signature.namespace() != PROVENANCE_NAMESPACE {
        return Err(BuildProblem::Malformed(format!(
            "namespace is {:?}, not {PROVENANCE_NAMESPACE:?}",
            signature.namespace()
        )));
    }
    key.verify(
        PROVENANCE_NAMESPACE,
        &signed.record.signing_bytes(),
        &signature,
    )
    .map_err(|_| BuildProblem::NotVerified)
}

/// SHA-256 of bytes, hex.
///
/// Here rather than in each caller so there is one place that decides what a
/// digest of something is, which is the same reason `fingerprint_of_certificate`
/// lives in one place (one implementation).
pub fn digest_bytes(bytes: &[u8]) -> String {
    let digest: [u8; 32] = sha2::Sha256::digest(bytes).into();
    crate::transparency::hex(&digest)
}

/// SHA-256 of a file, hex — how every subject and material is named.
pub fn digest_file(path: &std::path::Path) -> std::io::Result<String> {
    use std::io::Read as _;
    let mut file = std::fs::File::open(path)?;
    let mut hasher = sha2::Sha256::new();
    // Streamed, not read whole: an artifact is exactly the kind of file that is
    // large, and a verifier that needs a gigabyte of memory to hash one is a
    // verifier nobody runs on the machine that has the artifact.
    let mut buf = vec![0u8; 64 * 1024];
    loop {
        let n = file.read(&mut buf)?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    let digest: [u8; 32] = hasher.finalize().into();
    Ok(crate::transparency::hex(&digest))
}

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

    fn record() -> BuildRecord {
        BuildRecord {
            builder: "runner-1".into(),
            source_repo: "nordisk/gunnar".into(),
            source_commit: "a".repeat(40),
            recipe: "gunnar-server".into(),
            toolchain: "rustc 1.85.0".into(),
            network: false,
            materials: vec![Subject {
                name: "vendor.tar".into(),
                sha256: "b".repeat(64),
            }],
            subjects: vec![Subject {
                name: "gunnar".into(),
                sha256: "c".repeat(64),
            }],
            started_at_unix_ms: 1_700_000_000_000,
            finished_at_unix_ms: 1_700_000_060_000,
        }
    }

    #[test]
    fn a_build_record_verifies_and_every_tampering_is_caught() {
        let key =
            ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse");
        let other =
            ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse");
        let signed = sign_build(&key, record()).expect("sign");
        assert_eq!(verify_build(&signed, key.public_key()), Ok(()));

        assert!(matches!(
            verify_build(&signed, other.public_key()),
            Err(BuildProblem::WrongKey { .. })
        ));

        // **Every signed field must be bound.** A record whose subject digest
        // can be swapped is a record that attests to a different artifact.
        for mutate in [
            |r: &mut BuildRecord| r.source_commit = "d".repeat(40),
            |r: &mut BuildRecord| r.builder = "somebody-else".into(),
            |r: &mut BuildRecord| r.network = true,
            |r: &mut BuildRecord| r.recipe = "another".into(),
            |r: &mut BuildRecord| r.toolchain = "rustc 1.0.0".into(),
            |r: &mut BuildRecord| r.subjects[0].sha256 = "e".repeat(64),
            |r: &mut BuildRecord| r.subjects[0].name = "other".into(),
            |r: &mut BuildRecord| r.materials[0].sha256 = "f".repeat(64),
            |r: &mut BuildRecord| r.finished_at_unix_ms += 1,
        ] {
            let mut tampered = signed.clone();
            mutate(&mut tampered.record);
            assert_eq!(
                verify_build(&tampered, key.public_key()),
                Err(BuildProblem::NotVerified),
                "a field was changed and the signature still verified: {:?}",
                tampered.record
            );
        }
    }

    /// A signature made under another namespace is refused — which is what
    /// stops a commit signature being presented as build provenance.
    #[test]
    fn a_signature_from_another_namespace_is_refused() {
        let key =
            ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse");
        let record = record();
        let elsewhere = key
            .sign("git", ssh_key::HashAlg::Sha512, &record.signing_bytes())
            .expect("sign");
        let smuggled = SignedBuild {
            key_fingerprint: key
                .public_key()
                .fingerprint(ssh_key::HashAlg::Sha256)
                .to_string(),
            signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("armour"),
            record,
        };
        assert!(matches!(
            verify_build(&smuggled, key.public_key()),
            Err(BuildProblem::Malformed(_))
        ));
    }

    #[test]
    fn the_digest_moves_with_the_record_and_produced_answers_on_the_digest() {
        let a = record();
        let mut b = record();
        b.subjects[0].sha256 = "9".repeat(64);
        assert_ne!(a.digest(), b.digest());
        assert!(a.produced(&"c".repeat(64)));
        assert!(!a.produced(&"9".repeat(64)));
    }

    #[test]
    fn a_files_digest_is_its_sha256() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("artifact");
        std::fs::write(&path, b"hello\n").expect("write");
        // `sha256sum` of "hello\n".
        assert_eq!(
            digest_file(&path).expect("digest"),
            "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
        );
    }
}