use std::path::Path;
use sigstore_trust_root::TrustedRoot;
use crate::forge;
use crate::model::{InvalidDocument, ReleaseListStatement, Scheme, Statement};
use crate::sigstore::{self, Policy, SignedBy, Trust};
#[derive(Debug, Clone, Copy)]
pub struct Options<'a> {
pub require_log: bool,
pub trusted_root: &'a TrustedRoot,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Verified {
pub project: String,
pub version: String,
pub published_at: String,
pub scheme: Scheme,
pub key_id: String,
pub attested_by: crate::model::Attestor,
pub prerelease: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logged_at: Option<String>,
pub provenance_linked: bool,
pub checked_artifacts: Vec<String>,
pub artifact_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub resources: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedList {
pub list: ReleaseListStatement,
pub scheme: Scheme,
pub key_id: String,
pub issuer: Option<String>,
pub logged_at: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("statement is not valid JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("statement is invalid: {0}")]
Invalid(#[from] InvalidDocument),
#[error("statement declares scheme {declared} but the bundle was signed by {actual}")]
SchemeMismatch {
declared: Scheme,
actual: &'static str,
},
#[error("statement declares signer {declared:?}, the bundle was signed by {actual:?}")]
DeclaredSignerMismatch { declared: String, actual: String },
#[error("statement declares issuer {declared:?}, the certificate says {actual:?}")]
DeclaredIssuerMismatch { declared: String, actual: String },
#[error("{0}")]
Sigstore(#[from] sigstore::Error),
#[error("artifact {name}: {why}")]
Artifact { name: String, why: String },
}
fn check_declared(
identity: &crate::model::Identity,
signed_by: &SignedBy,
) -> Result<(Scheme, String, Option<String>), Error> {
match (identity.scheme, signed_by) {
(
Scheme::SigstoreOidc,
SignedBy::Identity {
identity: actual,
issuer,
},
) => {
if identity.key_id != *actual {
return Err(Error::DeclaredSignerMismatch {
declared: identity.key_id.clone(),
actual: actual.clone(),
});
}
if let Some(declared) = &identity.issuer
&& declared != issuer
{
return Err(Error::DeclaredIssuerMismatch {
declared: declared.clone(),
actual: issuer.clone(),
});
}
Ok((Scheme::SigstoreOidc, actual.clone(), Some(issuer.clone())))
}
(Scheme::SigstoreKey, SignedBy::Key { key_id }) => {
if !identity.key_id.eq_ignore_ascii_case(key_id) {
return Err(Error::DeclaredSignerMismatch {
declared: identity.key_id.clone(),
actual: key_id.clone(),
});
}
Ok((Scheme::SigstoreKey, key_id.clone(), None))
}
(declared, SignedBy::Identity { .. }) => Err(Error::SchemeMismatch {
declared,
actual: "an OIDC identity",
}),
(declared, SignedBy::Key { .. }) => Err(Error::SchemeMismatch {
declared,
actual: "a key",
}),
}
}
fn logged_at(integrated_time: Option<i64>) -> Option<String> {
integrated_time
.and_then(|t| jiff::Timestamp::from_second(t).ok())
.map(|t| t.to_string())
}
pub fn verify(
bundle: &str,
trust: &Trust<'_>,
options: Options<'_>,
artifacts: &[&Path],
) -> Result<Verified, Error> {
let verified = sigstore::verify(bundle, trust, options.require_log, options.trusted_root)?;
let statement: Statement = serde_json::from_slice(&verified.statement)?;
statement.validate()?;
let version = crate::model::parse_version(&statement.predicate.version)?;
let (scheme, key_id, issuer) =
check_declared(&statement.predicate.identity, &verified.signed_by)?;
let mut checked = Vec::new();
for path in artifacts {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let Some(expected) = statement.digest_of(&name) else {
return Err(Error::Artifact {
name,
why: "not listed in the document".into(),
});
};
let (actual, size) = crate::digest_file(path).map_err(|e| Error::Artifact {
name: name.clone(),
why: e.to_string(),
})?;
if actual != expected {
return Err(Error::Artifact {
name,
why: format!("sha256 is {actual}, document says {expected}"),
});
}
let declared_size = statement
.predicate
.artifacts
.iter()
.find(|a| a.name == name)
.map(|a| a.size);
if let Some(declared) = declared_size
&& declared != size
{
return Err(Error::Artifact {
name,
why: format!("size is {size}, document says {declared}"),
});
}
checked.push(name);
}
Ok(Verified {
project: statement.predicate.project.clone(),
version: statement.predicate.version.clone(),
published_at: statement.predicate.published_at.clone(),
scheme,
key_id,
attested_by: statement.predicate.attested_by,
prerelease: !version.pre.is_empty(),
channel: crate::model::channel(&version).map(str::to_string),
issuer,
logged_at: logged_at(verified.integrated_time),
provenance_linked: statement.provenance_linked(),
checked_artifacts: checked,
artifact_count: statement.predicate.artifacts.len(),
resources: statement
.predicate
.resources
.iter()
.map(|r| {
format!(
"{} ({})",
r.label(),
r.source().map_or("?".to_string(), |s| s.to_string())
)
})
.collect(),
requires: statement
.predicate
.artifacts
.iter()
.filter_map(|a| {
let requires = a.requires.as_ref().filter(|r| !r.is_empty())?;
Some(format!("{}: {}", a.name, requires.summary()))
})
.collect(),
})
}
pub fn verify_release_list(
bundle: &str,
trust: &Trust<'_>,
options: Options<'_>,
) -> Result<VerifiedList, Error> {
let verified = sigstore::verify(bundle, trust, options.require_log, options.trusted_root)?;
let list: ReleaseListStatement = serde_json::from_slice(&verified.statement)?;
list.validate()?;
let (scheme, key_id, issuer) = check_declared(&list.predicate.identity, &verified.signed_by)?;
Ok(VerifiedList {
list,
scheme,
key_id,
issuer,
logged_at: logged_at(verified.integrated_time),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ForgeVerified<T> {
pub verified: T,
pub check: forge::Check,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ForgeError {
#[error(transparent)]
Verify(#[from] Error),
#[error(transparent)]
Identity(#[from] forge::IdentityError),
}
fn forge_policy(bundle: &str, expected: &forge::Expected<'_>) -> Result<Policy, ForgeError> {
let payload = sigstore::peek_statement(bundle).map_err(Error::from)?;
let peeked: serde_json::Value = serde_json::from_slice(&payload).map_err(Error::from)?;
let signed = peeked["predicate"]["project"].as_str().unwrap_or_default();
forge::policy(expected.project, signed)
.ok_or_else(|| forge::IdentityError::NotForge(expected.project.to_string()).into())
}
pub fn verify_forge(
bundle: &str,
expected: &forge::Expected<'_>,
options: Options<'_>,
artifacts: &[&Path],
) -> Result<ForgeVerified<Verified>, ForgeError> {
let policy = forge_policy(bundle, expected)?;
let verified = verify(bundle, &Trust::Identity(&policy), options, artifacts)?;
let source = sigstore::source_repository(bundle).map_err(Error::from)?;
let check = forge::check(
expected,
&verified.project,
&verified.key_id,
verified.issuer.as_deref(),
source.as_ref(),
)?;
Ok(ForgeVerified { verified, check })
}
pub fn verify_forge_release_list(
bundle: &str,
expected: &forge::Expected<'_>,
options: Options<'_>,
) -> Result<ForgeVerified<VerifiedList>, ForgeError> {
let policy = forge_policy(bundle, expected)?;
let verified = verify_release_list(bundle, &Trust::Identity(&policy), options)?;
let source = sigstore::source_repository(bundle).map_err(Error::from)?;
let check = forge::check(
expected,
&verified.list.predicate.project,
&verified.key_id,
verified.issuer.as_deref(),
source.as_ref(),
)?;
Ok(ForgeVerified { verified, check })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::forge::{Continuity, Evidence, Expected, ForgePin, IdentityError};
const HK: &str = include_str!("../tests/fixtures/hk-v2.3.0.sigstore.json");
fn options(root: &TrustedRoot) -> Options<'_> {
Options {
require_log: true,
trusted_root: root,
}
}
#[test]
fn a_published_certificate_records_the_repository_ids() {
let source = sigstore::source_repository(HK).unwrap().unwrap();
assert_eq!(source.uri, "https://github.com/jdx/hk");
assert_eq!(source.id.as_deref(), Some("922514152"));
assert_eq!(source.owner_uri.as_deref(), Some("https://github.com/jdx"));
assert_eq!(source.owner_id.as_deref(), Some("216188"));
}
#[test]
fn a_published_release_verifies_by_repository_id() {
let root = sigstore::trusted_root(None).unwrap();
let ok =
verify_forge(HK, &Expected::new("github.com/jdx/hk"), options(&root), &[]).unwrap();
assert_eq!(ok.verified.project, "github.com/jdx/hk");
assert_eq!(ok.check.continuity, Continuity::Same);
let pin = ok.check.pin.clone().unwrap();
assert_eq!(
pin,
ForgePin::new("github.com/jdx/hk", "922514152", Some("216188".into()))
);
assert!(
ok.check
.continues_signer("https://github.com/jdx/hk/.github/workflows/release.yml")
);
let pinned = ForgePin::new("github.com/jdx/hook", "922514152", Some("216188".into()));
let ok = verify_forge(
HK,
&Expected::new("github.com/jdx/hook").pinned(Some(&pinned)),
options(&root),
&[],
)
.unwrap();
assert_eq!(
ok.check.continuity,
Continuity::Renamed {
requested: "github.com/jdx/hook".into(),
signed: "github.com/jdx/hk".into(),
}
);
let err = verify_forge(
HK,
&Expected::new("github.com/jdx/hook"),
options(&root),
&[],
)
.unwrap_err();
assert!(
matches!(
err,
ForgeError::Identity(IdentityError::ProjectMismatch { .. })
),
"{err}"
);
let other = ForgePin::new("github.com/jdx/hk", "1", Some("216188".into()));
let err = verify_forge(
HK,
&Expected::new("github.com/jdx/hk").pinned(Some(&other)),
options(&root),
&[],
)
.unwrap_err();
assert!(
matches!(
err,
ForgeError::Identity(IdentityError::DifferentRepository {
evidence: Evidence::Pin,
..
})
),
"{err}"
);
let err = verify_forge(
HK,
&Expected::new("github.com/jdx/hk/tool"),
options(&root),
&[],
)
.unwrap_err();
assert!(
matches!(
err,
ForgeError::Identity(IdentityError::ProjectMismatch { .. })
),
"{err}"
);
let err = verify_forge(HK, &Expected::new("hk.jdx.dev"), options(&root), &[]).unwrap_err();
assert!(
matches!(err, ForgeError::Identity(IdentityError::NotForge(_))),
"{err}"
);
}
}