//! Provenance others can verify (oxygenhub#239): a Flow artifact's in-toto //! Statement with an SLSA provenance v1 predicate, in a DSSE envelope signed //! with the hub's Ed25519 key, as `cosign verify-blob-attestation` and other //! in-toto and SLSA verifiers read it. The runner signed the job's receipt //! with a key the hub holds no secret of; the hub checked that receipt when //! it kept it, and this envelope is the hub's statement of what the receipt //! says, in the shape the wider tools understand. `/.well-known/oxyhub-signing.pem` //! is the public key, as cosign's `--key` takes it. use crate::Shared; /// DSSE's payload type for in-toto statements. pub const PAYLOAD_TYPE: &str = "application/vnd.in-toto+json"; /// What the statement says of one artifact. pub struct Subject<'a> { /// The artifact's file name. pub name: &'a str, pub bytes: &'a [u8], /// `owner/name` and the hub's URL of the run. pub repo: &'a str, pub run_url: &'a str, pub bookmark: &'a str, /// The state the run built, and the heads it is. pub state: &'a str, pub heads: &'a str, pub pipeline_hash: &'a str, pub job: &'a str, /// The runner that ran it and signed its receipt. pub runner: &'a str, pub receipt_signer: &'a str, pub receipt_attest: &'a str, pub started_ms: Option, pub finished_ms: Option, } /// The in-toto Statement v1 with an SLSA provenance v1 predicate. pub fn statement(base_url: &str, s: &Subject) -> serde_json::Value { use sha2::Digest; let sha256 = hex::encode(sha2::Sha256::digest(s.bytes)); let when = |ms: Option| { ms.and_then(|t| time::OffsetDateTime::from_unix_timestamp_nanos(t as i128 * 1_000_000).ok()) .and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok()) }; let mut metadata = serde_json::json!({ "invocationId": s.run_url }); if let Some(t) = when(s.started_ms) { metadata["startedOn"] = t.into(); } if let Some(t) = when(s.finished_ms) { metadata["finishedOn"] = t.into(); } serde_json::json!({ "_type": "https://in-toto.io/Statement/v1", "subject": [{ "name": s.name, "digest": { "sha256": sha256, "blake3": blake3::hash(s.bytes).to_hex().to_string() } }], "predicateType": "https://slsa.dev/provenance/v1", "predicate": { "buildDefinition": { "buildType": format!("{base_url}/oxygenflow/job/v1"), "externalParameters": { "repository": format!("{base_url}/{}", s.repo), "bookmark": s.bookmark, "job": s.job, "pipeline": s.pipeline_hash, }, "resolvedDependencies": [{ "uri": format!("{base_url}/{}", s.repo), "digest": { "oxygen-state": s.state }, "annotations": { "heads": s.heads }, }], }, "runDetails": { "builder": { "id": format!("{base_url}/oxygenflow/runner/{}", s.runner), }, "metadata": metadata, "byproducts": [{ "name": "oxygen-receipt", "digest": { "oxygen-attestation": s.receipt_attest }, "annotations": { "signedBy": s.receipt_signer }, }], }, }, }) } /// DSSE's pre-authentication encoding: what the signature covers. pub fn pae(payload_type: &str, payload: &[u8]) -> Vec { let mut out = format!("DSSEv1 {} {} {} ", payload_type.len(), payload_type, payload.len()).into_bytes(); out.extend_from_slice(payload); out } /// `statement` in a DSSE envelope signed with the hub's key. pub fn envelope(app: &Shared, statement: &serde_json::Value) -> anyhow::Result { let payload = serde_json::to_vec(statement)?; let (key, secret) = crate::keys::load_secret(&app.config.keys_dir())?; let pair = ring::signature::Ed25519KeyPair::from_seed_unchecked(&secret.ed_seed) .map_err(|_| anyhow::anyhow!("the hub's key is not an Ed25519 seed"))?; let sig = pair.sign(&pae(PAYLOAD_TYPE, &payload)); Ok(serde_json::json!({ "payloadType": PAYLOAD_TYPE, "payload": base64(&payload), "signatures": [{ "keyid": key.to_hex(), "sig": base64(sig.as_ref()) }], })) } /// The hub's Ed25519 public key as a PEM SubjectPublicKeyInfo. pub fn public_pem(app: &Shared) -> String { // SEQUENCE { SEQUENCE { OID 1.3.101.112 }, BIT STRING { key } } let mut der = vec![0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]; der.extend_from_slice(&app.hub_key.identity.ed25519_pk); let body = base64(&der); format!("-----BEGIN PUBLIC KEY-----\n{body}\n-----END PUBLIC KEY-----\n") } /// Standard base64 with padding, as DSSE and PEM use. pub fn base64(bytes: &[u8]) -> String { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len())); for i in 0..4 { if i <= chunk.len() { out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char); } else { out.push('='); } } } out } #[cfg(test)] mod tests { #[test] fn pae_and_base64_are_the_specs() { // DSSE's own example: PAE("http://example.com/HelloWorld", "hello world"). assert_eq!( super::pae("http://example.com/HelloWorld", b"hello world"), b"DSSEv1 29 http://example.com/HelloWorld 11 hello world".to_vec() ); assert_eq!(super::base64(b"f"), "Zg=="); assert_eq!(super::base64(b"fo"), "Zm8="); assert_eq!(super::base64(b"foo"), "Zm9v"); assert_eq!(super::base64(b"foobar"), "Zm9vYmFy"); } }