OXYGENHUB

Release trust counts receipts by keys this hub certified 244724133fed

A receipt signed by a short-lived key (keyless, #241) named the job's
fresh key, which no runner token signs with, so a release's trust report
warned "that key is not one this hub's runners use" for every asset of
oxy 0.4.5. A receipt whose certificate this hub's key issued for the
repository now counts as a known signer, and the report says "a
short-lived key this hub certified for repo:OWNER/NAME:bookmark:B". The
certificate itself is checked with the receipt, as before.

Refs #241
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 19:59 UTC
Signature
✔ signed by Claude Code
Hash
244724133fedb94ae25b96dea27e95115c271a8e47c09f52dca574dd9fd5eee1
Depends on
3eb491e9489b 42fa95540468 df940d75cd3d
Depended on by
965b950f9b96
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, 2 files

Browse the tree at this change

modified crates/hub/src/web/release_trust.rs +32 −7
…
66 unchanged lines
67 67
    /// Why the kept receipt does not verify now, if it does not.
68 68
    pub receipt_problem: Option<String>,
69 69
    pub runner_revoked: bool,
70 -
    /// The runner's key is one a runner token on this hub signs with.
70 +
    /// The runner's key is one a runner token on this hub signs with, or a
71 +
    /// short-lived key this hub certified for the repository (#241).
71 72
    pub runner_known: bool,
73 +
    /// The subject this hub certified the short-lived key that signed the
74 +
    /// receipt for, when it was one.
75 +
    pub certified_for: Option<String>,
72 76
    /// The signed receipt, hex, for `oxy receipt verify`.
73 77
    pub signed_receipt: Option<String>,
74 78
}
…
126 unchanged lines
201 205
    authors: Vec<AuthorTrust>,
202 206
    changes: usize,
203 207
    /// Kept receipts by attestation: (runner, problem, revoked, signed hex).
204 -
    receipts: HashMap<String, (KeyInfo, Option<String>, bool, String)>,
208 +
    receipts: HashMap<String, KeptReceipt>,
205 209
}
206 210
207 211
fn key_info(r: &oxy_core::repo::Repo, key: &ObjectRef) -> KeyInfo {
…
8 unchanged lines
216 220
217 221
/// Build the report for release `tag`, or `None` when there is no such
218 222
/// release or its tag is gone.
223 +
/// A kept receipt as the report sees it: its signer, why it does not
224 +
/// verify (if it does not), whether the key was revoked, the signed
225 +
/// receipt in hex, and the issuer and subject of a short-lived key's
226 +
/// certificate.
227 +
type KeptReceipt = (KeyInfo, Option<String>, bool, String, Option<(String, String)>);
228 +
219 229
pub async fn report(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow, tag: &str) -> Result<Option<ReleaseTrust>, String> {
220 230
    let releases = rdb::list(&app.db, row.id).await.map_err(|e| e.to_string())?;
221 231
    let Some(rel) = releases.iter().find(|r| r.tag == tag).cloned() else { return Ok(None) };
…
81 unchanged lines
303 313
                        email: i.signed.runner.email.clone(),
304 314
                        account: None,
305 315
                    };
306 -
                    receipts.insert(a.clone(), (runner, i.problem.clone(), i.revoked, hex::encode(i.signed.encode())));
316 +
                    // A short-lived key's certificate: its issuer and subject.
317 +
                    let certified = i.signed.certificate.as_ref().map(|c| (c.cert.cert.issuer.to_hex(), c.cert.cert.subject.clone()));
318 +
                    receipts.insert(a.clone(), (runner, i.problem.clone(), i.revoked, hex::encode(i.signed.encode()), certified));
307 319
                }
308 320
            }
309 321
            Ok(Some(RepoFacts { heads, tag_signer, tag_signature, authors, changes, receipts }))
…
25 unchanged lines
335 347
                job.as_ref().and_then(|j| serde_json::from_str(&j.sandbox).ok()).unwrap_or(serde_json::Value::Null);
336 348
            let heads = run.as_ref().map(|r| r.heads.clone()).unwrap_or_default();
337 349
            let kept = facts.receipts.get(&p.receipt_attest).cloned();
350 +
            // Certified by this hub's key for this repository: as good as
351 +
            // a runner key of this hub (the certificate itself was checked
352 +
            // with the receipt).
353 +
            let certified_for = kept
