Keyless step 3, hub: receipts signed by a key the hub certified are kept 2c31915f5c89
A signed receipt that carries a certificate is kept when the certificate is this hub's, names the run's repository and bookmark, its run and job claims are the run's and the job's, and it covers the moment the hub accepts it (the hub's clock, not the runner's). No runner key is bound for it; the run names the signer with "(short-lived key)". Spec 13.5.2. Test: a job's own certified key is kept and the receipt verifies on its own; another job's certificate and another issuer's are refused. 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 15:08 UTC
- Signature
- ✔ signed by Claude Code
- Hash
2c31915f5c895708d14d724527c202339c80b97ac4a68f02efc68e40c5fb4903- Depends on
- 026a76d251ce 380996db84e5 42fa95540468 93be9ce4653e b6b7e4cf6b24 cc19697f8871
- Depended on by
- 8aa5ef384ef1 3eb491e9489b a74ba3cb4511
- 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
- 6 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/hub/src/flow.rs +30 −6
| … | 2283 unchanged lines |
||
| 2284 | 2284 | if sr.state() != state { |
|
| 2285 | 2285 | return Err("it is for another state than the run's".into()); |
|
| 2286 | 2286 | } |
|
| 2287 | - | let key = sr.runner.hash().to_hex(); |
|
| 2288 | - | let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?; |
|
| 2289 | - | if bound != key { |
|
| 2290 | - | return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())])); |
|
| 2291 | - | } |
|
| 2287 | + | let signer = match &sr.certificate { |
|
| 2288 | + | // Signed by a short-lived key this hub certified for this job |
|
| 2289 | + | // (keyless, #241): the certificate stands for the runner's key. |
|
| 2290 | + | Some(c) => { |
|
| 2291 | + | let cert = &c.cert.cert; |
|
| 2292 | + | if cert.issuer != app.hub_key.key || c.issuer.hash() != app.hub_key.key { |
|
| 2293 | + | return Err("its certificate was not issued by this hub".into()); |
|
| 2294 | + | } |
|
| 2295 | + | let subject = format!("repo:{}/{}:bookmark:{}", row.owner, row.name, run.bookmark); |
|
| 2296 | + | if cert.subject != subject { |
|
| 2297 | + | return Err(format!("its certificate is for {}, not {subject}", cert.subject)); |
|
| 2298 | + | } |
|
| 2299 | + | if cert.claim("run") != Some(run.number.to_string().as_str()) || cert.claim("job") != Some(sr.receipt.job_name.as_str()) { |
|
| 2300 | + | return Err("its certificate is for another run or job".into()); |
|
| 2301 | + | } |
|
| 2302 | + | // Now is when this hub accepts it: a time the runner did not choose. |
|
| 2303 | + | if !cert.covers(oxy_core::identity::Scope::SEAL, db::now_ms() as u64) { |
|
| 2304 | + | return Err("its certificate does not cover now: certify the key just before signing the receipt".into()); |
|
| 2305 | + | } |
|
| 2306 | + | format!("{} (short-lived key)", runner.name) |
|
| 2307 | + | } |
|
| 2308 | + | None => { |
|
| 2309 | + | let key = sr.runner.hash().to_hex(); |
|
| 2310 | + | let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?; |
|
| 2311 | + | if bound != key { |
|
| 2312 | + | return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())])); |
|
| 2313 | + | } |
|
| 2314 | + | sr.runner.name.clone() |
|
| 2315 | + | } |
|
| 2316 | + | }; |
|
| 2292 | 2317 | let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
|
| 2293 | - | let signer = sr.runner.name.clone(); |
|
| 2294 | 2318 | let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?; |
|
| 2295 | 2319 | Ok((attest.to_hex(), signer)) |
|
| 2296 | 2320 | } |
|
| … | 203 unchanged lines |
modified crates/hub/tests/flow.rs +100 −0
| … | 4772 unchanged lines |
||
| 4773 | 4773 | let (st, body) = ask(&token, &token).await; |
|
| 4774 | 4774 | assert_eq!(st, StatusCode::UNAUTHORIZED, "{body}"); |
|
| 4775 | 4775 | } |
|
| 4776 | + | ||
| 4777 | + | /// Keyless step 3 (oxygenhub#241): a job signs its receipt with a fresh |
|
| 4778 | + | /// key the hub certified for it, and the hub keeps the receipt without |
|
| 4779 | + | /// binding any runner key; a certificate for another job, or from another |
|
| 4780 | + | /// issuer, is not this job's. |
|
| 4781 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 4782 | + | async fn a_receipt_signed_by_a_certified_key_is_kept() { |
|
| 4783 | + | use oxy_core::cert::{certify_challenge, SignedCertificate}; |
|
| 4784 | + | use oxy_core::hash::{hash_object, ChangeHash, ObjectType}; |
|
| 4785 | + | use oxy_core::identity::SecretKey; |
|
| 4786 | + | use oxy_core::objects::StateObject; |
|
| 4787 | + | use oxy_core::receipt::SignedReceipt; |
|
| 4788 | + | let h = hub().await; |
|
| 4789 | + | create(&h.app, "andy", "demo").await; |
|
| 4790 | + | let url = format!("{}/andy/demo", h.base); |
|
| 4791 | + | let work = tempfile::tempdir().unwrap(); |
|
| 4792 | + | let c = client(&url, work.path()); |
|
| 4793 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 4794 | + | c.snapshot("test").unwrap(); |
|
| 4795 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 4796 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4797 | + | let run = runs_of(&h.app, "andy", "demo").await.remove(0); |
|
| 4798 | + | let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect(); |
|
| 4799 | + | let state = hash_object(ObjectType::State, &StateObject::new(heads.clone()).encode()); |
|
| 4800 | + | let repo_id = h.app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap().id().unwrap(); |
|
| 4801 | + | ||
| 4802 | + | // Certify a fresh key with a job's token, as a runner does just before |
|
| 4803 | + | // it signs the receipt. |
|
| 4804 | + | let certify = |token: String| { |
|
| 4805 | + | let app = h.app.clone(); |
|
| 4806 | + | async move { |
|
| 4807 | + | let sk = SecretKey::generate(); |
|
| 4808 | + | let id = sk.identity("test-1 job", "", 1); |
|
| 4809 | + | let proof = sk.sign_challenge(&id.hash(), &certify_challenge(&token)).unwrap(); |
|
| 4810 | + | let body = serde_json::json!({ |
|
| 4811 | + | "identity": oxy_core::dsse::base64(&id.encode()), |
|
| 4812 | + | "proof": oxy_core::dsse::base64(&oxy_core::cbor::encode(&proof.to_value())), |
|
| 4813 | + | "scope": "seal", |
|
| 4814 | + | }); |
|
| 4815 | + | let (st, body) = call(&app, "POST", "/api/v1/keyless/certify", Some(&token), Some(body.to_string())).await; |
|
| 4816 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 4817 | + | let v: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 4818 | + | let cert = SignedCertificate::decode(&oxy_core::dsse::base64_decode(v["certificate"].as_str().unwrap()).unwrap()).unwrap(); |
|
| 4819 | + | (sk, id, cert) |
|
| 4820 | + | } |
|
| 4821 | + | }; |
|
| 4822 | + | let sign = |r: &serde_json::Value, |
|
| 4823 | + | sk: &SecretKey, |
|
| 4824 | + | id: &oxy_core::identity::Identity, |
|
| 4825 | + | cert: SignedCertificate, |
|
| 4826 | + | issuer: oxy_core::identity::Identity| { |
|
| 4827 | + | let r: flow_core::Receipt = serde_json::from_value(r.clone()).unwrap(); |
|
| 4828 | + | let run_url = format!("{url}/flow/{}", run.number); |
|
| 4829 | + | hex::encode(SignedReceipt::sign(r.to_oxy(), repo_id, state, &run_url, id, sk).with_certificate(cert, issuer).encode()) |
|
| 4830 | + | }; |
|
| 4831 | + | let hub_id = h.app.hub_key.identity.clone(); |
|
| 4832 | + | ||
| 4833 | + | let a = claim(&h.app).await.unwrap(); |
|
| 4834 | + | let b = claim(&h.app).await.unwrap(); |
|
| 4835 | + | let ph = a["pipeline_hash"].as_str().unwrap().to_string(); |
|
| 4836 | + | let (a_name, b_name) = (a["job"]["name"].as_str().unwrap().to_string(), b["job"]["name"].as_str().unwrap().to_string()); |
|
| 4837 | + | let (a_id, b_id) = (a["job_id"].as_i64().unwrap(), b["job_id"].as_i64().unwrap()); |
|
| 4838 | + | let (sk_a, id_a, cert_a) = certify(a["token"].as_str().unwrap().to_string()).await; |
|
| 4839 | + | ||
| 4840 | + | // Job b's receipt signed with job a's certified key: another job's. |
|
| 4841 | + | let rb = receipt(&b_name, &ph, 0); |
|
| 4842 | + | let body = serde_json::json!({ "exit_code": 0, "duration_ms": 300, "receipt": rb, "signed_receipt": sign(&rb, &sk_a, &id_a, cert_a.clone(), hub_id.clone()) }); |
|
| 4843 | + | assert_eq!(finish(&h.app, b_id, body).await, StatusCode::NO_CONTENT); |
|
| 4844 | + | // Job a's, with its own certificate: kept. |
|
| 4845 | + | let ra = receipt(&a_name, &ph, 0); |
|
| 4846 | + | let body = serde_json::json!({ "exit_code": 0, "duration_ms": 700, "receipt": ra, "signed_receipt": sign(&ra, &sk_a, &id_a, cert_a, hub_id.clone()) }); |
|
| 4847 | + | assert_eq!(finish(&h.app, a_id, body).await, StatusCode::NO_CONTENT); |
|
| 4848 | + | ||
| 4849 | + | let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap(); |
|
| 4850 | + | let job = |n: &str| jobs.iter().find(|j| j.name == n).unwrap().clone(); |
|
| 4851 | + | assert_eq!(job(&a_name).receipt_attest.len(), 64, "{}", job(&a_name).receipt_note); |
|
| 4852 | + | assert!(job(&a_name).receipt_signer.ends_with("(short-lived key)"), "{}", job(&a_name).receipt_signer); |
|
| 4853 | + | assert!(job(&b_name).receipt_attest.is_empty()); |
|
| 4854 | + | assert!(job(&b_name).receipt_note.contains("another run or job"), "{}", job(&b_name).receipt_note); |
|
| 4855 | + | // The kept receipt carries its certificate, and verifies on its own. |
|
| 4856 | + | let kept = h.app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap().receipts(&heads).unwrap(); |
|
| 4857 | + | assert_eq!(kept.len(), 1); |
|
| 4858 | + | assert!(kept[0].problem.is_none() && kept[0].signed.certificate.is_some()); |
|
| 4859 | + | ||
| 4860 | + | // A certificate from another issuer, for the third job: not this hub's. |
|
| 4861 | + | let t = claim(&h.app).await.unwrap(); |
|
| 4862 | + | let t_name = t["job"]["name"].as_str().unwrap().to_string(); |
|
| 4863 | + | let fake_sk = SecretKey::generate(); |
|
| 4864 | + | let fake = fake_sk.identity("not the hub", "", 1); |
|
| 4865 | + | let (sk_t, id_t, mut cert_t) = certify(t["token"].as_str().unwrap().to_string()).await; |
|
| 4866 | + | cert_t.cert.issuer = fake.hash(); |
|
| 4867 | + | let cert_t = SignedCertificate::sign(cert_t.cert, &fake, &fake_sk); |
|
| 4868 | + | let rt = receipt(&t_name, &ph, 0); |
|
| 4869 | + | let body = |
|
| 4870 | + | serde_json::json!({ "exit_code": 0, "duration_ms": 500, "receipt": rt, "signed_receipt": sign(&rt, &sk_t, &id_t, cert_t, fake) }); |
|
| 4871 | + | assert_eq!(finish(&h.app, t["job_id"].as_i64().unwrap(), body).await, StatusCode::NO_CONTENT); |
|
| 4872 | + | let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap(); |
|
| 4873 | + | let t_job = jobs.iter().find(|j| j.name == t_name).unwrap(); |
|
| 4874 | + | assert!(t_job.receipt_note.contains("not issued by this hub"), "{}", t_job.receipt_note); |
|
| 4875 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +1 −1
| … | 589 unchanged lines |
||
| 590 | 590 | ||
| 591 | 591 | ### 13.5.2 Certificates for short-lived keys (oxygenhub#241, docs/keyless.md, step 2 built 2026-10-17) |
|
| 592 | 592 | ||
| 593 | - | `POST /api/v1/keyless/certify` gives a fresh key a certificate (Oxygen spec §14.7) for ten minutes, signed with the hub's key. The bearer is the credential: a running job's token (subject `repo:OWNER/NAME:bookmark:B`, claims `repository`, `bookmark`, `run`, `job`, `runner`, and `environment` with `approved_by` once an approved job may deploy), or one of this hub's OIDC tokens for the audience `{base_url}/keyless` (its `sub` the subject, and the same claims it carries). The body is `{"identity": base64 identity object, "proof": base64 CBOR Signature, "scope": "changes,refs"}`, the proof the fresh key's challenge signature (§14.3) over `certify_challenge(credential)` (`"oxy1 certify "` and the BLAKE3 of the credential), so it cannot be replayed with another credential. The answer is `{"certificate": base64 [certificate, signature], "subject", "expires_ms"}`; a certificate never outlives its credential, and it starts 30 s back for clocks a little behind. Each one is audited (`keyless.certified`). Which issuers and subjects a repository trusts is its policy's, and signing with these keys is steps 3 to 5 of docs/keyless.md. |
|
| 593 | + | `POST /api/v1/keyless/certify` gives a fresh key a certificate (Oxygen spec §14.7) for ten minutes, signed with the hub's key. The bearer is the credential: a running job's token (subject `repo:OWNER/NAME:bookmark:B`, claims `repository`, `bookmark`, `run`, `job`, `runner`, and `environment` with `approved_by` once an approved job may deploy), or one of this hub's OIDC tokens for the audience `{base_url}/keyless` (its `sub` the subject, and the same claims it carries). The body is `{"identity": base64 identity object, "proof": base64 CBOR Signature, "scope": "changes,refs"}`, the proof the fresh key's challenge signature (§14.3) over `certify_challenge(credential)` (`"oxy1 certify "` and the BLAKE3 of the credential), so it cannot be replayed with another credential. The answer is `{"certificate": base64 [certificate, signature], "subject", "expires_ms"}`; a certificate never outlives its credential, and it starts 30 s back for clocks a little behind. Each one is audited (`keyless.certified`). A runner may sign a job's receipt with such a key, certified just before it signs (scope `seal`): the receipt carries the certificate (Oxygen spec §14.6), and the hub keeps it when the certificate is this hub's, its subject is the run's repository and bookmark, its `run` and `job` claims are the run's and the job's, and it covers the moment the hub accepts it; no runner key is bound then, and the run names the signer as the runner's name with "(short-lived key)". Which issuers and subjects a repository trusts for changes and references is its policy's (docs/keyless.md, step 4). |
|
| 594 | 594 | ||
| 595 | 595 | ### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06) |
|
| 596 | 596 | ||
| … | 168 unchanged lines |