OXYGENHUB
# c2efc0b4674d oxygenhub / crates / hub / src / provenance.rs
5.9 KiB · Rust · last changed Flow: SLSA provenance for artifacts, in DSSE, that cosign verifies 19 days ago source blame history raw
1
//! Provenance others can verify (oxygenhub#239): a Flow artifact's in-toto
2
//! Statement with an SLSA provenance v1 predicate, in a DSSE envelope signed
3
//! with the hub's Ed25519 key, as `cosign verify-blob-attestation` and other
4
//! in-toto and SLSA verifiers read it. The runner signed the job's receipt
5
//! with a key the hub holds no secret of; the hub checked that receipt when
6
//! it kept it, and this envelope is the hub's statement of what the receipt
7
//! says, in the shape the wider tools understand. `/.well-known/oxyhub-signing.pem`
8
//! is the public key, as cosign's `--key` takes it.
9
10
use crate::Shared;
11
12
/// DSSE's payload type for in-toto statements.
13
pub const PAYLOAD_TYPE: &str = "application/vnd.in-toto+json";
14
15
/// What the statement says of one artifact.
16
pub struct Subject<'a> {
17
    /// The artifact's file name.
18
    pub name: &'a str,
19
    pub bytes: &'a [u8],
20
    /// `owner/name` and the hub's URL of the run.
21
    pub repo: &'a str,
22
    pub run_url: &'a str,
23
    pub bookmark: &'a str,
24
    /// The state the run built, and the heads it is.
25
    pub state: &'a str,
26
    pub heads: &'a str,
27
    pub pipeline_hash: &'a str,
28
    pub job: &'a str,
29
    /// The runner that ran it and signed its receipt.
30
    pub runner: &'a str,
31
    pub receipt_signer: &'a str,
32
    pub receipt_attest: &'a str,
33
    pub started_ms: Option<i64>,
34
    pub finished_ms: Option<i64>,
35
}
36
37
/// The in-toto Statement v1 with an SLSA provenance v1 predicate.
38
pub fn statement(base_url: &str, s: &Subject) -> serde_json::Value {
39
    use sha2::Digest;
40
    let sha256 = hex::encode(sha2::Sha256::digest(s.bytes));
41
    let when = |ms: Option<i64>| {
42
        ms.and_then(|t| time::OffsetDateTime::from_unix_timestamp_nanos(t as i128 * 1_000_000).ok())
43
            .and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok())
44
    };
45
    let mut metadata = serde_json::json!({ "invocationId": s.run_url });
46
    if let Some(t) = when(s.started_ms) {
47
        metadata["startedOn"] = t.into();
48
    }
49
    if let Some(t) = when(s.finished_ms) {
50
        metadata["finishedOn"] = t.into();
51
    }
52
    serde_json::json!({
53
        "_type": "https://in-toto.io/Statement/v1",
54
        "subject": [{ "name": s.name, "digest": { "sha256": sha256, "blake3": blake3::hash(s.bytes).to_hex().to_string() } }],
55
        "predicateType": "https://slsa.dev/provenance/v1",
56
        "predicate": {
57
            "buildDefinition": {
58
                "buildType": format!("{base_url}/oxygenflow/job/v1"),
59
                "externalParameters": {
60
                    "repository": format!("{base_url}/{}", s.repo),
61
                    "bookmark": s.bookmark,
62
                    "job": s.job,
63
                    "pipeline": s.pipeline_hash,
64
                },
65
                "resolvedDependencies": [{
66
                    "uri": format!("{base_url}/{}", s.repo),
67
                    "digest": { "oxygen-state": s.state },
68
                    "annotations": { "heads": s.heads },
69
                }],
70
            },
71
            "runDetails": {
72
                "builder": {
73
                    "id": format!("{base_url}/oxygenflow/runner/{}", s.runner),
74
                },
75
                "metadata": metadata,
76
                "byproducts": [{
77
                    "name": "oxygen-receipt",
78
                    "digest": { "oxygen-attestation": s.receipt_attest },
79
                    "annotations": { "signedBy": s.receipt_signer },
80
                }],
81
            },
82
        },
83
    })
84
}
85
86
/// DSSE's pre-authentication encoding: what the signature covers.
87
pub fn pae(payload_type: &str, payload: &[u8]) -> Vec<u8> {
88
    let mut out = format!("DSSEv1 {} {} {} ", payload_type.len(), payload_type, payload.len()).into_bytes();
89
    out.extend_from_slice(payload);
90
    out
91
}
92
93
/// `statement` in a DSSE envelope signed with the hub's key.
94
pub fn envelope(app: &Shared, statement: &serde_json::Value) -> anyhow::Result<serde_json::Value> {
95
    let payload = serde_json::to_vec(statement)?;
96
    let (key, secret) = crate::keys::load_secret(&app.config.keys_dir())?;
97
    let pair = ring::signature::Ed25519KeyPair::from_seed_unchecked(&secret.ed_seed)
98
        .map_err(|_| anyhow::anyhow!("the hub's key is not an Ed25519 seed"))?;
99
    let sig = pair.sign(&pae(PAYLOAD_TYPE, &payload));
100
    Ok(serde_json::json!({
101
        "payloadType": PAYLOAD_TYPE,
102
        "payload": base64(&payload),
103
        "signatures": [{ "keyid": key.to_hex(), "sig": base64(sig.as_ref()) }],
104
    }))
105
}
106
107
/// The hub's Ed25519 public key as a PEM SubjectPublicKeyInfo.
108
pub fn public_pem(app: &Shared) -> String {
109
    // SEQUENCE { SEQUENCE { OID 1.3.101.112 }, BIT STRING { key } }
110
    let mut der = vec![0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00];
111
    der.extend_from_slice(&app.hub_key.identity.ed25519_pk);
112
    let body = base64(&der);
113
    format!("-----BEGIN PUBLIC KEY-----\n{body}\n-----END PUBLIC KEY-----\n")
114
}
115
116
/// Standard base64 with padding, as DSSE and PEM use.
117
pub fn base64(bytes: &[u8]) -> String {
118
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
119
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
120
    for chunk in bytes.chunks(3) {
121
        let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len()));
122
        for i in 0..4 {
123
            if i <= chunk.len() {
124
                out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
125
            } else {
126
                out.push('=');
127
            }
128
        }
129
    }
130
    out
131
}
132
133
#[cfg(test)]
134
mod tests {
135
    #[test]
136
    fn pae_and_base64_are_the_specs() {
137
        // DSSE's own example: PAE("http://example.com/HelloWorld", "hello world").
138
        assert_eq!(
139
            super::pae("http://example.com/HelloWorld", b"hello world"),
140
            b"DSSEv1 29 http://example.com/HelloWorld 11 hello world".to_vec()
141
        );
142
        assert_eq!(super::base64(b"f"), "Zg==");
143
        assert_eq!(super::base64(b"fo"), "Zm8=");
144
        assert_eq!(super::base64(b"foo"), "Zm9v");
145
        assert_eq!(super::base64(b"foobar"), "Zm9vYmFy");
146
    }
147
}