354 +
                .as_ref()
355 +
                .and_then(|(.., c)| c.clone())
356 +
                .filter(|(issuer, subject)| *issuer == app.hub_key.key.to_hex() && subject.starts_with(&format!("repo:{}:", row.slug())));
357 +
            let certified_for = certified_for.map(|(_, subject)| subject);
338 358
            let s = |v: &serde_json::Value| v.as_str().unwrap_or("").to_string();
339 359
            BuildTrust {
340 360
                run: p.run_number,
…
7 unchanged lines
348 368
                rootfs: s(&sandbox["rootfs"]),
349 369
                runtime: s(&sandbox["runtime"]),
350 370
                exit_code: job.as_ref().and_then(|j| j.exit_code),
351 -
                runner_known: kept.as_ref().is_some_and(|(k, ..)| known_runner_keys.contains(&k.key)),
371 +
                runner_known: kept.as_ref().is_some_and(|(k, ..)| known_runner_keys.contains(&k.key)) || certified_for.is_some(),
372 +
                certified_for,
352 373
                runner: kept.as_ref().map(|(k, ..)| k.clone()),
353 374
                receipt: kept.as_ref().map(|_| p.receipt_attest.clone()),
354 375
                receipt_url: kept.as_ref().map(|_| format!("{base}/receipts/{}", p.receipt_attest)),
355 376
                receipt_problem: kept.as_ref().and_then(|(_, pr, ..)| pr.clone()),
356 -
                runner_revoked: kept.as_ref().is_some_and(|(_, _, rv, _)| *rv),
357 -
                signed_receipt: kept.map(|(.., hex)| hex),
377 +
                runner_revoked: kept.as_ref().is_some_and(|(_, _, rv, ..)| *rv),
378 +
                signed_receipt: kept.map(|(_, _, _, hex, _)| hex),
358 379
            }
359 380
        });
360 381
        asset_rows.push(AssetTrust {
…
94 unchanged lines
455 476
                        format!(
456 477
                            "signed by {}{}",
457 478
                            b.runner.as_ref().map(|k| k.name.as_str()).unwrap_or(""),
458 -
                            if known { ", a runner key of this hub" } else { "; that key is not one this hub's runners use" }
479 +
                            match (&b.certified_for, known) {
480 +
                                (Some(s), _) => format!(", a short-lived key this hub certified for {s}"),
481 +
                                (None, true) => ", a runner key of this hub".to_string(),
482 +
                                (None, false) => "; that key is not one this hub's runners use".to_string(),
483 +
                            }
459 484
                        ),
460 485
                    ),
461 486
                });
…
236 unchanged lines
modified crates/hub/tests/flow.rs +79 −0
…
4873 unchanged lines
4874 4874
    assert!(t_job.receipt_note.contains("not issued by this hub"), "{}", t_job.receipt_note);
