use sha2::Digest as _;
pub const PROVENANCE_NAMESPACE: &str = "gunnar-provenance";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Subject {
pub name: String,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BuildRecord {
pub builder: String,
pub source_repo: String,
pub source_commit: String,
pub recipe: String,
pub toolchain: String,
pub network: bool,
pub materials: Vec<Subject>,
pub subjects: Vec<Subject>,
pub started_at_unix_ms: i64,
pub finished_at_unix_ms: i64,
}
impl BuildRecord {
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()
}
pub fn digest(&self) -> String {
let digest: [u8; 32] = sha2::Sha256::digest(self.signing_bytes()).into();
crate::transparency::hex(&digest)
}
pub fn produced(&self, sha256: &str) -> bool {
self.subjects.iter().any(|s| s.sha256 == sha256)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedBuild {
#[serde(flatten)]
pub record: BuildRecord,
pub key_fingerprint: String,
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildProblem {
Malformed(String),
WrongKey {
signed_by: String,
offered: String,
},
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")
}
}
}
}
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,
})
}
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)
}
pub fn digest_bytes(bytes: &[u8]) -> String {
let digest: [u8; 32] = sha2::Sha256::digest(bytes).into();
crate::transparency::hex(&digest)
}
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();
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 { .. })
));
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
);
}
}
#[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");
assert_eq!(
digest_file(&path).expect("digest"),
"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
);
}
}