Flow: SLSA provenance for artifacts, in DSSE, that cosign verifies 076c7a7c86b1
Receipts and seals are richer than Sigstore's bundles but only Oxygen tools read them (Sigstore comparison, 2026-10-16). An artifact of a job whose signed receipt the hub kept now has …/provenance: an in-toto Statement v1 with an SLSA provenance v1 predicate in a DSSE envelope, signed with the hub's Ed25519 key, the subject by SHA-256 and BLAKE3, the build definition (repository, bookmark, job, pipeline hash; the state built as its dependency), the run details (the runner as builder, the run, start and finish) and the kept receipt as a byproduct. /.well-known/oxyhub-signing.pem is the key as cosign's --key takes it: cosign verify-blob-attestation --key oxyhub-signing.pem --type slsaprovenance1 --signature tool.intoto.json tool. Tests: DSSE's PAE example and base64; the envelope verifies with the hub's key, the digest is the artifact's, the PEM is the key; no kept receipt, no provenance. 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 01:14 UTC
- Signature
- ✔ signed by Claude Code
- Hash
076c7a7c86b1e375dc69e47fa0760ad10ddbb92a3fac329be83f44ff84eeb556- Depends on
- 10708ab8f54d 3f4b0ea6eabf 42fa95540468 4ac01a732552 8f3d2ccfe6fc 93be9ce4653e a3c8288b0141 d1727c573f30 d23464c0ac60
- Depended on by
- aeed63c00ad7 0ee5c3fb32eb 681db69eba17
- 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
- 7 atoms, 1 tree operation, 6 files
Browse the tree at this change
added crates/hub/src/provenance.rs +147 −0
| 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 | + | } |
modified crates/hub/src/lib.rs +1 −0
| … | 20 unchanged lines |
||
| 21 | 21 | pub mod notify; |
|
| 22 | 22 | pub mod oidc; |
|
| 23 | 23 | pub mod protocol; |
|
| 24 | + | pub mod provenance; |
|
| 24 | 25 | pub mod render; |
|
| 25 | 26 | pub mod repos; |
|
| 26 | 27 | pub mod secrets; |
|
| … | 122 unchanged lines |
modified crates/hub/src/web/flow.rs +60 −0
| … | 647 unchanged lines |
||
| 648 | 648 | .into_response()) |
|
| 649 | 649 | } |
|
| 650 | 650 | ||
| 651 | + | /// `GET …/artifacts/{job}/{artifact}/provenance` (oxygenhub#239): the |
|
| 652 | + | /// artifact's SLSA provenance in a DSSE envelope signed by the hub, for |
|
| 653 | + | /// `cosign verify-blob-attestation --key oxyhub-signing.pem --type |
|
| 654 | + | /// slsaprovenance1`. Only for a job whose signed receipt the hub kept. |
|
| 655 | + | pub async fn provenance( |
|
| 656 | + | State(app): State<Shared>, |
|
| 657 | + | viewer: Viewer, |
|
| 658 | + | Path((owner, name, id, job, artifact)): Path<(String, String, i64, String, String)>, |
|
| 659 | + | ) -> Result<Response, Error> { |
|
| 660 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/provenance"), &viewer); |
|
| 661 | + | let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 662 | + | let run = fdb::run(&app.db, row.id, id) |
|
| 663 | + | .await |
|
| 664 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 665 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 666 | + | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 667 | + | let Some(j) = jobs.iter().find(|j| j.name == job) else { return Err(Error::not_found(shell)) }; |
|
| 668 | + | if j.receipt_attest.is_empty() { |
|
| 669 | + | return Err(Error::bad(shell, "This job has no signed receipt the hub kept, so the hub states no provenance for it.".to_string())); |
|
| 670 | + | } |
|
| 671 | + | let arts = fdb::artifacts(&app.db, j.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 672 | + | let Some(a) = arts.into_iter().find(|a| a.name == artifact) else { return Err(Error::not_found(shell)) }; |
|
| 673 | + | let Some(blob) = oxy_core::hash::ObjectRef::from_hex(&a.blob) else { return Err(Error::not_found(shell)) }; |
|
| 674 | + | let bytes = app.repos.read(&repo, move |r, _| Ok(r.read_blob(&blob)?)).await.map_err(|_| Error::not_found(shell.clone()))?; |
|
| 675 | + | let file = a.path.rsplit('/').next().unwrap_or(&a.name).to_string(); |
|
| 676 | + | let base = app.config.base_url(); |
|
| 677 | + | let subject = crate::provenance::Subject { |
|
| 678 | + | name: &file, |
|
| 679 | + | bytes: &bytes, |
|
| 680 | + | repo: &format!("{owner}/{name}"), |
|
| 681 | + | run_url: &format!("{base}/{owner}/{name}/flow/{id}"), |
|
| 682 | + | bookmark: &run.bookmark, |
|
| 683 | + | state: &run.state, |
|
| 684 | + | heads: &run.heads, |
|
| 685 | + | pipeline_hash: &run.pipeline_hash, |
|
| 686 | + | job: &j.name, |
|
| 687 | + | runner: &j.runner, |
|
| 688 | + | receipt_signer: &j.receipt_signer, |
|
| 689 | + | receipt_attest: &j.receipt_attest, |
|
| 690 | + | started_ms: j.started_ms, |
|
| 691 | + | finished_ms: j.finished_ms, |
|
| 692 | + | }; |
|
| 693 | + | let statement = crate::provenance::statement(base, &subject); |
|
| 694 | + | let envelope = crate::provenance::envelope(&app, &statement).map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 695 | + | Ok(( |
|
| 696 | + | [ |
|
| 697 | + | (axum::http::header::CONTENT_TYPE, "application/json".to_string()), |
|
| 698 | + | (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}.intoto.json\"", file.replace('"', ""))), |
|
| 699 | + | ], |
|
| 700 | + | serde_json::to_string(&envelope).unwrap_or_default(), |
|
| 701 | + | ) |
|
| 702 | + | .into_response()) |
|
| 703 | + | } |
|
| 704 | + | ||
| 705 | + | /// `GET /.well-known/oxyhub-signing.pem`: the hub's Ed25519 public key, as |
|
| 706 | + | /// cosign and other verifiers take it (oxygenhub#239). |
|
| 707 | + | pub async fn signing_key(State(app): State<Shared>) -> Response { |
|
| 708 | + | ([(axum::http::header::CONTENT_TYPE, "application/x-pem-file")], crate::provenance::public_pem(&app)).into_response() |
|
| 709 | + | } |
|
| 710 | + | ||
| 651 | 711 | #[derive(Deserialize)] |
|
| 652 | 712 | pub struct AttachForm { |
|
| 653 | 713 | _csrf: String, |
|
| … | 69 unchanged lines |
modified crates/hub/src/web/mod.rs +2 −0
| … | 56 unchanged lines |
||
| 57 | 57 | .route("/about", get(home::about)) |
|
| 58 | 58 | .route("/.well-known/oxyhub-key", get(home::hub_key)) |
|
| 59 | 59 | .route("/.well-known/oxyhub-ca.pem", get(home::hub_ca)) |
|
| 60 | + | .route("/.well-known/oxyhub-signing.pem", get(flow::signing_key)) |
|
| 60 | 61 | .route("/.well-known/openid-configuration", get(crate::oidc::discovery)) |
|
| 61 | 62 | .route("/.well-known/jwks.json", get(crate::oidc::jwks)) |
|
| 62 | 63 | .route("/style.css", get(statics::style)) |
|
| … | 138 unchanged lines |
||
| 201 | 202 | .route("/{owner}/{name}/receipts/{attest}", get(flow::receipt_file)) |
|
| 202 | 203 | .route("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}", get(flow::artifact_file)) |
|
| 203 | 204 | .route("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/attach", post(flow::attach)) |
|
| 205 | + | .route("/{owner}/{name}/flow/{id}/artifacts/{job}/{artifact}/provenance", get(flow::provenance)) |
|
| 204 | 206 | .route("/{owner}/{name}/releases", get(releases::list)) |
|
| 205 | 207 | .route("/{owner}/{name}/releases/new", get(releases::new_form).post(releases::create)) |
|
| 206 | 208 | .route("/{owner}/{name}/releases/tag/{*tag}", get(releases::show)) |
|
| … | 438 unchanged lines |
modified crates/hub/tests/flow.rs +48 −0
| … | 889 unchanged lines |
||
| 890 | 890 | let (st, got) = get_bytes(&h.app, &link).await; |
|
| 891 | 891 | assert_eq!((st, got == body), (StatusCode::OK, true)); |
|
| 892 | 892 | assert_eq!(get_bytes(&h.app, &format!("/andy/demo/flow/{}/artifacts/build/none", run.number)).await.0, StatusCode::NOT_FOUND); |
|
| 893 | + | // Provenance (#239): only for a job whose signed receipt the hub kept; |
|
| 894 | + | // then an in-toto statement in a DSSE envelope the hub's key verifies. |
|
| 895 | + | let prov = format!("{link}/provenance"); |
|
| 896 | + | assert_eq!(get_bytes(&h.app, &prov).await.0, StatusCode::BAD_REQUEST, "no kept receipt, no provenance"); |
|
| 897 | + | sqlx::query("UPDATE flow_jobs SET receipt_attest = 'a1b2', receipt_signer = 'test-1 runner' WHERE id = $1") |
|
| 898 | + | .bind(id) |
|
| 899 | + | .execute(&h.app.db) |
|
| 900 | + | .await |
|
| 901 | + | .unwrap(); |
|
| 902 | + | let (st, env) = get_bytes(&h.app, &prov).await; |
|
| 903 | + | assert_eq!(st, StatusCode::OK); |
|
| 904 | + | let env: serde_json::Value = serde_json::from_slice(&env).unwrap(); |
|
| 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 | + | }; |
|
| 924 | + | let payload = unb64(env["payload"].as_str().unwrap()); |
|
| 925 | + | let sig = unb64(env["signatures"][0]["sig"].as_str().unwrap()); |
|
| 926 | + | let signed = oxygenhub::provenance::pae("application/vnd.in-toto+json", &payload); |
|
| 927 | + | ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, h.app.hub_key.identity.ed25519_pk) |
|
| 928 | + | .verify(&signed, &sig) |
|
| 929 | + | .expect("the hub's key signed the envelope"); |
|
| 930 | + | let statement: serde_json::Value = serde_json::from_slice(&payload).unwrap(); |
|
| 931 | + | use sha2::Digest; |
|
| 932 | + | assert_eq!(statement["subject"][0]["digest"]["sha256"], hex::encode(sha2::Sha256::digest(&body))); |
|
| 933 | + | assert_eq!(statement["predicateType"], "https://slsa.dev/provenance/v1"); |
|
| 934 | + | assert_eq!(statement["predicate"]["buildDefinition"]["externalParameters"]["job"], "build"); |
|
| 935 | + | assert_eq!(statement["predicate"]["runDetails"]["byproducts"][0]["digest"]["oxygen-attestation"], "a1b2"); |
|
| 936 | + | let (_, pem) = get_bytes(&h.app, "/.well-known/oxyhub-signing.pem").await; |
|
| 937 | + | let pem = String::from_utf8(pem).unwrap(); |
|
| 938 | + | let der = unb64(pem.lines().filter(|l| !l.starts_with("-----")).collect::<String>().as_str()); |
|
| 939 | + | assert_eq!(&der[der.len() - 32..], &h.app.hub_key.identity.ed25519_pk[..], "the PEM holds the hub's key"); |
|
| 940 | + | sqlx::query("UPDATE flow_jobs SET receipt_attest = '', receipt_signer = '' WHERE id = $1").bind(id).execute(&h.app.db).await.unwrap(); |
|
| 893 | 941 | ||
| 894 | 942 | // A writer attaches the artifact to a release from the run page; the |
|
| 895 | 943 | // release then names the run and job that built it, as does the API. |
|
| … | 1096 unchanged lines |
modified docs/spec.md +1 −0
| … | 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 | 502 | - **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`. |
|
| 502 | 503 | - The runner holding the job uploads each one with `POST /api/v1/flow/jobs/{id}/artifacts/{name}` (raw bytes, at most 512 MiB). |
|
| 503 | 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 |