///|
/// The identity claims of a Fulcio certificate. Each value is the text of its
/// extension (`1.3.6.1.4.1.57264.1.*`), or `None` when the extension is absent.
pub struct CertificateClaims {
/// The OIDC issuer (`.1.8`, else `.1.1`).
issuer : String?
/// The URI or email subject alternative names.
subject_names : Array[String]
build_signer_uri : String?
build_signer_digest : String?
runner_environment : String?
source_repository_uri : String?
source_repository_digest : String?
source_repository_ref : String?
source_repository_identifier : String?
source_repository_owner_uri : String?
source_repository_owner_identifier : String?
build_config_uri : String?
build_config_digest : String?
build_trigger : String?
run_invocation_uri : String?
source_repository_visibility : String?
} derive(Eq, Debug)
///|
pub extend CertificateClaims with Eq::{equal, not_equal}
///|
pub extend CertificateClaims with @debug.Debug::{to_repr}
///|
fn read_claims(leaf : @x509.Certificate) -> CertificateClaims raise @x509.X509Error {
fn claim(arc : Int) -> String? raise @x509.X509Error {
match leaf.extension("1.3.6.1.4.1.57264.1.\{arc}") {
Some(extension) => Some(@x509.fulcio_value(extension))
None => None
}
}
let issuer = match claim(8) {
Some(value) => Some(value)
None => claim(1)
}
{
issuer,
subject_names: [..leaf.san_uris, ..leaf.san_emails],
build_signer_uri: claim(9),
build_signer_digest: claim(10),
runner_environment: claim(11),
source_repository_uri: claim(12),
source_repository_digest: claim(13),
source_repository_ref: claim(14),
source_repository_identifier: claim(15),
source_repository_owner_uri: claim(16),
source_repository_owner_identifier: claim(17),
build_config_uri: claim(18),
build_config_digest: claim(19),
build_trigger: claim(20),
run_invocation_uri: claim(21),
source_repository_visibility: claim(22),
}
}
///|
priv enum Identity {
ExactName(SubjectName)
SignerWorkflow(GitHubRepository, WorkflowPath, GitRef?)
}
///|
/// A publisher identity that a signing certificate must have. A policy is an
/// immutable value; each method returns a new policy with one more claim.
pub struct Policy {
priv issuer : Issuer
priv identity : Identity
priv source_repository : GitHubRepository?
priv source_owner : GitHubOwner?
priv repository_id : GitHubId?
priv owner_id : GitHubId?
priv source_ref : GitRef?
priv hosted_runner_only : Bool
priv predicate_type : PredicateType?
}
///|
fn Policy::new(issuer : Issuer, identity : Identity) -> Policy {
{
issuer,
identity,
source_repository: None,
source_owner: None,
repository_id: None,
owner_id: None,
source_ref: None,
hosted_runner_only: false,
predicate_type: None,
}
}
///|
/// A policy that requires the exact subject alternative name `name`.
pub fn Policy::subject_name(
name : SubjectName,
issuer? : Issuer = Issuer::github_actions(),
) -> Policy {
Policy::new(issuer, ExactName(name))
}
///|
/// A policy that requires the signer workflow `path` in `repository`, at `git_ref`
/// when it is given, else at any ref. It matches the SAN
/// `https://github.com///@`.
pub fn Policy::signer_workflow(
repository : GitHubRepository,
path : WorkflowPath,
git_ref? : GitRef,
issuer? : Issuer = Issuer::github_actions(),
) -> Policy {
Policy::new(issuer, SignerWorkflow(repository, path, git_ref))
}
///|
/// The usual policy for a GitHub release: the workflow `workflow` of
/// `repository` signed it, from the source of that repository, on a
/// GitHub-hosted runner unless `hosted_runner_only` is false, and the statement
/// has the predicate type `predicate_type` (by default SLSA provenance v1, as
/// `gh attestation verify` requires). So an SBOM attestation of the same
/// workflow does not pass as provenance.
pub fn Policy::github_workflow(
repository : GitHubRepository,
workflow~ : WorkflowPath,
git_ref? : GitRef,
hosted_runner_only? : Bool = true,
predicate_type? : PredicateType = PredicateType::slsa_provenance_v1(),
) -> Policy {
let policy = Policy::signer_workflow(repository, workflow, git_ref?)
.source_repository(repository)
.predicate_type(predicate_type)
if hosted_runner_only {
policy.hosted_runners_only()
} else {
policy
}
}
///|
/// Also require an in-toto statement with the predicate type `predicate_type`.
/// A message-signature bundle has no statement, so it does not match.
pub fn Policy::predicate_type(self : Policy, predicate_type : PredicateType) -> Policy {
{ ..self, predicate_type: Some(predicate_type) }
}
///|
/// Also require the source repository claim (`.1.12`).
pub fn Policy::source_repository(self : Policy, repository : GitHubRepository) -> Policy {
{ ..self, source_repository: Some(repository) }
}
///|
/// Also require the source repository owner claim (`.1.16`).
pub fn Policy::source_owner(self : Policy, owner : GitHubOwner) -> Policy {
{ ..self, source_owner: Some(owner) }
}
///|
/// Also require the source repository ID claim (`.1.15`).
pub fn Policy::repository_id(self : Policy, id : GitHubId) -> Policy {
{ ..self, repository_id: Some(id) }
}
///|
/// Also require the source repository owner ID claim (`.1.17`).
pub fn Policy::owner_id(self : Policy, id : GitHubId) -> Policy {
{ ..self, owner_id: Some(id) }
}
///|
/// Also require the source ref claim (`.1.14`).
pub fn Policy::source_ref(self : Policy, git_ref : GitRef) -> Policy {
{ ..self, source_ref: Some(git_ref) }
}
///|
/// Also require the runner environment `github-hosted` (`.1.11`).
pub fn Policy::hosted_runners_only(self : Policy) -> Policy {
{ ..self, hosted_runner_only: true }
}
///|
/// A certificate claim that a policy can require.
pub enum Claim {
SourceRepository
SourceOwner
RepositoryId
OwnerId
SourceRef
} derive(Eq, Debug)
///|
pub extend Claim with Eq::{equal, not_equal}
///|
pub extend Claim with @debug.Debug::{to_repr}
///|
/// Why a certificate does not match one policy.
pub enum PolicyMismatch {
/// The certificate issuer is `actual`, not `expected`.
IssuerMismatch(expected~ : String, actual~ : String?)
/// No subject alternative name of the certificate matches `expected`.
IdentityMismatch(expected~ : String, actual~ : Array[String])
/// The claim `claim` is `actual`, not `expected`.
ClaimMismatch(claim~ : Claim, expected~ : String, actual~ : String?)
/// The policy requires a GitHub-hosted runner; the certificate gives `actual`.
RunnerEnvironment(actual~ : String?)
/// The statement has the predicate type `actual` (`None` without a
/// statement), not `expected`.
PredicateTypeMismatch(expected~ : PredicateType, actual~ : PredicateType?)
} derive(Eq, Debug)
///|
pub extend PolicyMismatch with Eq::{equal, not_equal}
///|
pub extend PolicyMismatch with @debug.Debug::{to_repr}
///|
/// Text for people that names the expected and the actual value.
pub fn PolicyMismatch::message(self : PolicyMismatch) -> String {
fn shown(value : String?) -> String {
match value {
Some(text) => text.escape()
None => "nothing"
}
}
match self {
IssuerMismatch(expected~, actual~) =>
"the issuer is \{shown(actual)}, not \{expected.escape()}"
IdentityMismatch(expected~, actual~) =>
"the certificate identity \{actual.map(name => name.escape()).join(", ")} does not match \{expected.escape()}"
ClaimMismatch(claim~, expected~, actual~) => {
let name = match claim {
SourceRepository => "source repository"
SourceOwner => "source repository owner"
RepositoryId => "repository ID"
OwnerId => "owner ID"
SourceRef => "source ref"
}
"the \{name} is \{shown(actual)}, not \{expected.escape()}"
}
RunnerEnvironment(actual~) =>
"the runner environment is \{shown(actual)}, not \"github-hosted\""
PredicateTypeMismatch(expected~, actual~) =>
"the predicate type is \{shown(actual.map(value => value.to_string()))}, not \{expected.to_string().escape()}"
}
}
///|
fn equal_ignoring_case(a : String?, b : String) -> Bool {
a is Some(text) && text.to_lower() == b.to_lower()
}
///|
/// The first reason that `claims` and `statement` do not match the policy, or
/// `None`.
fn Policy::mismatch(
self : Policy,
claims : CertificateClaims,
statement : Statement?,
) -> PolicyMismatch? {
let issuer = self.issuer.to_string()
guard claims.issuer == Some(issuer) else {
return Some(IssuerMismatch(expected=issuer, actual=claims.issuer))
}
match self.identity {
ExactName(name) => {
let expected = name.to_string()
guard claims.subject_names.contains(expected) else {
return Some(IdentityMismatch(expected~, actual=claims.subject_names))
}
}
SignerWorkflow(repository, path, git_ref) => {
let prefix = "\{repository.url()}/\{path}@"
let expected = match git_ref {
Some(git_ref) => prefix + git_ref.to_string()
None => prefix + "*"
}
let matches = claims.subject_names.iter().any(name => {
// Owner and repository names are case-insensitive; the path and ref are not.
guard name.length() > prefix.length() else { return false }
let head = name[:repository.url().length()].to_owned()
let tail = name[repository.url().length():].to_owned()
guard head.to_lower() == repository.url().to_lower() &&
tail.has_prefix("/\{path}@") else {
return false
}
let actual_ref = tail["/\{path}@".length():].to_owned()
match git_ref {
Some(git_ref) => actual_ref == git_ref.to_string()
None => actual_ref.has_prefix("refs/")
}
})
guard matches else {
return Some(IdentityMismatch(expected~, actual=claims.subject_names))
}
}
}
if self.source_repository is Some(repository) {
guard equal_ignoring_case(claims.source_repository_uri, repository.url()) else {
return Some(
ClaimMismatch(
claim=SourceRepository,
expected=repository.url(),
actual=claims.source_repository_uri,
),
)
}
}
if self.source_owner is Some(owner) {
let expected = "https://github.com/\{owner}"
guard equal_ignoring_case(claims.source_repository_owner_uri, expected) else {
return Some(
ClaimMismatch(
claim=SourceOwner,
expected~,
actual=claims.source_repository_owner_uri,
),
)
}
}
if self.repository_id is Some(id) {
guard claims.source_repository_identifier == Some(id.to_string()) else {
return Some(
ClaimMismatch(
claim=RepositoryId,
expected=id.to_string(),
actual=claims.source_repository_identifier,
),
)
}
}
if self.owner_id is Some(id) {
guard claims.source_repository_owner_identifier == Some(id.to_string()) else {
return Some(
ClaimMismatch(
claim=OwnerId,
expected=id.to_string(),
actual=claims.source_repository_owner_identifier,
),
)
}
}
if self.source_ref is Some(git_ref) {
guard claims.source_repository_ref == Some(git_ref.to_string()) else {
return Some(
ClaimMismatch(
claim=SourceRef,
expected=git_ref.to_string(),
actual=claims.source_repository_ref,
),
)
}
}
if self.hosted_runner_only {
guard claims.runner_environment == Some("github-hosted") else {
return Some(RunnerEnvironment(actual=claims.runner_environment))
}
}
if self.predicate_type is Some(expected) {
let actual = statement.map(statement => statement.predicate_type)
guard actual == Some(expected) else {
return Some(PredicateTypeMismatch(expected~, actual~))
}
}
None
}