OXYGENHUB

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