use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use serde::{Deserialize, Serialize};
use crate::signature::keyless::KeylessSignature;
pub const BUNDLE_MEDIA_TYPE: &str = "application/vnd.dev.sigstore.bundle.v0.3+json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SigstoreBundle {
pub media_type: String,
pub verification_material: VerificationMaterial,
pub message_signature: MessageSignature,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerificationMaterial {
pub x509_certificate_chain: CertificateChain,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tlog_entries: Vec<TransparencyLogEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CertificateChain {
pub certificates: Vec<Certificate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Certificate {
pub raw_bytes: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessageSignature {
pub message_digest: MessageDigest,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessageDigest {
pub algorithm: String,
pub digest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransparencyLogEntry {
pub log_index: String,
pub log_id: LogId,
#[serde(skip_serializing_if = "Option::is_none")]
pub canonicalized_body: Option<String>,
pub integrated_time: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub inclusion_proof: Option<InclusionProof>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signed_entry_timestamp: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogId {
pub key_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InclusionProof {
pub log_index: String,
pub root_hash: String,
pub tree_size: String,
pub hashes: Vec<String>,
}
impl SigstoreBundle {
pub fn from_keyless_signature(sig: &KeylessSignature) -> Self {
let certificates: Vec<Certificate> = sig
.cert_chain
.iter()
.map(|pem_str| {
let der_bytes = pem_to_der(pem_str);
Certificate {
raw_bytes: BASE64.encode(&der_bytes),
}
})
.collect();
let tlog_entry = build_tlog_entry(&sig.rekor_entry);
let digest_hex = sig
.module_hash
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>();
SigstoreBundle {
media_type: BUNDLE_MEDIA_TYPE.to_string(),
verification_material: VerificationMaterial {
x509_certificate_chain: CertificateChain { certificates },
tlog_entries: vec![tlog_entry],
},
message_signature: MessageSignature {
message_digest: MessageDigest {
algorithm: "SHA2_256".to_string(),
digest: digest_hex,
},
signature: BASE64.encode(&sig.signature),
},
}
}
pub fn to_json(&self) -> Result<Vec<u8>, serde_json::Error> {
serde_json::to_vec_pretty(self)
}
pub fn from_json(json: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(json)
}
}
fn build_tlog_entry(
rekor: &crate::signature::keyless::rekor::RekorEntry,
) -> TransparencyLogEntry {
let inclusion_proof = if rekor.inclusion_proof.is_empty() {
None
} else {
parse_inclusion_proof(&rekor.inclusion_proof)
};
let integrated_time_str = chrono::DateTime::parse_from_rfc3339(&rekor.integrated_time)
.map(|dt| dt.timestamp().to_string())
.unwrap_or_else(|_| rekor.integrated_time.clone());
TransparencyLogEntry {
log_index: rekor.log_index.to_string(),
log_id: LogId {
key_id: rekor.log_id.clone(),
},
canonicalized_body: if rekor.body.is_empty() {
None
} else {
Some(rekor.body.clone())
},
integrated_time: integrated_time_str,
inclusion_proof,
signed_entry_timestamp: if rekor.signed_entry_timestamp.is_empty() {
None
} else {
Some(rekor.signed_entry_timestamp.clone())
},
}
}
fn parse_inclusion_proof(bytes: &[u8]) -> Option<InclusionProof> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawProof {
hashes: Vec<String>,
log_index: u64,
root_hash: String,
tree_size: u64,
}
let raw: RawProof = serde_json::from_slice(bytes).ok()?;
Some(InclusionProof {
log_index: raw.log_index.to_string(),
root_hash: raw.root_hash,
tree_size: raw.tree_size.to_string(),
hashes: raw.hashes,
})
}
fn pem_to_der(pem_str: &str) -> Vec<u8> {
let b64: String = pem_str
.lines()
.filter(|line| {
!line.starts_with("-----BEGIN") && !line.starts_with("-----END") && !line.is_empty()
})
.collect::<Vec<&str>>()
.join("");
BASE64.decode(&b64).unwrap_or_else(|_| pem_str.as_bytes().to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signature::keyless::rekor::RekorEntry;
fn create_test_rekor_entry() -> RekorEntry {
RekorEntry {
uuid: "test-uuid-1234".to_string(),
log_index: 42,
body: "eyJ0ZXN0IjoidmFsdWUifQ==".to_string(),
log_id: "c0d23d6ad406973f".to_string(),
inclusion_proof: serde_json::to_vec(&serde_json::json!({
"hashes": ["aabbcc", "ddeeff"],
"logIndex": 42,
"rootHash": "112233",
"treeSize": 1000
}))
.unwrap(),
signed_entry_timestamp: "c2lnbmF0dXJl".to_string(),
integrated_time: "2024-01-01T00:00:00Z".to_string(),
}
}
fn create_test_keyless_signature() -> KeylessSignature {
KeylessSignature::new(
vec![1, 2, 3, 4, 5, 6, 7, 8],
vec![
"-----BEGIN CERTIFICATE-----\nTUlJQmtUQ0NBVGVnQXdJQkFnSVVUZXN0\n-----END CERTIFICATE-----".to_string(),
"-----BEGIN CERTIFICATE-----\nTUlJQmtUQ0NBVGVnQXdJQkFnSVVSb290\n-----END CERTIFICATE-----".to_string(),
],
create_test_rekor_entry(),
vec![0xde, 0xad, 0xbe, 0xef],
)
}
#[test]
fn test_bundle_media_type() {
assert_eq!(
BUNDLE_MEDIA_TYPE,
"application/vnd.dev.sigstore.bundle.v0.3+json"
);
}
#[test]
fn test_from_keyless_signature() {
let sig = create_test_keyless_signature();
let bundle = SigstoreBundle::from_keyless_signature(&sig);
assert_eq!(bundle.media_type, BUNDLE_MEDIA_TYPE);
assert_eq!(
bundle.verification_material.x509_certificate_chain.certificates.len(),
2
);
assert_eq!(bundle.verification_material.tlog_entries.len(), 1);
let expected_sig_b64 = BASE64.encode(&sig.signature);
assert_eq!(bundle.message_signature.signature, expected_sig_b64);
assert_eq!(bundle.message_signature.message_digest.algorithm, "SHA2_256");
assert_eq!(bundle.message_signature.message_digest.digest, "deadbeef");
}
#[test]
fn test_tlog_entry_conversion() {
let rekor = create_test_rekor_entry();
let tlog = build_tlog_entry(&rekor);
assert_eq!(tlog.log_index, "42");
assert_eq!(tlog.log_id.key_id, "c0d23d6ad406973f");
assert_eq!(tlog.integrated_time, "1704067200");
assert!(tlog.canonicalized_body.is_some());
assert!(tlog.signed_entry_timestamp.is_some());
let proof = tlog.inclusion_proof.unwrap();
assert_eq!(proof.log_index, "42");
assert_eq!(proof.root_hash, "112233");
assert_eq!(proof.tree_size, "1000");
assert_eq!(proof.hashes, vec!["aabbcc", "ddeeff"]);
}
#[test]
fn test_tlog_entry_empty_inclusion_proof() {
let mut rekor = create_test_rekor_entry();
rekor.inclusion_proof = vec![];
let tlog = build_tlog_entry(&rekor);
assert!(tlog.inclusion_proof.is_none());
}
#[test]
fn test_tlog_entry_empty_set() {
let mut rekor = create_test_rekor_entry();
rekor.signed_entry_timestamp = String::new();
let tlog = build_tlog_entry(&rekor);
assert!(tlog.signed_entry_timestamp.is_none());
}
#[test]
fn test_tlog_entry_empty_body() {
let mut rekor = create_test_rekor_entry();
rekor.body = String::new();
let tlog = build_tlog_entry(&rekor);
assert!(tlog.canonicalized_body.is_none());
}
#[test]
fn test_bundle_json_serialization_roundtrip() {
let sig = create_test_keyless_signature();
let bundle = SigstoreBundle::from_keyless_signature(&sig);
let json = bundle.to_json().expect("Serialization failed");
let parsed = SigstoreBundle::from_json(&json).expect("Deserialization failed");
assert_eq!(parsed.media_type, bundle.media_type);
assert_eq!(
parsed.verification_material.x509_certificate_chain.certificates.len(),
bundle.verification_material.x509_certificate_chain.certificates.len()
);
assert_eq!(
parsed.verification_material.tlog_entries.len(),
bundle.verification_material.tlog_entries.len()
);
assert_eq!(
parsed.message_signature.signature,
bundle.message_signature.signature
);
assert_eq!(
parsed.message_signature.message_digest.algorithm,
bundle.message_signature.message_digest.algorithm
);
assert_eq!(
parsed.message_signature.message_digest.digest,
bundle.message_signature.message_digest.digest
);
}
#[test]
fn test_bundle_json_structure() {
let sig = create_test_keyless_signature();
let bundle = SigstoreBundle::from_keyless_signature(&sig);
let json_bytes = bundle.to_json().unwrap();
let json_str = String::from_utf8(json_bytes).unwrap();
assert!(json_str.contains("mediaType"));
assert!(json_str.contains("verificationMaterial"));
assert!(json_str.contains("messageSignature"));
assert!(json_str.contains("x509CertificateChain"));
assert!(json_str.contains("tlogEntries"));
assert!(json_str.contains("messageDigest"));
assert!(json_str.contains("SHA2_256"));
}
#[test]
fn test_pem_to_der_valid() {
let payload = b"test certificate data";
let b64 = BASE64.encode(payload);
let pem = format!(
"-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----",
b64
);
let der = pem_to_der(&pem);
assert_eq!(der, payload);
}
#[test]
fn test_pem_to_der_multiline() {
let payload = vec![0u8; 100]; let b64 = BASE64.encode(&payload);
let lines: Vec<String> = b64
.as_bytes()
.chunks(64)
.map(|c| String::from_utf8(c.to_vec()).unwrap())
.collect();
let pem = format!(
"-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----",
lines.join("\n")
);
let der = pem_to_der(&pem);
assert_eq!(der, payload);
}
#[test]
fn test_pem_to_der_invalid_base64_fallback() {
let pem = "-----BEGIN CERTIFICATE-----\n!!!invalid!!!\n-----END CERTIFICATE-----";
let der = pem_to_der(pem);
assert_eq!(der, pem.as_bytes());
}
#[test]
fn test_bundle_empty_cert_chain() {
let sig = KeylessSignature::new(
vec![1, 2, 3],
vec![],
create_test_rekor_entry(),
vec![0xaa, 0xbb],
);
let bundle = SigstoreBundle::from_keyless_signature(&sig);
assert!(
bundle
.verification_material
.x509_certificate_chain
.certificates
.is_empty()
);
let json = bundle.to_json().unwrap();
let parsed = SigstoreBundle::from_json(&json).unwrap();
assert!(
parsed
.verification_material
.x509_certificate_chain
.certificates
.is_empty()
);
}
#[test]
fn test_parse_inclusion_proof_valid() {
let bytes = serde_json::to_vec(&serde_json::json!({
"hashes": ["aa", "bb", "cc"],
"logIndex": 100,
"rootHash": "deadbeef",
"treeSize": 5000
}))
.unwrap();
let proof = parse_inclusion_proof(&bytes).unwrap();
assert_eq!(proof.log_index, "100");
assert_eq!(proof.root_hash, "deadbeef");
assert_eq!(proof.tree_size, "5000");
assert_eq!(proof.hashes, vec!["aa", "bb", "cc"]);
}
#[test]
fn test_parse_inclusion_proof_invalid() {
let result = parse_inclusion_proof(b"not json");
assert!(result.is_none());
}
#[test]
fn test_certificate_serialization() {
let cert = Certificate {
raw_bytes: "AQIDBA==".to_string(),
};
let json = serde_json::to_string(&cert).unwrap();
assert!(json.contains("rawBytes"));
assert!(json.contains("AQIDBA=="));
let parsed: Certificate = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.raw_bytes, cert.raw_bytes);
}
#[test]
fn test_message_digest_serialization() {
let digest = MessageDigest {
algorithm: "SHA2_256".to_string(),
digest: "deadbeef".to_string(),
};
let json = serde_json::to_string(&digest).unwrap();
assert!(json.contains("SHA2_256"));
assert!(json.contains("deadbeef"));
}
}