use std::path::Path;
use sigstore_trust_root::TrustedRoot;
use crate::forge;
use crate::model::{Artifact, 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)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct VerifiedArtifact {
#[serde(flatten)]
pub artifact: Artifact,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Verified {
pub project: String,
pub version: String,
pub published_at: String,
pub scheme: Scheme,
pub key_id: String,
#[serde(skip_serializing_if = "std::clone::Clone::clone")]
pub pin_workflow: bool,
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,
pub checked_assets: Vec<String>,
pub asset_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub resources: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<String>,
pub artifacts: Vec<VerifiedArtifact>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct VerifyReport<'a> {
#[serde(flatten)]
pub verified: &'a Verified,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_repository: Option<&'a sigstore::SourceRepository>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedList {
pub list: ReleaseListStatement,
pub scheme: Scheme,
pub key_id: String,
pub issuer: Option<String>,
pub pin_workflow: bool,
pub logged_at: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("statement is not well-formed: {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_artifacts = Vec::new();
let mut checked_assets = 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);
match declared_size {
Some(declared) if declared != size => {
return Err(Error::Artifact {
name,
why: format!("size is {size}, document says {declared}"),
});
}
Some(_) => checked_artifacts.push(name),
None => checked_assets.push(name),
}
}
let artifact_count = statement.predicate.artifacts.len();
Ok(Verified {
project: statement.predicate.project.clone(),
version: statement.predicate.version.clone(),
published_at: statement.predicate.published_at.clone(),
scheme,
key_id,
pin_workflow: statement.predicate.identity.pins_workflow(),
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,
artifact_count,
checked_assets,
asset_count: statement.subject.len() - artifact_count,
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(),
artifacts: statement
.predicate
.artifacts
.iter()
.map(|a| VerifiedArtifact {
artifact: a.clone(),
sha256: statement.digest_of(&a.name).unwrap_or_default().to_string(),
})
.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)?;
let pin_workflow = list.predicate.identity.pins_workflow();
Ok(VerifiedList {
list,
scheme,
key_id,
issuer,
pin_workflow,
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),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Claimed {
pub predicate_type: String,
pub project: String,
pub version: Option<String>,
}
pub fn peek_unverified(bundle: &str) -> Result<Claimed, Error> {
#[derive(serde::Deserialize)]
struct Peek {
#[serde(rename = "predicateType")]
predicate_type: String,
predicate: PeekPredicate,
}
#[derive(serde::Deserialize)]
struct PeekPredicate {
project: String,
version: Option<String>,
}
let payload = sigstore::peek_statement(bundle)?;
let peek: Peek = serde_json::from_slice(&payload)?;
let is_release = peek.predicate_type == crate::model::PREDICATE_TYPE;
Ok(Claimed {
predicate_type: peek.predicate_type,
project: peek.predicate.project,
version: peek.predicate.version.filter(|_| is_release),
})
}
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_source(
expected,
&verified.project,
&verified.key_id,
verified.issuer.as_deref(),
source.as_ref(),
)?
.declaring_pin_workflow(verified.pin_workflow);
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_source(
expected,
&verified.list.predicate.project,
&verified.key_id,
verified.issuer.as_deref(),
source.as_ref(),
)?
.declaring_pin_workflow(verified.pin_workflow);
Ok(ForgeVerified { verified, check })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::forge::{Continuity, Evidence, Expected, ForgePin, IdentityError, PinSource};
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_verified_report_lists_every_artifact_with_its_signed_digest() {
let root = sigstore::trusted_root(None).unwrap();
let ok =
verify_forge(HK, &Expected::new("github.com/jdx/hk"), options(&root), &[]).unwrap();
let report = &ok.verified;
assert_eq!(report.artifacts.len(), report.artifact_count);
let linux = report
.artifacts
.iter()
.find(|a| a.artifact.name == "hk-x86_64-unknown-linux-gnu.tar.gz")
.unwrap();
assert_eq!(linux.artifact.os.as_deref(), Some("linux"));
assert_eq!(linux.artifact.arch.as_deref(), Some("x86_64"));
assert_eq!(linux.sha256.len(), 64);
let json = serde_json::to_value(report).unwrap();
let first = &json["artifacts"][0];
assert!(first["name"].is_string() && first["sha256"].is_string());
assert!(first["size"].is_u64());
}
#[cfg(feature = "schema")]
#[test]
fn the_report_schema_requires_only_fields_the_report_always_has() {
let root = sigstore::trusted_root(None).unwrap();
let ok =
verify_forge(HK, &Expected::new("github.com/jdx/hk"), options(&root), &[]).unwrap();
let report = serde_json::to_value(&ok.verified).unwrap();
assert!(report.get("pin_workflow").is_none());
let schema = crate::verify_report_schema();
for key in schema["required"].as_array().unwrap() {
let key = key.as_str().unwrap();
assert!(report.get(key).is_some(), "{key} is required but absent");
}
let artifact_schema = &schema["$defs"]["VerifiedArtifact"]["required"];
for key in artifact_schema.as_array().unwrap() {
let key = key.as_str().unwrap();
assert!(report["artifacts"][0].get(key).is_some(), "{key} absent");
}
}
#[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::of("github.com/jdx/hk", "922514152"));
assert!(
ok.check
.continues_signer("https://github.com/jdx/hk/.github/workflows/release.yml")
);
let ok =
verify_forge(HK, &Expected::new("github.com/JDX/HK"), options(&root), &[]).unwrap();
assert_eq!(ok.check.continuity, Continuity::Same);
let prefix = |prefix: &str| Policy {
issuer: Some(sigstore::GITHUB_ISSUER.into()),
identity: None,
identity_prefix: Some(prefix.into()),
};
let policy = prefix("https://github.com/JDX/HK/");
assert!(verify(HK, &Trust::Identity(&policy), options(&root), &[]).is_ok());
let policy = prefix("https://github.com/jdx/hk/.GITHUB/");
assert!(matches!(
verify(HK, &Trust::Identity(&policy), options(&root), &[]),
Err(Error::Sigstore(sigstore::Error::IdentityPrefix { .. }))
));
let exact = |identity: &str| Policy {
issuer: Some(sigstore::GITHUB_ISSUER.into()),
identity: Some(identity.into()),
identity_prefix: None,
};
let policy =
exact("https://github.com/JDX/HK/.github/workflows/release.yml@refs/tags/v2.3.0");
assert!(verify(HK, &Trust::Identity(&policy), options(&root), &[]).is_ok());
let policy =
exact("https://github.com/jdx/hk/.github/workflows/Release.yml@refs/tags/v2.3.0");
assert!(matches!(
verify(HK, &Trust::Identity(&policy), options(&root), &[]),
Err(Error::Sigstore(sigstore::Error::Verification(_)))
));
let pinned = ForgePin::of("github.com/jdx/hook", "922514152");
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::of("github.com/jdx/hk", "1");
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}"
);
}
#[test]
fn a_published_release_is_held_to_every_pin() {
let root = sigstore::trusted_root(None).unwrap();
let pins = [
(
PinSource::Local,
ForgePin::of("github.com/jdx/hk", "922514152"),
),
(PinSource::Lockfile, ForgePin::of("github.com/jdx/hk", "1")),
];
let err = verify_forge(
HK,
&Expected::new("github.com/jdx/hk").pinned_by(&pins),
options(&root),
&[],
)
.unwrap_err();
assert!(
matches!(
err,
ForgeError::Identity(IdentityError::DifferentRepository {
evidence: Evidence::LockfilePin,
..
})
),
"{err}"
);
let ok = verify_forge(
HK,
&Expected::new("github.com/jdx/hk").pinned_by(&pins[..1]),
options(&root),
&[],
)
.unwrap();
assert_eq!(ok.check.continuity, Continuity::Same);
}
#[test]
fn a_bundle_claims_a_project_before_it_is_verified() {
let claimed = peek_unverified(HK).unwrap();
assert_eq!(
claimed,
Claimed {
predicate_type: crate::model::PREDICATE_TYPE.into(),
project: "github.com/jdx/hk".into(),
version: Some("2.3.0".into()),
}
);
use base64::Engine as _;
let mut bundle: serde_json::Value = serde_json::from_str(HK).unwrap();
let list = serde_json::json!({
"_type": crate::model::STATEMENT_TYPE,
"subject": [],
"predicateType": crate::model::RELEASES_PREDICATE_TYPE,
"predicate": { "project": "github.com/evil/x", "version": "9.9.9" },
});
bundle["dsseEnvelope"]["payload"] = base64::engine::general_purpose::STANDARD
.encode(list.to_string())
.into();
let forged = bundle.to_string();
let claimed = peek_unverified(&forged).unwrap();
assert_eq!(claimed.project, "github.com/evil/x");
assert_eq!(claimed.version, None, "a release list has no version");
let root = sigstore::trusted_root(None).unwrap();
assert!(
verify_forge_release_list(&forged, &Expected::new("github.com/evil/x"), options(&root))
.is_err()
);
assert!(peek_unverified("not a bundle").is_err());
}
}