use crate::error::{Error, Result};
use serde::Serialize;
use sigstore_crypto::Checkpoint;
use sigstore_crypto::Keyring;
use sigstore_types::bundle::InclusionProof;
use sigstore_types::{Bundle, KindVersion, Sha256Hash, SignatureBytes, TransparencyLogEntry};
pub fn verify_tlog_entries(
bundle: &Bundle,
rekor_keys: &Keyring,
not_before: jiff::Timestamp,
not_after: jiff::Timestamp,
) -> Result<Option<jiff::Timestamp>> {
let mut integrated_time_result: Option<jiff::Timestamp> = None;
for entry in &bundle.verification_material.tlog_entries {
verify_entry_inclusion(entry, rekor_keys)?;
let is_rekor_v2 = entry.kind_version == KindVersion::HashedRekordV002;
if !is_rekor_v2 && entry.inclusion_promise.is_some() {
if let Some(time) = entry.integrated_time {
validate_integrated_time(time, jiff::Timestamp::now(), not_before, not_after)?;
integrated_time_result = Some(earliest(integrated_time_result, time));
}
}
}
Ok(integrated_time_result)
}
pub(crate) fn earliest(current: Option<jiff::Timestamp>, time: jiff::Timestamp) -> jiff::Timestamp {
current.map_or(time, |current| current.min(time))
}
fn validate_integrated_time(
time: jiff::Timestamp,
now: jiff::Timestamp,
not_before: jiff::Timestamp,
not_after: jiff::Timestamp,
) -> Result<()> {
validate_integrated_time_not_in_future(time, now)?;
if time < not_before {
return Err(Error::integrated_time(format!(
"integrated time {} is before certificate validity (not_before: {})",
time, not_before
)));
}
if time > not_after {
return Err(Error::integrated_time(format!(
"integrated time {} is after certificate validity (not_after: {})",
time, not_after
)));
}
Ok(())
}
pub(crate) fn validate_integrated_time_not_in_future(
time: jiff::Timestamp,
now: jiff::Timestamp,
) -> Result<()> {
if time > now {
return Err(Error::integrated_time(format!(
"integrated time {} is in the future (current time: {})",
time, now
)));
}
Ok(())
}
pub fn verify_entry_inclusion(entry: &TransparencyLogEntry, rekor_keys: &Keyring) -> Result<()> {
if let Some(ref inclusion_proof) = entry.inclusion_proof {
verify_merkle_inclusion(entry, inclusion_proof)?;
verify_checkpoint(
inclusion_proof.checkpoint.envelope(),
inclusion_proof,
is_rekor_v2(entry),
rekor_keys,
)?;
}
if entry.inclusion_promise.is_some() {
verify_set(entry, rekor_keys)?;
}
Ok(())
}
fn verify_merkle_inclusion(entry: &TransparencyLogEntry, proof: &InclusionProof) -> Result<()> {
let (leaf_index, tree_size, root_hash) = if is_rekor_v2(entry) {
let checkpoint = proof.checkpoint.checkpoint().ok_or_else(|| {
Error::checkpoint("Rekor v2 inclusion proof has no checkpoint".to_string())
})?;
let leaf_index = entry.log_index.get();
(leaf_index, checkpoint.tree_size(), *checkpoint.root_hash())
} else {
let leaf_index = proof.log_index.get();
let tree_size = proof.tree_size;
(leaf_index, tree_size, proof.root_hash)
};
let leaf_hash = sigstore_merkle::hash_leaf(entry.canonicalized_body.as_bytes());
sigstore_merkle::verify_inclusion_proof(
&leaf_hash,
leaf_index,
tree_size,
&proof.hashes,
&root_hash,
)
.map_err(|e| Error::inclusion_proof(format!("inclusion proof verification failed: {}", e)))
}
pub fn verify_checkpoint(
checkpoint_envelope: &str,
inclusion_proof: &InclusionProof,
is_v2: bool,
rekor_keys: &Keyring,
) -> Result<()> {
let checkpoint = Checkpoint::from_text(checkpoint_envelope)
.map_err(|e| Error::checkpoint(format!("Failed to parse checkpoint: {}", e)))?;
let checkpoint_root_hash = checkpoint.root_hash();
let proof_root_hash = &inclusion_proof.root_hash;
if !is_v2 && checkpoint_root_hash.as_bytes() != proof_root_hash.as_bytes() {
return Err(Error::checkpoint(format!(
"Checkpoint root hash mismatch: expected {}, got {}",
checkpoint_root_hash.to_hex(),
proof_root_hash.to_hex()
)));
}
let message = checkpoint.signed_data();
let now = jiff::Timestamp::now();
let mut found_matching_key = false;
for sig in checkpoint.signatures() {
for key in rekor_keys.keys_by_hint(&sig.key_id, now) {
found_matching_key = true;
if key.verify(message, &sig.signature).is_ok() {
return Ok(());
}
}
}
let message = if found_matching_key {
"No valid Rekor signature found on checkpoint"
} else {
"No matching Rekor key found for checkpoint signature"
};
Err(Error::checkpoint(message.to_string()))
}
fn is_rekor_v2(entry: &TransparencyLogEntry) -> bool {
entry.kind_version == KindVersion::HashedRekordV002
}
#[derive(Serialize)]
struct RekorPayload {
body: String,
#[serde(rename = "integratedTime")]
integrated_time: i64,
#[serde(rename = "logIndex")]
log_index: i64,
#[serde(rename = "logID")]
log_id: String,
}
pub fn verify_set(entry: &TransparencyLogEntry, rekor_keys: &Keyring) -> Result<()> {
let promise = entry
.inclusion_promise
.as_ref()
.ok_or(Error::inclusion_promise("Missing inclusion promise".into()))?;
let decoded_key_id = entry.log_id.key_id.as_bytes();
let key_id = Sha256Hash::try_from(decoded_key_id)
.map_err(|e| Error::malformed_entry(format!("invalid Rekor log ID: {e}")))?;
let keyring = rekor_keys;
let log_key = if let Some(integrated_ts) = entry.integrated_time {
keyring.key_at(&key_id, integrated_ts).ok_or_else(|| {
Error::inclusion_promise(format!(
"No log key valid at integrated time {} for log ID {}",
integrated_ts, entry.log_id.key_id
))
})?
} else {
keyring
.key_started_by(&key_id, jiff::Timestamp::now())
.ok_or_else(|| Error::unknown_log(entry.log_id.key_id.to_string()))?
};
let body = entry.canonicalized_body.to_base64();
let log_index = entry.log_index.as_i64();
let log_id_hex = hex::encode(decoded_key_id);
let payload = RekorPayload {
body,
integrated_time: entry.integrated_time.map_or(0, |ts| ts.as_second()),
log_index,
log_id: log_id_hex,
};
let canonical_json = serde_json_canonicalizer::to_vec(&payload)
.map_err(|e| Error::inclusion_promise(format!("Canonicalization failed: {}", e)))?;
let signature = SignatureBytes::new(promise.signed_entry_timestamp.as_bytes().to_vec());
log_key
.verify(&canonical_json, &signature)
.map_err(|e| Error::inclusion_promise(format!("SET verification failed: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(seconds: i64) -> jiff::Timestamp {
jiff::Timestamp::from_second(seconds).unwrap()
}
const NOW: i64 = 1_700_000_000;
#[test]
fn test_integrated_time_in_future_rejected() {
let err = validate_integrated_time(ts(NOW + 1), ts(NOW), ts(NOW - 600), ts(NOW + 600))
.unwrap_err();
assert!(err.to_string().contains("is in the future"));
}
#[test]
fn test_integrated_time_at_now_accepted() {
assert!(validate_integrated_time(ts(NOW), ts(NOW), ts(NOW - 600), ts(NOW + 600)).is_ok());
}
}