Skip to main content

gunnar_core/
provenance.rs

1//! **Build provenance, signed** — links 5 and 6 of the chain.
2//!
3//! A push attestation says *how a commit got into this server*. This says *what
4//! was built from it, by whom, and what came out*. Together they are the join
5//! that lets a verifier start at an artifact and finish at a line of source.
6//!
7//! ```text
8//!   artifact sha256 ──▶ BuildRecord ──▶ source commit ──▶ push ──▶ log
9//!        link 7            5 + 6            link 2       link 3   link 4
10//! ```
11//!
12//! # Shape: SLSA's fields, gunnar's signature
13//!
14//! The field set is SLSA provenance's, deliberately, so an export to in-toto is
15//! a rename and not a redesign: a **builder** identity, the **source** the build
16//! was made from, the **recipe** it followed, the **materials** it consumed and
17//! the **subjects** it produced. What is *not* SLSA is the signature envelope:
18//! SLSA leans on DSSE and, in practice, on Sigstore's public trust root. The
19//! roadmap's locked decision is the opposite — gunnar's own issuer and gunnar's
20//! own log, so an on-prem install verifies with no third party — and the one
21//! signature format in this repository is `sshsig`.
22//!
23//! So a record is signed exactly the way a commit and a tree head are, under
24//! its own namespace, and a client can check it with `ssh-keygen -Y verify` and
25//! no gunnar at all.
26//!
27//! # What "hermetic" would add, and what this does not claim
28//!
29//! Nothing here proves a build was hermetic. It records what the builder said
30//! it did: the toolchain, the materials, whether the network was available. A
31//! record that claims `network: false` is a claim by the builder about itself
32//! and is worth exactly as much as the builder is trusted — which is why
33//! [`BuildRecord::builder`] is a principal and not a hostname, and why the
34//! signature binds it. Making the claim *enforceable* is the sandbox's job
35//! (roadmap phase 3) and this type is what it will sign.
36
37use sha2::Digest as _;
38
39/// The `sshsig` namespace a build record is signed under.
40///
41/// Distinct from `git`, `gunnar-grpc` and `gunnar-transparency`: a signature
42/// made under one namespace does not verify under another, so no commit
43/// signature can ever be presented as a build attestation.
44pub const PROVENANCE_NAMESPACE: &str = "gunnar-provenance";
45
46/// One thing a build consumed or produced, named by its content.
47#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct Subject {
50    /// What it is called — a path, an artifact name, an image reference.
51    pub name: String,
52    /// Lowercase-hex SHA-256 of its bytes.
53    ///
54    /// The **only** identity that matters. A name can be reused and a version
55    /// can be re-tagged; a digest cannot, which is why every join in the chain
56    /// is on one.
57    pub sha256: String,
58}
59
60/// What a builder claims about one build.
61#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct BuildRecord {
64    /// The ACL principal that ran the build. **A principal, not a hostname**:
65    /// the question a verifier asks is *whose builder was this*, and a hostname
66    /// answers a different one.
67    pub builder: String,
68    /// `<namespace>/<repo>` the source came from.
69    pub source_repo: String,
70    /// The commit that was built, hex. This is the join to links 2–4.
71    pub source_commit: String,
72    /// What the build was asked to make — a recipe, an atom, a variant name.
73    pub recipe: String,
74    /// The toolchain, as the builder resolved it (`rustc 1.85.0 (…)`).
75    ///
76    /// Free text on purpose: pinning a structure here would make a build with
77    /// an unusual toolchain unrecordable, and an unrecordable build is one that
78    /// happens anyway and is not attested.
79    pub toolchain: String,
80    /// Whether the build had network access.
81    ///
82    /// A **claim by the builder about itself**, and the module docs say what
83    /// that is worth. It is recorded rather than omitted because a record that
84    /// cannot express "this was not hermetic" makes every build look hermetic.
85    pub network: bool,
86    /// What went in: pinned sources, vendored dependencies, base images.
87    pub materials: Vec<Subject>,
88    /// What came out.
89    pub subjects: Vec<Subject>,
90    /// When it started, in Unix milliseconds.
91    pub started_at_unix_ms: i64,
92    /// When it finished, in Unix milliseconds.
93    pub finished_at_unix_ms: i64,
94}
95
96impl BuildRecord {
97    /// The exact bytes that are signed.
98    ///
99    /// A **canonical, line-oriented rendering** rather than the JSON, for the
100    /// same reason a tree head signs one: JSON has no canonical form, so
101    /// signing a serialisation makes the signature depend on the serialiser's
102    /// field order. Every field appears once, in a fixed order, and the lists
103    /// are rendered in the order the builder recorded them.
104    pub fn signing_bytes(&self) -> Vec<u8> {
105        let mut out = String::new();
106        out.push_str("gunnar-provenance-v1\n");
107        out.push_str(&format!("builder {}\n", self.builder));
108        out.push_str(&format!("source-repo {}\n", self.source_repo));
109        out.push_str(&format!("source-commit {}\n", self.source_commit));
110        out.push_str(&format!("recipe {}\n", self.recipe));
111        out.push_str(&format!("toolchain {}\n", self.toolchain));
112        out.push_str(&format!("network {}\n", self.network));
113        out.push_str(&format!("started {}\n", self.started_at_unix_ms));
114        out.push_str(&format!("finished {}\n", self.finished_at_unix_ms));
115        for material in &self.materials {
116            out.push_str(&format!("material {} {}\n", material.sha256, material.name));
117        }
118        for subject in &self.subjects {
119            out.push_str(&format!("subject {} {}\n", subject.sha256, subject.name));
120        }
121        out.into_bytes()
122    }
123
124    /// SHA-256 over [`Self::signing_bytes`], hex — the record's own identity.
125    pub fn digest(&self) -> String {
126        let digest: [u8; 32] = sha2::Sha256::digest(self.signing_bytes()).into();
127        crate::transparency::hex(&digest)
128    }
129
130    /// Does this build claim to have produced `sha256`?
131    pub fn produced(&self, sha256: &str) -> bool {
132        self.subjects.iter().any(|s| s.sha256 == sha256)
133    }
134}
135
136/// A [`BuildRecord`] with the signature that makes it evidence.
137#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct SignedBuild {
140    /// The record the signature is over.
141    #[serde(flatten)]
142    pub record: BuildRecord,
143    /// `SHA256:…` of the key that signed it.
144    pub key_fingerprint: String,
145    /// The `sshsig`, PEM-armoured.
146    pub signature: String,
147}
148
149/// What went wrong verifying a build record.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum BuildProblem {
152    /// The stored form could not be read at all.
153    Malformed(String),
154    /// The record names a key that is not the one offered.
155    WrongKey {
156        /// The fingerprint the record claims.
157        signed_by: String,
158        /// The fingerprint of the key given to check it.
159        offered: String,
160    },
161    /// Well formed, right key, and the signature does not check out.
162    NotVerified,
163}
164
165impl std::fmt::Display for BuildProblem {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        match self {
168            BuildProblem::Malformed(why) => {
169                write!(
170                    f,
171                    "the build record's signature is not a well-formed sshsig: {why}"
172                )
173            }
174            BuildProblem::WrongKey { signed_by, offered } => write!(
175                f,
176                "the build record says it was signed by {signed_by} and was checked against \
177                 {offered}"
178            ),
179            BuildProblem::NotVerified => {
180                write!(f, "the build record's signature does not verify")
181            }
182        }
183    }
184}
185
186/// Sign a build record under [`PROVENANCE_NAMESPACE`].
187pub fn sign_build(
188    key: &ssh_key::PrivateKey,
189    record: BuildRecord,
190) -> Result<SignedBuild, ssh_key::Error> {
191    let signature = key.sign(
192        PROVENANCE_NAMESPACE,
193        ssh_key::HashAlg::Sha512,
194        &record.signing_bytes(),
195    )?;
196    Ok(SignedBuild {
197        key_fingerprint: key
198            .public_key()
199            .fingerprint(ssh_key::HashAlg::Sha256)
200            .to_string(),
201        signature: signature.to_pem(ssh_key::LineEnding::LF)?,
202        record,
203    })
204}
205
206/// Verify a signed build record against a public key.
207///
208/// The fingerprint is checked before the signature, and the namespace is
209/// checked before the verify — both for the same reason a tree head checks
210/// them: a signature made by another key, or for another purpose, must never be
211/// reported as this one verifying.
212pub fn verify_build(signed: &SignedBuild, key: &ssh_key::PublicKey) -> Result<(), BuildProblem> {
213    let offered = key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
214    if offered != signed.key_fingerprint {
215        return Err(BuildProblem::WrongKey {
216            signed_by: signed.key_fingerprint.clone(),
217            offered,
218        });
219    }
220    let signature = ssh_key::SshSig::from_pem(signed.signature.as_bytes())
221        .map_err(|err| BuildProblem::Malformed(err.to_string()))?;
222    if signature.namespace() != PROVENANCE_NAMESPACE {
223        return Err(BuildProblem::Malformed(format!(
224            "namespace is {:?}, not {PROVENANCE_NAMESPACE:?}",
225            signature.namespace()
226        )));
227    }
228    key.verify(
229        PROVENANCE_NAMESPACE,
230        &signed.record.signing_bytes(),
231        &signature,
232    )
233    .map_err(|_| BuildProblem::NotVerified)
234}
235
236/// SHA-256 of bytes, hex.
237///
238/// Here rather than in each caller so there is one place that decides what a
239/// digest of something is, which is the same reason `fingerprint_of_certificate`
240/// lives in one place (one implementation).
241pub fn digest_bytes(bytes: &[u8]) -> String {
242    let digest: [u8; 32] = sha2::Sha256::digest(bytes).into();
243    crate::transparency::hex(&digest)
244}
245
246/// SHA-256 of a file, hex — how every subject and material is named.
247pub fn digest_file(path: &std::path::Path) -> std::io::Result<String> {
248    use std::io::Read as _;
249    let mut file = std::fs::File::open(path)?;
250    let mut hasher = sha2::Sha256::new();
251    // Streamed, not read whole: an artifact is exactly the kind of file that is
252    // large, and a verifier that needs a gigabyte of memory to hash one is a
253    // verifier nobody runs on the machine that has the artifact.
254    let mut buf = vec![0u8; 64 * 1024];
255    loop {
256        let n = file.read(&mut buf)?;
257        if n == 0 {
258            break;
259        }
260        hasher.update(&buf[..n]);
261    }
262    let digest: [u8; 32] = hasher.finalize().into();
263    Ok(crate::transparency::hex(&digest))
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn record() -> BuildRecord {
271        BuildRecord {
272            builder: "runner-1".into(),
273            source_repo: "nordisk/gunnar".into(),
274            source_commit: "a".repeat(40),
275            recipe: "gunnar-server".into(),
276            toolchain: "rustc 1.85.0".into(),
277            network: false,
278            materials: vec![Subject {
279                name: "vendor.tar".into(),
280                sha256: "b".repeat(64),
281            }],
282            subjects: vec![Subject {
283                name: "gunnar".into(),
284                sha256: "c".repeat(64),
285            }],
286            started_at_unix_ms: 1_700_000_000_000,
287            finished_at_unix_ms: 1_700_000_060_000,
288        }
289    }
290
291    #[test]
292    fn a_build_record_verifies_and_every_tampering_is_caught() {
293        let key =
294            ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse");
295        let other =
296            ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse");
297        let signed = sign_build(&key, record()).expect("sign");
298        assert_eq!(verify_build(&signed, key.public_key()), Ok(()));
299
300        assert!(matches!(
301            verify_build(&signed, other.public_key()),
302            Err(BuildProblem::WrongKey { .. })
303        ));
304
305        // **Every signed field must be bound.** A record whose subject digest
306        // can be swapped is a record that attests to a different artifact.
307        for mutate in [
308            |r: &mut BuildRecord| r.source_commit = "d".repeat(40),
309            |r: &mut BuildRecord| r.builder = "somebody-else".into(),
310            |r: &mut BuildRecord| r.network = true,
311            |r: &mut BuildRecord| r.recipe = "another".into(),
312            |r: &mut BuildRecord| r.toolchain = "rustc 1.0.0".into(),
313            |r: &mut BuildRecord| r.subjects[0].sha256 = "e".repeat(64),
314            |r: &mut BuildRecord| r.subjects[0].name = "other".into(),
315            |r: &mut BuildRecord| r.materials[0].sha256 = "f".repeat(64),
316            |r: &mut BuildRecord| r.finished_at_unix_ms += 1,
317        ] {
318            let mut tampered = signed.clone();
319            mutate(&mut tampered.record);
320            assert_eq!(
321                verify_build(&tampered, key.public_key()),
322                Err(BuildProblem::NotVerified),
323                "a field was changed and the signature still verified: {:?}",
324                tampered.record
325            );
326        }
327    }
328
329    /// A signature made under another namespace is refused — which is what
330    /// stops a commit signature being presented as build provenance.
331    #[test]
332    fn a_signature_from_another_namespace_is_refused() {
333        let key =
334            ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse");
335        let record = record();
336        let elsewhere = key
337            .sign("git", ssh_key::HashAlg::Sha512, &record.signing_bytes())
338            .expect("sign");
339        let smuggled = SignedBuild {
340            key_fingerprint: key
341                .public_key()
342                .fingerprint(ssh_key::HashAlg::Sha256)
343                .to_string(),
344            signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("armour"),
345            record,
346        };
347        assert!(matches!(
348            verify_build(&smuggled, key.public_key()),
349            Err(BuildProblem::Malformed(_))
350        ));
351    }
352
353    #[test]
354    fn the_digest_moves_with_the_record_and_produced_answers_on_the_digest() {
355        let a = record();
356        let mut b = record();
357        b.subjects[0].sha256 = "9".repeat(64);
358        assert_ne!(a.digest(), b.digest());
359        assert!(a.produced(&"c".repeat(64)));
360        assert!(!a.produced(&"9".repeat(64)));
361    }
362
363    #[test]
364    fn a_files_digest_is_its_sha256() {
365        let dir = tempfile::tempdir().expect("tempdir");
366        let path = dir.path().join("artifact");
367        std::fs::write(&path, b"hello\n").expect("write");
368        // `sha256sum` of "hello\n".
369        assert_eq!(
370            digest_file(&path).expect("digest"),
371            "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
372        );
373    }
374}