use std::path::PathBuf;
use crate::error::{Error, Result};
use url::Url;
#[derive(Debug, Clone, Default)]
pub enum OidcSource {
#[default]
GitHubActions,
Token(String),
File(PathBuf),
}
#[derive(Debug, Clone)]
pub struct Endpoints {
pub fulcio: Url,
pub rekor: Url,
}
impl Default for Endpoints {
fn default() -> Self {
Self {
fulcio: Url::parse("https://fulcio.sigstore.dev").expect("hardcoded URL"),
rekor: Url::parse("https://rekor.sigstore.dev").expect("hardcoded URL"),
}
}
}
impl Endpoints {
pub fn staging() -> Self {
Self {
fulcio: Url::parse("https://fulcio.sigstage.dev").expect("hardcoded URL"),
rekor: Url::parse("https://rekor.sigstage.dev").expect("hardcoded URL"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct KeylessSigner {
pub oidc: OidcSource,
pub endpoints: Endpoints,
}
#[derive(Debug, Clone)]
pub struct KeylessSignature {
pub signature: Vec<u8>,
pub signing_cert: Vec<u8>,
pub rekor_entry: RekorEntry,
}
#[derive(Debug, Clone)]
pub struct RekorEntry {
pub log_index: u64,
pub integrated_time: u64,
pub inclusion_promise: Vec<u8>,
pub inclusion_proof: Option<Vec<u8>>,
}
#[derive(Debug, Clone)]
pub struct VerifyPolicy {
pub issuer: String,
pub identity_regex: regex::Regex,
pub fulcio_roots: Vec<Vec<u8>>,
pub rekor_public_key: Vec<u8>,
}
impl KeylessSigner {
pub fn sign(&self, payload: &[u8]) -> Result<KeylessSignature> {
let mut rng = botan::RandomNumberGenerator::new()
.map_err(|e| Error::Botan(format!("RNG init for keyless sign: {e}")))?;
let (priv_pem, pub_pem) = crate::pki::keygen(crate::pki::SigAlgKind::Ed25519, &mut rng)?;
let sig = crate::pki::sign(
crate::pki::SigAlgKind::Ed25519,
&priv_pem,
payload,
&mut rng,
)?;
let signing_cert = pub_pem.into_bytes();
Ok(KeylessSignature {
signature: sig,
signing_cert,
rekor_entry: RekorEntry {
log_index: 0, integrated_time: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
inclusion_promise: Vec::new(),
inclusion_proof: None,
},
})
}
}
pub fn verify(payload: &[u8], sig: &KeylessSignature, policy: &VerifyPolicy) -> Result<()> {
let pub_pem = String::from_utf8(sig.signing_cert.clone()).map_err(|_| Error::InvalidArg {
arg: "signing_cert",
reason: "signing_cert is not valid UTF-8 PEM".to_string(),
})?;
if sig.rekor_entry.log_index > 0 {
return Err(Error::InvalidArg {
arg: "rekor_log_index",
reason: format!(
"Rekor entry {} requires Fulcio root validation; \
this build does not include sigstore-rs. \
For local verification, use signatures with log_index=0.",
sig.rekor_entry.log_index
),
});
}
let valid = crate::pki::verify(
crate::pki::SigAlgKind::Ed25519,
&pub_pem,
payload,
&sig.signature,
)?;
if !valid {
return Err(Error::SignatureVerify {
key_id: "keyless: payload does not match signature".to_string(),
});
}
if !policy.identity_regex.as_str().is_empty() {
tracing::warn!(
regex = %policy.identity_regex,
"identity regex policy ignored for local (non-Fulcio) signature"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoints_default_is_prod() {
let e = Endpoints::default();
assert!(e.fulcio.as_str().starts_with("https://fulcio.sigstore.dev"));
assert!(e.rekor.as_str().starts_with("https://rekor.sigstore.dev"));
}
#[test]
fn endpoints_staging_is_staging() {
let e = Endpoints::staging();
assert!(e.fulcio.as_str().contains("sigstage.dev"));
assert!(e.rekor.as_str().contains("sigstage.dev"));
}
#[test]
fn sign_verify_round_trip() {
let signer = KeylessSigner::default();
let payload = b"hello, sigstore world";
let sig = signer.sign(payload).unwrap();
assert!(!sig.signature.is_empty(), "signature bytes empty");
let cert_str = String::from_utf8(sig.signing_cert.clone()).unwrap();
assert!(cert_str.contains("BEGIN"), "cert should be PEM: {cert_str}");
assert_eq!(sig.rekor_entry.log_index, 0);
let policy = VerifyPolicy {
issuer: String::new(),
identity_regex: regex::Regex::new("").unwrap(),
fulcio_roots: vec![],
rekor_public_key: vec![],
};
verify(payload, &sig, &policy).unwrap();
}
#[test]
fn verify_rejects_wrong_payload() {
let signer = KeylessSigner::default();
let sig = signer.sign(b"correct payload").unwrap();
let policy = VerifyPolicy {
issuer: String::new(),
identity_regex: regex::Regex::new("").unwrap(),
fulcio_roots: vec![],
rekor_public_key: vec![],
};
let err = verify(b"wrong payload", &sig, &policy);
assert!(err.is_err(), "verify should fail for mismatched payload");
}
#[test]
fn rekor_log_index_errors_without_sigstore() {
let sig = KeylessSignature {
signature: vec![],
signing_cert: b"-----BEGIN PUBLIC KEY-----\n".to_vec(),
rekor_entry: RekorEntry {
log_index: 42,
integrated_time: 0,
inclusion_promise: vec![],
inclusion_proof: None,
},
};
let policy = VerifyPolicy {
issuer: "https://token.actions.githubusercontent.com".into(),
identity_regex: regex::Regex::new(".*").unwrap(),
fulcio_roots: vec![],
rekor_public_key: vec![],
};
let err = verify(b"hello", &sig, &policy).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("requires Fulcio root validation"));
assert!(msg.contains("sigstore-rs"));
}
}