Skip to main content

speccoder_runtime/attest/
dsse.rs

1//! DSSE envelopes and the in-toto Bundle that holds them.
2//!
3//! A signature covers `PAE(payloadType, payload)`, never the JSON around it
4//! (DSSE v1); a Bundle is JSON Lines, one envelope per line, in no
5//! particular order.
6
7use base64::Engine as _;
8use base64::engine::general_purpose::STANDARD;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12/// The payload type of every in-toto statement.
13pub const PAYLOAD_TYPE: &str = "application/vnd.in-toto+json";
14
15/// The bytes a DSSE signature covers:
16/// `"DSSEv1" SP LEN(type) SP type SP LEN(body) SP body`.
17#[must_use]
18pub fn pae(payload_type: &str, body: &[u8]) -> Vec<u8> {
19    let mut out = format!(
20        "DSSEv1 {} {payload_type} {} ",
21        payload_type.len(),
22        body.len()
23    )
24    .into_bytes();
25    out.extend_from_slice(body);
26    out
27}
28
29/// One signature on an envelope.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Signature {
32    /// The key's fingerprint (`SHA256:…` for SSH, the OpenPGP fingerprint).
33    pub keyid: String,
34    /// Base64 of the signature as the signer wrote it (armored text).
35    pub sig: String,
36}
37
38/// A DSSE envelope: one statement and its signatures.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Envelope {
41    /// [`PAYLOAD_TYPE`].
42    #[serde(rename = "payloadType")]
43    pub payload_type: String,
44    /// Base64 of the statement's bytes.
45    pub payload: String,
46    /// Empty when unsigned.
47    pub signatures: Vec<Signature>,
48    /// The Sigstore signer's verification material (certificate, log
49    /// entry); other consumers ignore it.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub sigstore: Option<Value>,
52}
53
54impl Envelope {
55    /// An unsigned envelope around `statement`'s canonical bytes.
56    #[must_use]
57    pub fn unsigned(statement: &[u8]) -> Self {
58        Self {
59            payload_type: PAYLOAD_TYPE.to_owned(),
60            payload: STANDARD.encode(statement),
61            signatures: Vec::new(),
62            sigstore: None,
63        }
64    }
65
66    /// The statement's bytes; `None` when the payload is not base64.
67    #[must_use]
68    pub fn body(&self) -> Option<Vec<u8>> {
69        STANDARD.decode(&self.payload).ok()
70    }
71
72    /// The bytes a signature on this envelope covers.
73    #[must_use]
74    pub fn signed_bytes(&self) -> Option<Vec<u8>> {
75        self.body().map(|b| pae(&self.payload_type, &b))
76    }
77
78    /// The statement, parsed.
79    #[must_use]
80    pub fn statement(&self) -> Option<Value> {
81        serde_json::from_slice(&self.body()?).ok()
82    }
83}
84
85/// Encodes a bundle: one envelope per line.
86#[must_use]
87pub fn encode(lines: &[Envelope]) -> String {
88    lines
89        .iter()
90        .filter_map(|e| serde_json::to_string(e).ok())
91        .map(|l| l + "\n")
92        .collect()
93}
94
95/// Decodes a bundle. Lines that are not envelopes are counted, not kept:
96/// a bundle may hold other tools' lines, and a line nobody can read is a
97/// finding for `attest verify`, not a reason to drop the rest.
98#[must_use]
99pub fn decode(text: &str) -> (Vec<Envelope>, usize) {
100    let mut lines = Vec::new();
101    let mut unreadable = 0;
102    for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
103        match serde_json::from_str::<Envelope>(line) {
104            Ok(e) => lines.push(e),
105            Err(_) => unreadable += 1,
106        }
107    }
108    (lines, unreadable)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn pae_matches_the_dsse_specification() {
117        // The DSSE v1 protocol's own example.
118        assert_eq!(
119            pae("http://example.com/HelloWorld", b"hello world"),
120            b"DSSEv1 29 http://example.com/HelloWorld 11 hello world"
121        );
122        assert_eq!(pae("", b""), b"DSSEv1 0  0 ");
123    }
124
125    #[test]
126    fn a_bundle_round_trips_and_counts_what_it_cannot_read() {
127        let e = Envelope::unsigned(br#"{"a":1}"#);
128        let text = format!("{}not json\n\n", encode(&[e.clone(), e.clone()]));
129        let (lines, unreadable) = decode(&text);
130        assert_eq!((lines.len(), unreadable), (2, 1));
131        assert_eq!(lines[0].statement(), Some(serde_json::json!({ "a": 1 })));
132        assert_eq!(
133            lines[0].signed_bytes().unwrap(),
134            b"DSSEv1 28 application/vnd.in-toto+json 7 {\"a\":1}"
135        );
136    }
137}