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 |