Provenance is built on oxy-core's DSSE, with one build type everywhere 681db69eba17
The hub's own PAE, base64 and PEM give way to oxy_core::dsse, which oxy receipt export --format slsa uses too, so both sign and encode the same way. buildType is https://oxygenhub.io/oxygenflow/job/v1 on every hub (it named each hub's URL), so a verifier's policy names one type. Refs #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 04:16 UTC
- Signature
- ✔ signed by Claude Code
- Hash
681db69eba17274bc0cf7fa6f64c4d4f99b04e58f5812e300a62aa0d24390def- Depends on
- 076c7a7c86b1 42fa95540468 4ac01a732552 93be9ce4653e a3c8288b0141
- Depended on by
- d2c1d6a7d12e
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 11 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/hub/src/provenance.rs +9 −55
| … | 7 unchanged lines |
||
| 8 | 8 | //! is the public key, as cosign's `--key` takes it. |
|
| 9 | 9 | ||
| 10 | 10 | use crate::Shared; |
|
| 11 | + | pub use oxy_core::dsse::pae; |
|
| 11 | 12 | ||
| 12 | 13 | /// DSSE's payload type for in-toto statements. |
|
| 13 | - | pub const PAYLOAD_TYPE: &str = "application/vnd.in-toto+json"; |
|
| 14 | + | pub const PAYLOAD_TYPE: &str = oxy_core::dsse::IN_TOTO; |
|
| 14 | 15 | ||
| 16 | + | /// What SLSA calls the build type of a Flow job: one URI wherever the job |
|
| 17 | + | /// ran, the same `oxy receipt export --format slsa` writes. |
|
| 18 | + | pub const BUILD_TYPE: &str = "https://oxygenhub.io/oxygenflow/job/v1"; |
|
| 19 | + | ||
| 15 | 20 | /// What the statement says of one artifact. |
|
| 16 | 21 | pub struct Subject<'a> { |
|
| 17 | 22 | /// The artifact's file name. |
|
| … | 37 unchanged lines |
||
| 55 | 60 | "predicateType": "https://slsa.dev/provenance/v1", |
|
| 56 | 61 | "predicate": { |
|
| 57 | 62 | "buildDefinition": { |
|
| 58 | - | "buildType": format!("{base_url}/oxygenflow/job/v1"), |
|
| 63 | + | "buildType": BUILD_TYPE, |
|
| 59 | 64 | "externalParameters": { |
|
| 60 | 65 | "repository": format!("{base_url}/{}", s.repo), |
|
| 61 | 66 | "bookmark": s.bookmark, |
|
| … | 19 unchanged lines |
||
| 81 | 86 | }, |
|
| 82 | 87 | }, |
|
| 83 | 88 | }) |
|
| 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 | 89 | } |
|
| 92 | 90 | ||
| 93 | 91 | /// `statement` in a DSSE envelope signed with the hub's key. |
|
| 94 | 92 | pub fn envelope(app: &Shared, statement: &serde_json::Value) -> anyhow::Result<serde_json::Value> { |
|
| 95 | 93 | let payload = serde_json::to_vec(statement)?; |
|
| 96 | 94 | 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 | - | })) |
|
| 95 | + | Ok(serde_json::from_str(&oxy_core::dsse::envelope(PAYLOAD_TYPE, &payload, &key.to_hex(), &secret))?) |
|
| 105 | 96 | } |
|
| 106 | 97 | ||
| 107 | 98 | /// The hub's Ed25519 public key as a PEM SubjectPublicKeyInfo. |
|
| 108 | 99 | 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 | - | } |
|
| 100 | + | oxy_core::dsse::public_pem(&app.hub_key.identity) |
|
| 147 | 101 | } |
modified crates/hub/tests/flow.rs +1 −18
| … | 902 unchanged lines |
||
| 903 | 903 | assert_eq!(st, StatusCode::OK); |
|
| 904 | 904 | let env: serde_json::Value = serde_json::from_slice(&env).unwrap(); |
|
| 905 | 905 | assert_eq!(env["payloadType"], "application/vnd.in-toto+json"); |
|
| 906 | - | let unb64 = |t: &str| -> Vec<u8> { |
|
| 907 | - | let val = |c: u8| match c { |
|
| 908 | - | b'A'..=b'Z' => c - b'A', |
|
| 909 | - | b'a'..=b'z' => c - b'a' + 26, |
|
| 910 | - | b'0'..=b'9' => c - b'0' + 52, |
|
| 911 | - | b'+' => 62, |
|
| 912 | - | _ => 63, |
|
| 913 | - | }; |
|
| 914 | - | let clean: Vec<u8> = t.bytes().filter(|c| *c != b'=').collect(); |
|
| 915 | - | let mut out = Vec::new(); |
|
| 916 | - | for chunk in clean.chunks(4) { |
|
| 917 | - | let n = chunk.iter().enumerate().fold(0u32, |acc, (i, c)| acc | (val(*c) as u32) << (18 - 6 * i)); |
|
| 918 | - | for i in 0..chunk.len() - 1 { |
|
| 919 | - | out.push((n >> (16 - 8 * i)) as u8); |
|
| 920 | - | } |
|
| 921 | - | } |
|
| 922 | - | out |
|
| 923 | - | }; |
|
| 906 | + | let unb64 = |t: &str| oxy_core::dsse::base64_decode(t).expect("base64"); |
|
| 924 | 907 | let payload = unb64(env["payload"].as_str().unwrap()); |
|
| 925 | 908 | let sig = unb64(env["signatures"][0]["sig"].as_str().unwrap()); |
|
| 926 | 909 | let signed = oxygenhub::provenance::pae("application/vnd.in-toto+json", &payload); |
|
| … | 1113 unchanged lines |
modified docs/spec.md +1 −1
| … | 497 unchanged lines |
||
| 498 | 498 | Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`. |
|
| 499 | 499 | - **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same. |
|
| 500 | 500 | - **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value. |
|
| 501 | - | - **Provenance others verify** (oxygenhub#239). For an artifact of a job whose signed receipt the hub kept, `…/flow/{run}/artifacts/{job}/{artifact}/provenance` answers an in-toto Statement v1 with an SLSA provenance v1 predicate, in a DSSE envelope signed with the hub's Ed25519 key. The subject is the artifact by SHA-256 (and BLAKE3); the build definition names the repository, bookmark, job and pipeline hash, and depends on the state built; the run details name the runner as builder, the run as invocation with its start and finish, and the kept receipt's attestation as a byproduct. The runner signed the receipt; the hub, which checked it, states what it says in the shape cosign, policy-controller, Kyverno and slsa-verifier read. `/.well-known/oxyhub-signing.pem` is the key: `cosign verify-blob-attestation --key oxyhub-signing.pem --type slsaprovenance1 --signature tool.intoto.json tool`. |
|
| 501 | + | - **Provenance others verify** (oxygenhub#239). For an artifact of a job whose signed receipt the hub kept, `…/flow/{run}/artifacts/{job}/{artifact}/provenance` answers an in-toto Statement v1 with an SLSA provenance v1 predicate, in a DSSE envelope signed with the hub's Ed25519 key. Its `buildType` is `https://oxygenhub.io/oxygenflow/job/v1` on every hub, as `oxy receipt export --format slsa` writes it (Oxygen spec §14.6). The subject is the artifact by SHA-256 (and BLAKE3); the build definition names the repository, bookmark, job and pipeline hash, and depends on the state built; the run details name the runner as builder, the run as invocation with its start and finish, and the kept receipt's attestation as a byproduct. The runner signed the receipt; the hub, which checked it, states what it says in the shape cosign, policy-controller, Kyverno and slsa-verifier read. `/.well-known/oxyhub-signing.pem` is the key: `cosign verify-blob-attestation --key oxyhub-signing.pem --type slsaprovenance1 --signature tool.intoto.json tool`. |
|
| 502 | 502 | - **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`. |
|
| 503 | 503 | - The runner holding the job uploads each one with `POST /api/v1/flow/jobs/{id}/artifacts/{name}` (raw bytes, at most 512 MiB). |
|
| 504 | 504 | - The hub refuses names the job does not declare. It keeps each artifact as a blob of the run's repository, records its path, size and BLAKE3 in `flow_artifacts`, and answers with the blob's ObjectRef. |
|
| … | 205 unchanged lines |