4875 4875
}
4876 4876
4877 +
/// oxygenhub#241: a release asset whose receipt a short-lived key signed,
4878 +
/// certified by this hub for the job, is an attested build of a known
4879 +
/// signer on the release's trust report (it was "not one this hub's
4880 +
/// runners use" with 0.4.5).
4881 +
#[tokio::test(flavor = "multi_thread")]
4882 +
async fn a_release_built_by_a_certified_key_is_attested() {
4883 +
    use oxy_core::cert::{certify_challenge, SignedCertificate};
4884 +
    use oxy_core::hash::{hash_object, ChangeHash, ObjectType};
4885 +
    use oxy_core::identity::SecretKey;
4886 +
    use oxy_core::objects::StateObject;
4887 +
    use oxy_core::receipt::SignedReceipt;
4888 +
    let h = hub().await;
4889 +
    create(&h.app, "andy", "demo").await;
4890 +
    let url = format!("{}/andy/demo", h.base);
4891 +
    let work = tempfile::tempdir().unwrap();
4892 +
    let mut c = client(&url, work.path());
4893 +
    c.create_identity("Andy", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap();
4894 +
    write(
4895 +
        work.path(),
4896 +
        ".flow/pipeline.kdl",
4897 +
        &format!(
4898 +
            "schema-version 1\nsandbox \"alpine\" {{\n    rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"build\" {{\n    sandbox \"alpine\"\n    exec \"make\"\n    output {{\n        artifact \"tool\" {{\n            path \"/src/out/tool\"\n        }}\n    }}\n}}\n"
4899 +
        ),
4900 +
    );
4901 +
    c.snapshot("test").unwrap();
4902 +
    c.commit("Build a tool", &[]).unwrap();
4903 +
    let heads = c.bookmark_heads().unwrap();
4904 +
    c.tag_create("v1", &heads, "first").unwrap();
4905 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
4906 +
4907 +
    let run = runs_of(&h.app, "andy", "demo").await.remove(0);
4908 +
    let a = claim(&h.app).await.unwrap();
4909 +
    let id = a["job_id"].as_i64().unwrap();
4910 +
    let token = a["token"].as_str().unwrap().to_string();
4911 +
    let body: Vec<u8> = b"#!/bin/sh\necho tool\n".to_vec();
4912 +
    let (_, text) = upload(&h.app, id, "tool", &body).await;
4913 +
    let blob = serde_json::from_str::<serde_json::Value>(&text).unwrap()["blob"].as_str().unwrap().to_string();
4914 +
    let mut r = receipt("build", a["pipeline_hash"].as_str().unwrap(), 0);
4915 +
    r["outputs"] = serde_json::json!([blob]);
4916 +
    let heads_run: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
4917 +
    let state = hash_object(ObjectType::State, &StateObject::new(heads_run).encode());
4918 +
    let repo_id = h.app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap().id().unwrap();
4919 +
    // The job's fresh key, certified with its token.
4920 +
    let sk = SecretKey::generate();
4921 +
    let key = sk.identity("test-1 job", "", 1);
4922 +
    let proof = sk.sign_challenge(&key.hash(), &certify_challenge(&token)).unwrap();
4923 +
    let ask = serde_json::json!({
4924 +
        "identity": oxy_core::dsse::base64(&key.encode()),
4925 +
        "proof": oxy_core::dsse::base64(&oxy_core::cbor::encode(&proof.to_value())),
4926 +
        "scope": "seal",
4927 +
    });
4928 +
    let (st, answer) = call(&h.app, "POST", "/api/v1/keyless/certify", Some(&token), Some(ask.to_string())).await;
4929 +
    assert_eq!(st, StatusCode::OK, "{answer}");
4930 +
    let v: serde_json::Value = serde_json::from_str(&answer).unwrap();
4931 +
    let cert = SignedCertificate::decode(&oxy_core::dsse::base64_decode(v["certificate"].as_str().unwrap()).unwrap()).unwrap();
4932 +
    let fr: flow_core::Receipt = serde_json::from_value(r.clone()).unwrap();
4933 +
    let signed = hex::encode(
4934 +
        SignedReceipt::sign(fr.to_oxy(), repo_id, state, &format!("{url}/flow/{}", run.number), &key, &sk)
4935 +
            .with_certificate(cert, h.app.hub_key.identity.clone())
4936 +
            .encode(),
4937 +
    );
4938 +
    let done = serde_json::json!({ "exit_code": 0, "duration_ms": 900, "receipt": r, "signed_receipt": signed });
4939 +
    assert_eq!(finish(&h.app, id, done).await, StatusCode::NO_CONTENT);
4940 +
4941 +
    let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap();
4942 +
    let rel = db::releases::create(&h.app.db, row.id, "v1", "One", "", false, None).await.unwrap();
4943 +
    oxygenhub::web::releases::store_asset(&h.app, rel.id, "tool", "", &body).await.unwrap();
4944 +
    let (st, page) = call(&h.app, "GET", "/andy/demo/releases/trust/v1", None, None).await;
4945 +
    assert_eq!(st, StatusCode::OK);
4946 +
    for want in [
4947 +
        "tool: signed receipt from run #1 build",
4948 +
        "a short-lived key this hub certified for repo:andy/demo:bookmark:main",
4949 +
        "1 of 1 assets built and attested by OxygenFlow",
4950 +
    ] {
4951 +
        assert!(page.contains(want), "missing {want:?}");
4952 +
    }
4953 +
    assert!(!page.contains("not one this hub"), "not an unknown key");
4954 +
}
4955 +
4877 4956
/// Keyless step 4c (oxygenhub#241): when a key the hub certified moves a
4878 4957
/// bookmark, the hub signs when it accepted the update and serves that
4879 4958
/// with the bookmark, so a fetch checks the certificate at the hub's time
…
63 unchanged lines