oxy receipt export --format slsa: SLSA provenance cosign can check a39754d02c58
A valid receipt exports as an in-toto Statement v1 with an SLSA provenance v1 predicate in a DSSE envelope, signed with the Ed25519 half of the exporter's signing key. Subjects are the job's outputs by Oxygen hash (the state when there are none); the signed receipt rides along as a byproduct, so the runner's own composite signature can still be checked. oxy identity export --pem writes the signing key as the PEM cosign's --key takes. oxy-core's new dsse module holds PAE, the envelope, base64 and the PEM; the hub's copy can move to it. Spec 14.6 and the guide say so; tests check PAE against DSSE's example, the envelope against the right and wrong keys, the PEM, and the CLI end to end. Refs andymccall/oxygenhub#239
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-17 02:19 UTC
- Signature
- ✔ signed by Claude Code
- Hash
a39754d02c58821ead87a27f98ce909521dff56945c5b04f233247201f90fe32- Depends on
- 119bbf50cb86 1983686fb7a7 28fc23d7706a 4dcb8c0916b7 75167672ebcd 83e0677b07fe c6aa5bd9d1c8 d42369abf180 e3d071cecfe2 ee06a6c557b7 fc1a466a1e66 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 02e691881a4e 5f661562b3a4
- In bookmarks
- carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 24 atoms, 1 tree operation, 9 files
Browse the tree at this change
added crates/oxy-core/src/dsse.rs +151 −0
| 1 | + | //! DSSE envelopes, the signature wrapper in-toto, SLSA and cosign read, and |
|
| 2 | + | //! the PEM public key cosign's `--key` takes: how Oxygen's attestations |
|
| 3 | + | //! reach tools outside it (spec §14.6, oxygenhub#239). Only the Ed25519 half |
|
| 4 | + | //! of a composite key signs here, since DSSE verifiers know Ed25519 and not |
|
| 5 | + | //! ML-DSA-65; DSSE's own pre-authentication encoding separates the domain |
|
| 6 | + | //! from Oxygen's signatures (which begin `oxy1 sig`). |
|
| 7 | + | ||
| 8 | + | use crate::identity::{Identity, SecretKey, VerifyError}; |
|
| 9 | + | ||
| 10 | + | /// DSSE's payload type for in-toto statements. |
|
| 11 | + | pub const IN_TOTO: &str = "application/vnd.in-toto+json"; |
|
| 12 | + | ||
| 13 | + | /// DSSE's pre-authentication encoding: what the signature covers. |
|
| 14 | + | pub fn pae(payload_type: &str, payload: &[u8]) -> Vec<u8> { |
|
| 15 | + | let mut out = format!( |
|
| 16 | + | "DSSEv1 {} {} {} ", |
|
| 17 | + | payload_type.len(), |
|
| 18 | + | payload_type, |
|
| 19 | + | payload.len() |
|
| 20 | + | ) |
|
| 21 | + | .into_bytes(); |
|
| 22 | + | out.extend_from_slice(payload); |
|
| 23 | + | out |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// A DSSE envelope over `payload`, signed by `sk`, as JSON. `keyid` names |
|
| 27 | + | /// the key to a reader; DSSE gives it no meaning. |
|
| 28 | + | pub fn envelope(payload_type: &str, payload: &[u8], keyid: &str, sk: &SecretKey) -> String { |
|
| 29 | + | let sig = sk.sign_ed25519(&pae(payload_type, payload)); |
|
| 30 | + | format!( |
|
| 31 | + | "{{\"payloadType\":{},\"payload\":\"{}\",\"signatures\":[{{\"keyid\":{},\"sig\":\"{}\"}}]}}", |
|
| 32 | + | crate::repo::json_str(payload_type), |
|
| 33 | + | base64(payload), |
|
| 34 | + | crate::repo::json_str(keyid), |
|
| 35 | + | base64(&sig) |
|
| 36 | + | ) |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | /// Check one envelope signature, its parts already decoded. |
|
| 40 | + | pub fn verify( |
|
| 41 | + | identity: &Identity, |
|
| 42 | + | payload_type: &str, |
|
| 43 | + | payload: &[u8], |
|
| 44 | + | sig: &[u8], |
|
| 45 | + | ) -> Result<(), VerifyError> { |
|
| 46 | + | identity.verify_ed25519(&pae(payload_type, payload), sig) |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /// The identity's Ed25519 public key as a PEM SubjectPublicKeyInfo. |
|
| 50 | + | pub fn public_pem(identity: &Identity) -> String { |
|
| 51 | + | // SEQUENCE { SEQUENCE { OID 1.3.101.112 }, BIT STRING { key } } |
|
| 52 | + | let mut der = vec![ |
|
| 53 | + | 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, |
|
| 54 | + | ]; |
|
| 55 | + | der.extend_from_slice(&identity.ed25519_pk); |
|
| 56 | + | format!( |
|
| 57 | + | "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----\n", |
|
| 58 | + | base64(&der) |
|
| 59 | + | ) |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; |
|
| 63 | + | ||
| 64 | + | /// Standard base64 with padding, as DSSE and PEM use. |
|
| 65 | + | pub fn base64(bytes: &[u8]) -> String { |
|
| 66 | + | let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); |
|
| 67 | + | for chunk in bytes.chunks(3) { |
|
| 68 | + | let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len())); |
|
| 69 | + | for i in 0..4 { |
|
| 70 | + | if i <= chunk.len() { |
|
| 71 | + | out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char); |
|
| 72 | + | } else { |
|
| 73 | + | out.push('='); |
|
| 74 | + | } |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | out |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | /// [`base64`] back to bytes; `None` for anything that is not. |
|
| 81 | + | pub fn base64_decode(text: &str) -> Option<Vec<u8>> { |
|
| 82 | + | let text = text.trim_end_matches('='); |
|
| 83 | + | let mut out = Vec::with_capacity(text.len() * 3 / 4); |
|
| 84 | + | let (mut acc, mut bits) = (0u32, 0); |
|
| 85 | + | for c in text.bytes() { |
|
| 86 | + | let v = ALPHABET.iter().position(|a| *a == c)? as u32; |
|
| 87 | + | acc = (acc << 6) | v; |
|
| 88 | + | bits += 6; |
|
| 89 | + | if bits >= 8 { |
|
| 90 | + | bits -= 8; |
|
| 91 | + | out.push((acc >> bits) as u8); |
|
| 92 | + | acc &= (1 << bits) - 1; |
|
| 93 | + | } |
|
| 94 | + | } |
|
| 95 | + | Some(out) |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | #[cfg(test)] |
|
| 99 | + | mod tests { |
|
| 100 | + | use super::*; |
|
| 101 | + | ||
| 102 | + | #[test] |
|
| 103 | + | fn pae_and_base64_are_the_specs() { |
|
| 104 | + | // DSSE's own example: PAE("http://example.com/HelloWorld", "hello world"). |
|
| 105 | + | assert_eq!( |
|
| 106 | + | pae("http://example.com/HelloWorld", b"hello world"), |
|
| 107 | + | b"DSSEv1 29 http://example.com/HelloWorld 11 hello world".to_vec() |
|
| 108 | + | ); |
|
| 109 | + | for (plain, coded) in [ |
|
| 110 | + | ("", ""), |
|
| 111 | + | ("f", "Zg=="), |
|
| 112 | + | ("fo", "Zm8="), |
|
| 113 | + | ("foo", "Zm9v"), |
|
| 114 | + | ("foobar", "Zm9vYmFy"), |
|
| 115 | + | ] { |
|
| 116 | + | assert_eq!(base64(plain.as_bytes()), coded); |
|
| 117 | + | assert_eq!(base64_decode(coded).unwrap(), plain.as_bytes()); |
|
| 118 | + | } |
|
| 119 | + | assert!(base64_decode("Zm9v!").is_none()); |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | #[test] |
|
| 123 | + | fn an_envelope_verifies_with_its_key_and_nothing_else() { |
|
| 124 | + | let sk = SecretKey::generate(); |
|
| 125 | + | let id = sk.identity("Ada", "[email protected]", 1); |
|
| 126 | + | let other = SecretKey::generate().identity("Eve", "[email protected]", 1); |
|
| 127 | + | let payload = br#"{"_type":"https://in-toto.io/Statement/v1"}"#; |
|
| 128 | + | let env = envelope(IN_TOTO, payload, "k", &sk); |
|
| 129 | + | let sig_b64 = env |
|
| 130 | + | .rsplit("\"sig\":\"") |
|
| 131 | + | .next() |
|
| 132 | + | .unwrap() |
|
| 133 | + | .trim_end_matches("\"}]}"); |
|
| 134 | + | let sig = base64_decode(sig_b64).unwrap(); |
|
| 135 | + | assert!(env.contains(&format!("\"payload\":\"{}\"", base64(payload)))); |
|
| 136 | + | assert!(verify(&id, IN_TOTO, payload, &sig).is_ok()); |
|
| 137 | + | assert!(verify(&other, IN_TOTO, payload, &sig).is_err()); |
|
| 138 | + | assert!(verify(&id, IN_TOTO, b"{}", &sig).is_err()); |
|
| 139 | + | assert!(verify(&id, "text/plain", payload, &sig).is_err()); |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | #[test] |
|
| 143 | + | fn the_pem_holds_the_ed25519_key() { |
|
| 144 | + | let id = SecretKey::generate().identity("Ada", "[email protected]", 1); |
|
| 145 | + | let pem = public_pem(&id); |
|
| 146 | + | let body: String = pem.lines().filter(|l| !l.starts_with("-----")).collect(); |
|
| 147 | + | let der = base64_decode(&body).unwrap(); |
|
| 148 | + | assert_eq!(der.len(), 44); |
|
| 149 | + | assert_eq!(&der[12..], &id.ed25519_pk); |
|
| 150 | + | } |
|
| 151 | + | } |
modified crates/oxy-core/src/identity.rs +15 −0
| … | 135 unchanged lines |
||
| 136 | 136 | } |
|
| 137 | 137 | } |
|
| 138 | 138 | ||
| 139 | + | /// Check a [`SecretKey::sign_ed25519`] signature by this identity. |
|
| 140 | + | pub fn verify_ed25519(&self, msg: &[u8], sig: &[u8]) -> Result<(), VerifyError> { |
|
| 141 | + | let sig = EdSignature::from_slice(sig).map_err(|_| VerifyError::Ed25519)?; |
|
| 142 | + | self.ed_key()? |
|
| 143 | + | .verify_strict(msg, &sig) |
|
| 144 | + | .map_err(|_| VerifyError::Ed25519) |
|
| 145 | + | } |
|
| 146 | + | ||
| 139 | 147 | fn ed_key(&self) -> Result<EdVerifyingKey, VerifyError> { |
|
| 140 | 148 | EdVerifyingKey::from_bytes(&self.ed25519_pk).map_err(|_| VerifyError::BadKey) |
|
| 141 | 149 | } |
|
| … | 178 unchanged lines |
||
| 320 | 328 | previous: None, |
|
| 321 | 329 | delegates: vec![], |
|
| 322 | 330 | } |
|
| 331 | + | } |
|
| 332 | + | ||
| 333 | + | /// Ed25519 alone over `msg`, for a format that separates its own |
|
| 334 | + | /// domain and whose verifiers know only Ed25519 (DSSE: see |
|
| 335 | + | /// [`crate::dsse`]). Oxygen's own objects are signed with [`Self::sign`]. |
|
| 336 | + | pub fn sign_ed25519(&self, msg: &[u8]) -> [u8; 64] { |
|
| 337 | + | self.ed().sign(msg).to_bytes() |
|
| 323 | 338 | } |
|
| 324 | 339 | ||
| 325 | 340 | pub fn sign(&self, domain: Domain, key: &KeyId, target: &ObjectRef) -> Signature { |
|
| … | 225 unchanged lines |
modified crates/oxy-core/src/lib.rs +1 −0
| … | 6 unchanged lines |
||
| 7 | 7 | pub mod cbor; |
|
| 8 | 8 | pub mod chunk; |
|
| 9 | 9 | pub mod diff; |
|
| 10 | + | pub mod dsse; |
|
| 10 | 11 | pub mod durable; |
|
| 11 | 12 | pub mod fasthash; |
|
| 12 | 13 | pub mod graph; |
|
| … | 17 unchanged lines |
modified docs/guide.md +8 −0
| … | 300 unchanged lines |
||
| 301 | 301 | oxy receipt export 3f2a9c build.receipt # hand it to someone |
|
| 302 | 302 | oxy receipt verify build.receipt # check one without keeping it |
|
| 303 | 303 | oxy receipt import build.receipt # verify and keep it |
|
| 304 | + | oxy receipt export 3f2a9c prov.json --format slsa # SLSA provenance, signed with your key |
|
| 305 | + | oxy identity export --pem me.pem # the key cosign checks it with |
|
| 304 | 306 | ``` |
|
| 305 | 307 | ||
| 308 | + | The SLSA form is for tools outside Oxygen: in-toto and SLSA verifiers, and |
|
| 309 | + | `cosign verify-blob-attestation --key me.pem --type slsaprovenance1 |
|
| 310 | + | --check-claims=false --signature prov.json`. Its subjects are named by |
|
| 311 | + | Oxygen hashes, which cosign cannot compute from a file, hence |
|
| 312 | + | `--check-claims=false`. |
|
| 313 | + | ||
| 306 | 314 | ## 11. Big repositories: partial, sparse, shallow |
|
| 307 | 315 | ||
| 308 | 316 | ``` |
|
| … | 148 unchanged lines |
modified docs/spec.md +2 −0
| … | 694 unchanged lines |
||
| 695 | 695 | ||
| 696 | 696 | **Storage.** A repository keeps receipts beside the state they name: the receipt, the attestation with its signatures, and the runner's identity, indexed by state ObjectRef. Receipts live as long as the changes of their state, and keep their outputs and inputs alive (§21). `oxy receipt list|show|export|verify|import` reads and exchanges them; a receipt for another repository is refused. |
|
| 697 | 697 | ||
| 698 | + | **SLSA provenance.** For tools outside Oxygen, `oxy receipt export --format slsa` writes what a valid receipt says as an in-toto Statement v1 with an SLSA provenance v1 predicate (`buildType` `https://oxygenhub.io/oxygenflow/job/v1`), in a DSSE envelope (payload type `application/vnd.in-toto+json`). Its subjects are the job's outputs, with the digest `oxygen-blob` (their ObjectRefs); a job with none has the state as its subject (`oxygen-state`). Resolved dependencies are the state, the scope (`oxygen-scope`) and the inputs; the builder is `urn:oxygen:runner:<runner KeyId>`; `startedOn` is the attestation's wall time less `duration_ms`, `finishedOn` that time. The signed receipt itself is a byproduct (`oxygen-receipt`, media type `application/vnd.oxygen.receipt+cbor`, base64 `content`), so an Oxygen verifier can still check the runner's signature. The envelope is signed by the **exporter**, with the Ed25519 half of its signing key over DSSE's pre-authentication encoding (which cannot be mistaken for an Oxygen signature message, §14.3); `keyid` is the KeyId in hex. DSSE verifiers know no ML-DSA, so this signature is classical: it vouches that the exporter checked the receipt, and the receipt inside keeps the runner's composite signature. `oxy identity export --pem` writes the signing key's Ed25519 public key as a PEM SubjectPublicKeyInfo, as cosign's `--key` takes it. A host does the same with its own key for the artifacts it keeps (hub spec). |
|
| 699 | + | ||
| 698 | 700 | ## 15. Transport |
|
| 699 | 701 | ||
| 700 | 702 | Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel. |
|
| … | 339 unchanged lines |
modified src/cli/identity.rs +18 −5
| … | 350 unchanged lines |
||
| 351 | 351 | Ok(0) |
|
| 352 | 352 | } |
|
| 353 | 353 | ||
| 354 | - | pub fn export(path: Option<PathBuf>) -> Result<i32> { |
|
| 354 | + | pub fn export(path: Option<PathBuf>, pem: bool) -> Result<i32> { |
|
| 355 | 355 | let repo = open()?; |
|
| 356 | - | let Some(key) = repo.config.author_key else { |
|
| 356 | + | // The PEM is for checking what this home signs, so it is the signing |
|
| 357 | + | // key's: a delegate's when `author.signer` names one. |
|
| 358 | + | let key = if pem { |
|
| 359 | + | repo.signing_key() |
|
| 360 | + | } else { |
|
| 361 | + | repo.config.author_key |
|
| 362 | + | }; |
|
| 363 | + | let Some(key) = key else { |
|
| 357 | 364 | anyhow::bail!("no identity configured; run `oxy identity create`"); |
|
| 358 | 365 | }; |
|
| 359 | 366 | let id = repo.identity(&key)?; |
|
| 360 | - | let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16)))); |
|
| 361 | - | std::fs::write(&path, id.encode())?; |
|
| 367 | + | let ext = if pem { "pem" } else { "identity" }; |
|
| 368 | + | let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.{ext}", key.short(16)))); |
|
| 369 | + | if pem { |
|
| 370 | + | std::fs::write(&path, oxy_core::dsse::public_pem(&id))?; |
|
| 371 | + | } else { |
|
| 372 | + | std::fs::write(&path, id.encode())?; |
|
| 373 | + | } |
|
| 362 | 374 | println!( |
|
| 363 | - | "{} identity {} <{}> ({}) to {}", |
|
| 375 | + | "{} {} {} <{}> ({}) to {}", |
|
| 364 | 376 | ui::done("Wrote"), |
|
| 377 | + | if pem { "the public key of" } else { "identity" }, |
|
| 365 | 378 | ui::author(&id.name), |
|
| 366 | 379 | id.email, |
|
| 367 | 380 | key.short(12), |
|
| … | 405 unchanged lines |
modified src/cli/mod.rs +14 −3
| … | 614 unchanged lines |
||
| 615 | 615 | /// Change, bookmark or tag whose state holds it |
|
| 616 | 616 | #[arg(long)] |
|
| 617 | 617 | rev: Option<String>, |
|
| 618 | + | /// `oxygen`: the runner's signed receipt, as `verify` and `import` |
|
| 619 | + | /// read it. `slsa`: SLSA provenance in a DSSE envelope, signed with |
|
| 620 | + | /// your key, for in-toto and cosign (`oxy identity export --pem` |
|
| 621 | + | /// gives the key they check it with) |
|
| 622 | + | #[arg(long, default_value = "oxygen", value_parser = ["oxygen", "slsa"])] |
|
| 623 | + | format: String, |
|
| 618 | 624 | }, |
|
| 619 | 625 | /// Check a signed receipt in FILE without keeping it |
|
| 620 | 626 | Verify { |
|
| … | 93 unchanged lines |
||
| 714 | 720 | Show, |
|
| 715 | 721 | /// Write your identity object to a file, to hand to a policy holder |
|
| 716 | 722 | Export { |
|
| 717 | - | /// File to write (default: <key>.identity in the current directory) |
|
| 723 | + | /// File to write (default: <key>.identity, or <key>.pem with --pem, in the current directory) |
|
| 718 | 724 | path: Option<PathBuf>, |
|
| 725 | + | /// Write the Ed25519 public key as PEM instead, as cosign's --key and |
|
| 726 | + | /// other DSSE verifiers take it |
|
| 727 | + | #[arg(long)] |
|
| 728 | + | pem: bool, |
|
| 719 | 729 | }, |
|
| 720 | 730 | /// Sign a host's challenge (hex, at least 16 bytes) to prove you hold your key; prints the signature as hex |
|
| 721 | 731 | SignChallenge { |
|
| … | 334 unchanged lines |
||
| 1056 | 1066 | receipt: r, |
|
| 1057 | 1067 | file, |
|
| 1058 | 1068 | rev, |
|
| 1059 | - | } => receipt::export(&r, &file, rev.as_deref()), |
|
| 1069 | + | format, |
|
| 1070 | + | } => receipt::export(&r, &file, rev.as_deref(), &format), |
|
| 1060 | 1071 | ReceiptCmd::Verify { file, json } => receipt::verify(&file, json), |
|
| 1061 | 1072 | ReceiptCmd::Import { file } => receipt::import(&file), |
|
| 1062 | 1073 | }, |
|
| … | 37 unchanged lines |
||
| 1100 | 1111 | expires, |
|
| 1101 | 1112 | } => identity::create(name, email, local, expires), |
|
| 1102 | 1113 | IdentityCmd::Show => identity::show(), |
|
| 1103 | - | IdentityCmd::Export { path } => identity::export(path), |
|
| 1114 | + | IdentityCmd::Export { path, pem } => identity::export(path, pem), |
|
| 1104 | 1115 | IdentityCmd::SignChallenge { challenge, json } => { |
|
| 1105 | 1116 | identity::sign_challenge(&challenge, json) |
|
| 1106 | 1117 | } |
|
| … | 99 unchanged lines |
modified src/cli/receipt.rs +108 −2
| … | 172 unchanged lines |
||
| 173 | 173 | Ok(if i.problem.is_some() { 1 } else { 0 }) |
|
| 174 | 174 | } |
|
| 175 | 175 | ||
| 176 | - | /// `oxy receipt export HASH FILE`: the signed receipt, to hand to anyone. |
|
| 177 | - | pub fn export(prefix: &str, file: &str, rev: Option<&str>) -> Result<i32> { |
|
| 176 | + | /// `oxy receipt export HASH FILE [--format slsa]`: the signed receipt, to |
|
| 177 | + | /// hand to anyone, or SLSA provenance of it for tools outside Oxygen. |
|
| 178 | + | pub fn export(prefix: &str, file: &str, rev: Option<&str>, format: &str) -> Result<i32> { |
|
| 178 | 179 | let repo = open()?; |
|
| 179 | 180 | let i = find(&repo, prefix, rev)?; |
|
| 181 | + | if format == "slsa" { |
|
| 182 | + | return export_slsa(&repo, &i, file); |
|
| 183 | + | } |
|
| 180 | 184 | std::fs::write(file, i.signed.encode())?; |
|
| 181 | 185 | println!( |
|
| 182 | 186 | "{} {} {}", |
|
| 183 | 187 | ui::done("Exported receipt"), |
|
| 184 | 188 | ui::hash(format!("#{}", i.attest.short(12))), |
|
| 185 | 189 | ui::path(file) |
|
| 190 | + | ); |
|
| 191 | + | Ok(0) |
|
| 192 | + | } |
|
| 193 | + | ||
| 194 | + | /// What SLSA calls the build type of a Flow job. |
|
| 195 | + | pub const BUILD_TYPE: &str = "https://oxygenhub.io/oxygenflow/job/v1"; |
|
| 196 | + | ||
| 197 | + | /// The in-toto Statement v1, with an SLSA provenance v1 predicate, of what |
|
| 198 | + | /// a receipt says (spec §14.6). Its subjects are the job's outputs (the |
|
| 199 | + | /// state it ran on when it made none), named by their Oxygen object hashes; |
|
| 200 | + | /// the signed receipt itself rides along as a byproduct, so an Oxygen |
|
| 201 | + | /// verifier can still check the runner's own signature. |
|
| 202 | + | pub fn statement(sr: &SignedReceipt) -> serde_json::Value { |
|
| 203 | + | use serde_json::json; |
|
| 204 | + | let r = &sr.receipt; |
|
| 205 | + | let subject: Vec<_> = if r.outputs.is_empty() { |
|
| 206 | + | vec![json!({ "name": "state", "digest": { "oxygen-state": sr.state().to_hex() } })] |
|
| 207 | + | } else { |
|
| 208 | + | r.outputs |
|
| 209 | + | .iter() |
|
| 210 | + | .map(|o| json!({ "name": o.to_hex(), "digest": { "oxygen-blob": o.to_hex() } })) |
|
| 211 | + | .collect() |
|
| 212 | + | }; |
|
| 213 | + | let rfc3339 = |ms: u64| super::format::when(ms).replacen(' ', "T", 1); |
|
| 214 | + | let made = sr.attest.hlc.wall_ms; |
|
| 215 | + | let mut deps = vec![ |
|
| 216 | + | json!({ "name": "state", "digest": { "oxygen-state": sr.state().to_hex() } }), |
|
| 217 | + | json!({ "name": "scope", "digest": { "oxygen-scope": r.scope_tree_hash.to_hex() } }), |
|
| 218 | + | ]; |
|
| 219 | + | deps.extend( |
|
| 220 | + | r.inputs |
|
| 221 | + | .iter() |
|
| 222 | + | .map(|h| json!({ "name": h.to_hex(), "digest": { "oxygen-blob": h.to_hex() } })), |
|
| 223 | + | ); |
|
| 224 | + | json!({ |
|
| 225 | + | "_type": "https://in-toto.io/Statement/v1", |
|
| 226 | + | "subject": subject, |
|
| 227 | + | "predicateType": "https://slsa.dev/provenance/v1", |
|
| 228 | + | "predicate": { |
|
| 229 | + | "buildDefinition": { |
|
| 230 | + | "buildType": BUILD_TYPE, |
|
| 231 | + | "externalParameters": { |
|
| 232 | + | "job": r.job_name, |
|
| 233 | + | "pipeline": r.pipeline_hash.to_hex(), |
|
| 234 | + | }, |
|
| 235 | + | "internalParameters": { |
|
| 236 | + | "environment": r.environment.to_hex(), |
|
| 237 | + | }, |
|
| 238 | + | "resolvedDependencies": deps, |
|
| 239 | + | }, |
|
| 240 | + | "runDetails": { |
|
| 241 | + | "builder": { |
|
| 242 | + | "id": format!("urn:oxygen:runner:{}", sr.runner.hash().to_hex()), |
|
| 243 | + | "version": { "name": sr.runner.name }, |
|
| 244 | + | }, |
|
| 245 | + | "metadata": { |
|
| 246 | + | "invocationId": sr.run(), |
|
| 247 | + | "startedOn": rfc3339(made.saturating_sub(r.duration_ms)), |
|
| 248 | + | "finishedOn": rfc3339(made), |
|
| 249 | + | }, |
|
| 250 | + | "byproducts": [{ |
|
| 251 | + | "name": "oxygen-receipt", |
|
| 252 | + | "mediaType": "application/vnd.oxygen.receipt+cbor", |
|
| 253 | + | "content": oxy_core::dsse::base64(&sr.encode()), |
|
| 254 | + | "annotations": { |
|
| 255 | + | "exitCode": r.exit_code, |
|
| 256 | + | "durationMs": r.duration_ms, |
|
| 257 | + | "runner": sr.runner.name, |
|
| 258 | + | }, |
|
| 259 | + | }], |
|
| 260 | + | }, |
|
| 261 | + | }, |
|
| 262 | + | }) |
|
| 263 | + | } |
|
| 264 | + | ||
| 265 | + | fn export_slsa(repo: &oxy_core::repo::Repo, i: &ReceiptInfo, file: &str) -> Result<i32> { |
|
| 266 | + | if let Some(p) = &i.problem { |
|
| 267 | + | bail!( |
|
| 268 | + | "receipt #{} does not verify ({p}); provenance is only made of a valid one", |
|
| 269 | + | i.attest.short(12) |
|
| 270 | + | ); |
|
| 271 | + | } |
|
| 272 | + | let Some((key, sk)) = repo.secret_key()? else { |
|
| 273 | + | bail!("SLSA provenance is signed with your key, and none is set up here; run `oxy identity create`"); |
|
| 274 | + | }; |
|
| 275 | + | let payload = serde_json::to_vec(&statement(&i.signed))?; |
|
| 276 | + | std::fs::write( |
|
| 277 | + | file, |
|
| 278 | + | oxy_core::dsse::envelope(oxy_core::dsse::IN_TOTO, &payload, &key.to_hex(), &sk), |
|
| 279 | + | )?; |
|
| 280 | + | println!( |
|
| 281 | + | "{} {} {} {}", |
|
| 282 | + | ui::done("Exported SLSA provenance of receipt"), |
|
| 283 | + | ui::hash(format!("#{}", i.attest.short(12))), |
|
| 284 | + | ui::path(file), |
|
| 285 | + | ui::dim(format!("signed by {}", key.short(12))) |
|
| 286 | + | ); |
|
| 287 | + | println!( |
|
| 288 | + | "{}", |
|
| 289 | + | ui::dim(format!( |
|
| 290 | + | " check it: oxy identity export --pem key.pem, then cosign verify-blob-attestation --key key.pem --type slsaprovenance1 --check-claims=false --signature {file}" |
|
| 291 | + | )) |
|
| 186 | 292 | ); |
|
| 187 | 293 | Ok(0) |
|
| 188 | 294 | } |
|
| … | 33 unchanged lines |
modified tests/cli_json.rs +50 −0
| … | 365 unchanged lines |
||
| 366 | 366 | ); |
|
| 367 | 367 | assert_eq!(std::fs::read(&out).unwrap(), sr.encode()); |
|
| 368 | 368 | ||
| 369 | + | // SLSA provenance is signed with the exporter's key: none yet, so it is |
|
| 370 | + | // refused and says what to do. |
|
| 371 | + | let prov = tmp.path().join("prov.json"); |
|
| 372 | + | let p = prov.to_str().unwrap(); |
|
| 373 | + | let slsa = ["receipt", "export", &attest[..10], p, "--format", "slsa"]; |
|
| 374 | + | let (code, _, err) = oxy(&r, &home, &slsa); |
|
| 375 | + | assert_ne!(code, 0); |
|
| 376 | + | assert!(err.contains("oxy identity create"), "{err}"); |
|
| 377 | + | let ident = [ |
|
| 378 | + | "identity", |
|
| 379 | + | "create", |
|
| 380 | + | "--name", |
|
| 381 | + | "Ada", |
|
| 382 | + | "--email", |
|
| 383 | + | "[email protected]", |
|
| 384 | + | ]; |
|
| 385 | + | assert_eq!(oxy(&r, &home, &ident).0, 0); |
|
| 386 | + | assert_eq!(oxy(&r, &home, &slsa).0, 0); |
|
| 387 | + | let env: serde_json::Value = serde_json::from_slice(&std::fs::read(&prov).unwrap()).unwrap(); |
|
| 388 | + | assert_eq!(env["payloadType"], oxy_core::dsse::IN_TOTO); |
|
| 389 | + | let payload = oxy_core::dsse::base64_decode(env["payload"].as_str().unwrap()).unwrap(); |
|
| 390 | + | let st: serde_json::Value = serde_json::from_slice(&payload).unwrap(); |
|
| 391 | + | assert_eq!(st["predicateType"], "https://slsa.dev/provenance/v1"); |
|
| 392 | + | assert_eq!(st["subject"][0]["digest"]["oxygen-state"], state.to_hex()); |
|
| 393 | + | let run = &st["predicate"]["runDetails"]; |
|
| 394 | + | assert_eq!(run["metadata"]["invocationId"], "demo#7"); |
|
| 395 | + | let kept = oxy_core::dsse::base64_decode(run["byproducts"][0]["content"].as_str().unwrap()); |
|
| 396 | + | assert_eq!( |
|
| 397 | + | kept.unwrap(), |
|
| 398 | + | sr.encode(), |
|
| 399 | + | "the runner's receipt rides along" |
|
| 400 | + | ); |
|
| 401 | + | // It verifies with the exporter's key and its PEM holds that key. |
|
| 402 | + | let repo = oxy_core::repo::Repo::open(&r) |
|
| 403 | + | .unwrap() |
|
| 404 | + | .with_home(home.clone()); |
|
| 405 | + | let key = repo.signing_key().unwrap(); |
|
| 406 | + | assert_eq!(env["signatures"][0]["keyid"], key.to_hex()); |
|
| 407 | + | let me = repo.identity(&key).unwrap(); |
|
| 408 | + | let sig = oxy_core::dsse::base64_decode(env["signatures"][0]["sig"].as_str().unwrap()).unwrap(); |
|
| 409 | + | assert!(oxy_core::dsse::verify(&me, oxy_core::dsse::IN_TOTO, &payload, &sig).is_ok()); |
|
| 410 | + | assert!(oxy_core::dsse::verify(&runner, oxy_core::dsse::IN_TOTO, &payload, &sig).is_err()); |
|
| 411 | + | let pem = tmp.path().join("key.pem"); |
|
| 412 | + | let pem_args = ["identity", "export", "--pem", pem.to_str().unwrap()]; |
|
| 413 | + | assert_eq!(oxy(&r, &home, &pem_args).0, 0); |
|
| 414 | + | assert_eq!( |
|
| 415 | + | std::fs::read_to_string(&pem).unwrap(), |
|
| 416 | + | oxy_core::dsse::public_pem(&me) |
|
| 417 | + | ); |
|
| 418 | + | ||
| 369 | 419 | // A receipt whose result was changed after signing does not verify. |
|
| 370 | 420 | let mut forged = sr.clone(); |
|
| 371 | 421 | forged.receipt.exit_code = 3; |
|
| … | 8 unchanged lines |