use std::path::{Path, PathBuf};
use base64::engine::general_purpose::STANDARD as BASE64_STD;
use base64::Engine as _;
use p256::ecdsa::signature::Verifier;
use p256::ecdsa::{Signature as EcdsaSig, VerifyingKey};
use sha2::{Digest as _, Sha256};
use x509_parser::extensions::{GeneralName, ParsedExtension};
use x509_parser::prelude::*;
use crate::trusted_keys::AuthorPolicy;
use crate::verify_error::VerifyError;
#[derive(Debug)]
pub struct VerifyInput<'a> {
pub cosign_bin: &'a Path,
pub tarball_path: &'a Path,
pub sig_path: &'a Path,
pub cert_path: &'a Path,
pub bundle_path: Option<&'a Path>,
pub policy: &'a AuthorPolicy,
}
#[derive(Debug, Clone)]
pub struct VerifiedSignature {
pub identity: String,
pub issuer: String,
}
pub fn discover_cosign_binary(_override_: Option<&Path>) -> Result<PathBuf, VerifyError> {
Ok(PathBuf::new())
}
pub async fn verify_plugin_signature(
input: VerifyInput<'_>,
) -> Result<VerifiedSignature, VerifyError> {
let (cert_pem, sig_text, blob) = tokio::try_join!(
tokio::fs::read_to_string(input.cert_path),
tokio::fs::read_to_string(input.sig_path),
tokio::fs::read(input.tarball_path),
)
.map_err(|e| VerifyError::Io(format!("read verify inputs: {e}")))?;
let policy = input.policy;
let cert_pem_owned = cert_pem.clone();
let sig_text_owned = sig_text.clone();
let identity_regex = policy.identity_regexp.clone();
let oidc_issuer = policy.oidc_issuer.clone();
tokio::task::spawn_blocking(move || -> Result<VerifiedSignature, VerifyError> {
verify_blocking(
&cert_pem_owned,
&sig_text_owned,
&blob,
&identity_regex,
&oidc_issuer,
)
})
.await
.map_err(|e| VerifyError::Io(format!("verify join: {e}")))?
}
fn verify_blocking(
cert_pem: &str,
sig_text: &str,
blob: &[u8],
identity_regexp: &str,
expected_issuer: &str,
) -> Result<VerifiedSignature, VerifyError> {
let pem = parse_x509_pem(cert_pem.as_bytes())
.map_err(|e| VerifyError::CertParseFailed(format!("pem: {e}")))?
.1;
if pem.label != "CERTIFICATE" {
return Err(VerifyError::CertParseFailed(format!(
"expected `CERTIFICATE` PEM label, got `{}`",
pem.label
)));
}
let (_, cert) = parse_x509_certificate(&pem.contents)
.map_err(|e| VerifyError::CertParseFailed(format!("x509: {e}")))?;
let pubkey = extract_p256_pubkey(&cert)?;
let sig_bytes = BASE64_STD
.decode(sig_text.trim().as_bytes())
.map_err(|e| VerifyError::SignatureDecodeFailed(format!("base64: {e}")))?;
let signature = EcdsaSig::from_der(&sig_bytes)
.map_err(|e| VerifyError::SignatureDecodeFailed(format!("der: {e}")))?;
let digest = Sha256::digest(blob);
pubkey
.verify(&digest, &signature)
.map_err(|_| VerifyError::SignatureMismatch)?;
let san_entries = extract_san_entries(&cert);
if san_entries.is_empty() {
return Err(VerifyError::IdentityNotFound);
}
let regex =
regex::Regex::new(identity_regexp).map_err(|e| VerifyError::IdentityRegexpInvalid {
got: identity_regexp.to_string(),
reason: e.to_string(),
})?;
let identity = san_entries
.iter()
.find(|entry| regex.is_match(entry))
.cloned()
.ok_or_else(|| VerifyError::IdentityMismatch {
found: san_entries.join(", "),
expected_regex: identity_regexp.to_string(),
})?;
let cert_issuer = extract_fulcio_issuer(&cert).ok_or_else(|| VerifyError::IssuerMismatch {
found: "<no Fulcio OIDC issuer extension>".to_string(),
expected: expected_issuer.to_string(),
})?;
if cert_issuer != expected_issuer {
return Err(VerifyError::IssuerMismatch {
found: cert_issuer,
expected: expected_issuer.to_string(),
});
}
Ok(VerifiedSignature {
identity,
issuer: cert_issuer,
})
}
fn extract_p256_pubkey(cert: &X509Certificate<'_>) -> Result<VerifyingKey, VerifyError> {
let spki = &cert.tbs_certificate.subject_pki;
let raw = spki.subject_public_key.data.as_ref();
VerifyingKey::from_sec1_bytes(raw).map_err(|e| {
VerifyError::UnsupportedKey(format!("expected ECDSA-P256 SEC1 point, parse failed: {e}"))
})
}
fn extract_san_entries(cert: &X509Certificate<'_>) -> Vec<String> {
let mut out = Vec::new();
for ext in cert.extensions() {
if let ParsedExtension::SubjectAlternativeName(san) = ext.parsed_extension() {
for name in &san.general_names {
match name {
GeneralName::URI(u) => out.push((*u).to_string()),
GeneralName::RFC822Name(e) => out.push((*e).to_string()),
GeneralName::DNSName(d) => out.push((*d).to_string()),
_ => {}
}
}
}
}
out
}
fn extract_fulcio_issuer(cert: &X509Certificate<'_>) -> Option<String> {
let mut legacy: Option<String> = None;
let mut modern: Option<String> = None;
let oid_legacy = oid_registry::Oid::from(&[1, 3, 6, 1, 4, 1, 57264, 1, 1]).ok()?;
let oid_modern = oid_registry::Oid::from(&[1, 3, 6, 1, 4, 1, 57264, 1, 8]).ok()?;
for ext in cert.extensions() {
if ext.oid == oid_legacy {
if let Ok(s) = std::str::from_utf8(ext.value) {
legacy = Some(s.to_string());
}
} else if ext.oid == oid_modern {
if let Some(s) = parse_der_utf8_string(ext.value) {
modern = Some(s);
}
}
}
modern.or(legacy)
}
fn parse_der_utf8_string(bytes: &[u8]) -> Option<String> {
if bytes.len() < 2 || bytes[0] != 0x0c {
return None;
}
let length_byte = bytes[1] as usize;
let (start, len) = if length_byte < 0x80 {
(2, length_byte)
} else {
let count = length_byte & 0x7f;
if count == 0 || bytes.len() < 2 + count {
return None;
}
let mut len_acc = 0usize;
for &b in &bytes[2..2 + count] {
len_acc = len_acc.checked_mul(256)?.checked_add(b as usize)?;
}
(2 + count, len_acc)
};
let end = start.checked_add(len)?;
if end > bytes.len() {
return None;
}
std::str::from_utf8(&bytes[start..end])
.ok()
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trusted_keys::TrustMode;
use rcgen::{
Certificate, CertificateParams, CustomExtension, DistinguishedName, KeyPair, SanType,
};
use std::fs;
use tempfile::TempDir;
const ISSUER: &str = "https://token.actions.githubusercontent.com";
const SAN_URI: &str =
"https://github.com/lordmacu/foo/.github/workflows/release.yml@refs/tags/v0.2.0";
fn make_policy() -> AuthorPolicy {
AuthorPolicy {
owner: "lordmacu".to_string(),
identity_regexp: "^https://github.com/lordmacu/.*$".to_string(),
oidc_issuer: ISSUER.to_string(),
mode: Some(TrustMode::Require),
}
}
fn make_cert_with_issuer(issuer: &str, san_uri: &str) -> (KeyPair, Certificate) {
let key = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
let mut params = CertificateParams::default();
params.distinguished_name = DistinguishedName::new();
params.subject_alt_names = vec![SanType::URI(san_uri.try_into().unwrap())];
let mut ext_value = Vec::with_capacity(2 + issuer.len());
ext_value.push(0x0c); ext_value.push(issuer.len() as u8); ext_value.extend_from_slice(issuer.as_bytes());
let issuer_ext =
CustomExtension::from_oid_content(&[1, 3, 6, 1, 4, 1, 57264, 1, 8], ext_value);
params.custom_extensions.push(issuer_ext);
let cert = params.self_signed(&key).unwrap();
(key, cert)
}
fn sign_blob(key: &KeyPair, blob: &[u8]) -> String {
use p256::pkcs8::DecodePrivateKey;
let signing_key = p256::ecdsa::SigningKey::from_pkcs8_der(&key.serialize_der()).unwrap();
let digest = Sha256::digest(blob);
let signature: EcdsaSig = p256::ecdsa::signature::Signer::sign(&signing_key, &digest);
BASE64_STD.encode(signature.to_der().as_bytes())
}
#[tokio::test]
async fn verify_accepts_signature_when_identity_and_issuer_match() {
let tmp = TempDir::new().unwrap();
let blob = b"plugin-tarball-bytes";
let (key, cert) = make_cert_with_issuer(ISSUER, SAN_URI);
let sig = sign_blob(&key, blob);
let tarball = tmp.path().join("plugin.tar.gz");
let cert_path = tmp.path().join("plugin.tar.gz.cert");
let sig_path = tmp.path().join("plugin.tar.gz.sig");
fs::write(&tarball, blob).unwrap();
fs::write(&cert_path, cert.pem()).unwrap();
fs::write(&sig_path, &sig).unwrap();
let policy = make_policy();
let result = verify_plugin_signature(VerifyInput {
cosign_bin: Path::new(""),
tarball_path: &tarball,
sig_path: &sig_path,
cert_path: &cert_path,
bundle_path: None,
policy: &policy,
})
.await
.expect("verify should succeed");
assert_eq!(result.issuer, ISSUER);
assert_eq!(result.identity, SAN_URI);
}
#[tokio::test]
async fn verify_rejects_when_blob_tampered_after_signing() {
let tmp = TempDir::new().unwrap();
let blob = b"original";
let (key, cert) = make_cert_with_issuer(ISSUER, SAN_URI);
let sig = sign_blob(&key, blob);
let tarball = tmp.path().join("plugin.tar.gz");
let cert_path = tmp.path().join("plugin.tar.gz.cert");
let sig_path = tmp.path().join("plugin.tar.gz.sig");
fs::write(&tarball, b"TAMPERED-BYTES").unwrap();
fs::write(&cert_path, cert.pem()).unwrap();
fs::write(&sig_path, sig).unwrap();
let err = verify_plugin_signature(VerifyInput {
cosign_bin: Path::new(""),
tarball_path: &tarball,
sig_path: &sig_path,
cert_path: &cert_path,
bundle_path: None,
policy: &make_policy(),
})
.await
.unwrap_err();
assert!(matches!(err, VerifyError::SignatureMismatch));
}
#[tokio::test]
async fn verify_rejects_when_san_does_not_match_policy_regex() {
let tmp = TempDir::new().unwrap();
let blob = b"x";
let (key, cert) =
make_cert_with_issuer(ISSUER, "https://github.com/other-org/foo/release.yml");
let sig = sign_blob(&key, blob);
let tarball = tmp.path().join("p.tar.gz");
let cert_path = tmp.path().join("p.tar.gz.cert");
let sig_path = tmp.path().join("p.tar.gz.sig");
fs::write(&tarball, blob).unwrap();
fs::write(&cert_path, cert.pem()).unwrap();
fs::write(&sig_path, sig).unwrap();
let err = verify_plugin_signature(VerifyInput {
cosign_bin: Path::new(""),
tarball_path: &tarball,
sig_path: &sig_path,
cert_path: &cert_path,
bundle_path: None,
policy: &make_policy(),
})
.await
.unwrap_err();
assert!(matches!(err, VerifyError::IdentityMismatch { .. }));
}
#[tokio::test]
async fn verify_rejects_when_oidc_issuer_extension_does_not_match() {
let tmp = TempDir::new().unwrap();
let blob = b"x";
let (key, cert) = make_cert_with_issuer("https://malicious.example.com", SAN_URI);
let sig = sign_blob(&key, blob);
let tarball = tmp.path().join("p.tar.gz");
let cert_path = tmp.path().join("p.tar.gz.cert");
let sig_path = tmp.path().join("p.tar.gz.sig");
fs::write(&tarball, blob).unwrap();
fs::write(&cert_path, cert.pem()).unwrap();
fs::write(&sig_path, sig).unwrap();
let err = verify_plugin_signature(VerifyInput {
cosign_bin: Path::new(""),
tarball_path: &tarball,
sig_path: &sig_path,
cert_path: &cert_path,
bundle_path: None,
policy: &make_policy(),
})
.await
.unwrap_err();
assert!(matches!(err, VerifyError::IssuerMismatch { .. }));
}
#[tokio::test]
async fn verify_rejects_malformed_pem_certificate() {
let tmp = TempDir::new().unwrap();
let blob = b"x";
let tarball = tmp.path().join("p.tar.gz");
let cert_path = tmp.path().join("p.tar.gz.cert");
let sig_path = tmp.path().join("p.tar.gz.sig");
fs::write(&tarball, blob).unwrap();
fs::write(&cert_path, b"not-a-pem-certificate").unwrap();
fs::write(&sig_path, "AAAA").unwrap();
let err = verify_plugin_signature(VerifyInput {
cosign_bin: Path::new(""),
tarball_path: &tarball,
sig_path: &sig_path,
cert_path: &cert_path,
bundle_path: None,
policy: &make_policy(),
})
.await
.unwrap_err();
assert!(matches!(err, VerifyError::CertParseFailed(_)));
}
#[tokio::test]
async fn verify_rejects_when_signature_base64_invalid() {
let tmp = TempDir::new().unwrap();
let blob = b"x";
let (_, cert) = make_cert_with_issuer(ISSUER, SAN_URI);
let tarball = tmp.path().join("p.tar.gz");
let cert_path = tmp.path().join("p.tar.gz.cert");
let sig_path = tmp.path().join("p.tar.gz.sig");
fs::write(&tarball, blob).unwrap();
fs::write(&cert_path, cert.pem()).unwrap();
fs::write(&sig_path, "###not-base64###").unwrap();
let err = verify_plugin_signature(VerifyInput {
cosign_bin: Path::new(""),
tarball_path: &tarball,
sig_path: &sig_path,
cert_path: &cert_path,
bundle_path: None,
policy: &make_policy(),
})
.await
.unwrap_err();
assert!(matches!(err, VerifyError::SignatureDecodeFailed(_)));
}
#[test]
fn discover_cosign_binary_is_a_no_op_in_lean_mode() {
assert!(discover_cosign_binary(None).is_ok());
assert!(discover_cosign_binary(Some(Path::new("/nonexistent/cosign"))).is_ok());
}
}