Skip to main content

sigstore_types/
bundle.rs

1//! Sigstore bundle format types
2//!
3//! The bundle is the core artifact produced by signing and consumed by verification.
4//! It contains the signature, verification material (certificate or public key),
5//! and transparency log entries.
6
7use crate::checkpoint::Checkpoint;
8use crate::dsse::DsseEnvelope;
9use crate::encoding::{
10    string_i64, CanonicalizedBody, DerCertificate, LogIndex, LogKeyId, Sha256Hash, SignatureBytes,
11    SignedTimestamp, TimestampToken,
12};
13use crate::error::{Error, Result};
14use crate::hash::HashAlgorithm;
15use serde::{Deserialize, Deserializer, Serialize};
16use std::str::FromStr;
17
18/// Deserialize a field that may be null as the default value
19fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
20where
21    D: Deserializer<'de>,
22    T: Default + Deserialize<'de>,
23{
24    let opt = Option::deserialize(deserializer)?;
25    Ok(opt.unwrap_or_default())
26}
27
28/// Helper for skip_serializing_if to check if i64 is zero
29fn is_zero(value: &i64) -> bool {
30    *value == 0
31}
32
33/// Sigstore bundle media types
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum MediaType {
36    /// Bundle format version 0.1
37    Bundle0_1,
38    /// Bundle format version 0.2
39    Bundle0_2,
40    /// Bundle format version 0.3
41    Bundle0_3,
42}
43
44impl MediaType {
45    /// Get the media type string
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            MediaType::Bundle0_1 => "application/vnd.dev.sigstore.bundle+json;version=0.1",
49            MediaType::Bundle0_2 => "application/vnd.dev.sigstore.bundle+json;version=0.2",
50            MediaType::Bundle0_3 => "application/vnd.dev.sigstore.bundle.v0.3+json",
51        }
52    }
53}
54
55impl FromStr for MediaType {
56    type Err = Error;
57
58    fn from_str(s: &str) -> Result<Self> {
59        match s {
60            "application/vnd.dev.sigstore.bundle+json;version=0.1" => Ok(MediaType::Bundle0_1),
61            "application/vnd.dev.sigstore.bundle+json;version=0.2" => Ok(MediaType::Bundle0_2),
62            "application/vnd.dev.sigstore.bundle.v0.3+json" => Ok(MediaType::Bundle0_3),
63            // Also accept alternative v0.3 format
64            "application/vnd.dev.sigstore.bundle+json;version=0.3" => Ok(MediaType::Bundle0_3),
65            _ => Err(Error::InvalidMediaType(s.to_string())),
66        }
67    }
68}
69
70/// Bundle version enum for serde
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72pub enum BundleVersion {
73    /// Version 0.1
74    #[serde(rename = "0.1")]
75    V0_1,
76    /// Version 0.2
77    #[serde(rename = "0.2")]
78    V0_2,
79    /// Version 0.3
80    #[serde(rename = "0.3")]
81    V0_3,
82}
83
84/// The main Sigstore bundle structure
85#[derive(Debug, Clone, PartialEq, Serialize)]
86#[serde(rename_all = "camelCase")]
87pub struct Bundle {
88    /// Media type identifying the bundle version
89    pub media_type: String,
90    /// Verification material (certificate chain or public key)
91    pub verification_material: VerificationMaterial,
92    /// The content being signed (message signature or DSSE envelope)
93    #[serde(flatten)]
94    pub content: SignatureContent,
95}
96
97impl Bundle {
98    /// Parse a bundle from JSON, preserving raw DSSE envelope for hash verification
99    pub fn from_json(json: &str) -> Result<Self> {
100        serde_json::from_str(json).map_err(Error::Json)
101    }
102
103    /// Serialize the bundle to JSON
104    pub fn to_json(&self) -> Result<String> {
105        serde_json::to_string(self).map_err(Error::Json)
106    }
107
108    /// Serialize the bundle to pretty-printed JSON
109    pub fn to_json_pretty(&self) -> Result<String> {
110        serde_json::to_string_pretty(self).map_err(Error::Json)
111    }
112
113    /// Get the bundle version from the media type
114    pub fn version(&self) -> Result<MediaType> {
115        MediaType::from_str(&self.media_type)
116    }
117
118    /// Get the signing certificate if present
119    pub fn signing_certificate(&self) -> Option<&DerCertificate> {
120        match &self.verification_material.content {
121            VerificationMaterialContent::Certificate(cert) => Some(&cert.raw_bytes),
122            VerificationMaterialContent::X509CertificateChain { certificates } => {
123                certificates.first().map(|c| &c.raw_bytes)
124            }
125            VerificationMaterialContent::PublicKey { .. } => None,
126        }
127    }
128
129    /// Check if the bundle has an inclusion proof
130    pub fn has_inclusion_proof(&self) -> bool {
131        self.verification_material
132            .tlog_entries
133            .iter()
134            .any(|e| e.inclusion_proof.is_some())
135    }
136
137    /// Check if the bundle has an inclusion promise (SET)
138    pub fn has_inclusion_promise(&self) -> bool {
139        self.verification_material
140            .tlog_entries
141            .iter()
142            .any(|e| e.inclusion_promise.is_some())
143    }
144}
145
146/// The signature content (either a message signature or DSSE envelope)
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub enum SignatureContent {
150    /// A simple message signature
151    MessageSignature(MessageSignature),
152    /// A DSSE envelope
153    DsseEnvelope(DsseEnvelope),
154}
155
156/// A simple message signature
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct MessageSignature {
160    /// Message digest (optional, for detached signatures)
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub message_digest: Option<MessageDigest>,
163    /// The signature bytes
164    pub signature: SignatureBytes,
165}
166
167/// Message digest with algorithm
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct MessageDigest {
171    /// Hash algorithm
172    pub algorithm: HashAlgorithm,
173    /// Digest bytes
174    pub digest: Sha256Hash,
175}
176
177/// Verification material containing certificate/key and log entries
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct VerificationMaterial {
181    /// Certificate, certificate chain, or public key
182    #[serde(flatten)]
183    pub content: VerificationMaterialContent,
184    /// Transparency log entries
185    #[serde(default)]
186    pub tlog_entries: Vec<TransparencyLogEntry>,
187    /// RFC 3161 timestamp verification data
188    #[serde(default, deserialize_with = "deserialize_null_as_default")]
189    pub timestamp_verification_data: TimestampVerificationData,
190}
191
192/// The verification material content type
193///
194/// The field name in JSON determines which variant is used:
195/// - "certificate" -> Certificate variant (v0.3 format)
196/// - "x509CertificateChain" -> X509CertificateChain variant (v0.1/v0.2 format)
197/// - "publicKey" -> PublicKey variant
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub enum VerificationMaterialContent {
201    /// Single certificate (v0.3 format)
202    Certificate(CertificateContent),
203    /// Certificate chain (v0.1/v0.2 format)
204    X509CertificateChain {
205        /// Chain of certificates
206        certificates: Vec<X509Certificate>,
207    },
208    /// Public key (keyless alternative)
209    PublicKey {
210        /// Public key hint
211        hint: String,
212    },
213}
214
215/// Certificate content for v0.3 bundles
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct CertificateContent {
219    /// DER-encoded certificate
220    pub raw_bytes: DerCertificate,
221}
222
223/// X.509 certificate in the chain
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "camelCase")]
226pub struct X509Certificate {
227    /// DER-encoded certificate
228    pub raw_bytes: DerCertificate,
229}
230
231/// A transparency log entry
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct TransparencyLogEntry {
235    /// Log index
236    pub log_index: LogIndex,
237    /// Log ID
238    pub log_id: LogId,
239    /// Kind and version of the entry
240    pub kind_version: KindVersion,
241    /// Integrated time (Unix timestamp)
242    /// For Rekor V2 entries, this field may be omitted (defaults to 0)
243    #[serde(default, with = "string_i64", skip_serializing_if = "is_zero")]
244    pub integrated_time: i64,
245    /// Inclusion promise (Signed Entry Timestamp)
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub inclusion_promise: Option<InclusionPromise>,
248    /// Inclusion proof
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub inclusion_proof: Option<InclusionProof>,
251    /// Canonicalized body
252    pub canonicalized_body: CanonicalizedBody,
253}
254
255/// Log identifier
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct LogId {
259    /// Key ID (base64 encoded SHA-256 of public key)
260    pub key_id: LogKeyId,
261}
262
263/// Entry kind and version
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct KindVersion {
267    /// Entry kind (e.g., "hashedrekord")
268    pub kind: String,
269    /// Entry version (e.g., "0.0.1")
270    pub version: String,
271}
272
273/// Inclusion promise (Signed Entry Timestamp)
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct InclusionPromise {
277    /// Signed entry timestamp
278    pub signed_entry_timestamp: SignedTimestamp,
279}
280
281/// Inclusion proof in the Merkle tree
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct InclusionProof {
285    /// Index of the entry in the log
286    pub log_index: LogIndex,
287    /// Root hash of the tree
288    pub root_hash: Sha256Hash,
289    /// Tree size at time of proof
290    #[serde(with = "string_i64")]
291    pub tree_size: i64,
292    /// Hashes in the inclusion proof path
293    #[serde(with = "sha256_hash_vec")]
294    pub hashes: Vec<Sha256Hash>,
295    /// Checkpoint (signed tree head) - optional
296    #[serde(default, skip_serializing_if = "CheckpointData::is_empty")]
297    pub checkpoint: CheckpointData,
298}
299
300/// Serde helper for `Vec<Sha256Hash>`
301mod sha256_hash_vec {
302    use super::Sha256Hash;
303    use serde::{Deserialize, Deserializer, Serialize, Serializer};
304
305    pub fn serialize<S>(hashes: &[Sha256Hash], serializer: S) -> Result<S::Ok, S::Error>
306    where
307        S: Serializer,
308    {
309        // Sha256Hash already implements Serialize (as base64)
310        hashes.serialize(serializer)
311    }
312
313    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Sha256Hash>, D::Error>
314    where
315        D: Deserializer<'de>,
316    {
317        Vec::<Sha256Hash>::deserialize(deserializer)
318    }
319}
320
321/// Checkpoint data in inclusion proof
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
323#[serde(rename_all = "camelCase")]
324pub struct CheckpointData {
325    /// Text representation of the checkpoint
326    #[serde(default)]
327    pub envelope: String,
328}
329
330impl CheckpointData {
331    /// Parse the checkpoint text
332    pub fn parse(&self) -> Result<Checkpoint> {
333        Checkpoint::from_text(&self.envelope)
334    }
335
336    /// Check if checkpoint data is empty
337    pub fn is_empty(&self) -> bool {
338        self.envelope.is_empty()
339    }
340}
341
342/// RFC 3161 timestamp verification data
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
344#[serde(rename_all = "camelCase")]
345pub struct TimestampVerificationData {
346    /// RFC 3161 signed timestamps
347    #[serde(default, skip_serializing_if = "Vec::is_empty")]
348    pub rfc3161_timestamps: Vec<Rfc3161Timestamp>,
349}
350
351/// An RFC 3161 timestamp
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "camelCase")]
354pub struct Rfc3161Timestamp {
355    /// Signed timestamp data (DER-encoded)
356    pub signed_timestamp: TimestampToken,
357}
358
359/// Default media type for bundles that don't specify one (pre-v0.1 format)
360fn default_media_type() -> String {
361    "application/vnd.dev.sigstore.bundle+json;version=0.1".to_string()
362}
363
364// Custom Deserialize implementation for Bundle
365impl<'de> Deserialize<'de> for Bundle {
366    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
367    where
368        D: serde::Deserializer<'de>,
369    {
370        #[derive(Deserialize)]
371        #[serde(rename_all = "camelCase")]
372        struct BundleHelper {
373            // Cosign V1 bundles may not have mediaType - default to v0.1
374            #[serde(default = "default_media_type")]
375            media_type: String,
376            verification_material: VerificationMaterial,
377            #[serde(flatten)]
378            content: SignatureContent,
379        }
380
381        let helper = BundleHelper::deserialize(deserializer)?;
382
383        Ok(Bundle {
384            media_type: helper.media_type,
385            verification_material: helper.verification_material,
386            content: helper.content,
387        })
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_media_type_parsing() {
397        assert_eq!(
398            MediaType::from_str("application/vnd.dev.sigstore.bundle+json;version=0.1").unwrap(),
399            MediaType::Bundle0_1
400        );
401        assert_eq!(
402            MediaType::from_str("application/vnd.dev.sigstore.bundle+json;version=0.2").unwrap(),
403            MediaType::Bundle0_2
404        );
405        assert_eq!(
406            MediaType::from_str("application/vnd.dev.sigstore.bundle.v0.3+json").unwrap(),
407            MediaType::Bundle0_3
408        );
409    }
410
411    #[test]
412    fn test_media_type_invalid() {
413        assert!(MediaType::from_str("invalid").is_err());
414    }
415}