OXYGENHUB

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