The hub countersigns updates by keys it certified (keyless step 4c) 3eb491e9489b
Repositories the hub serves over the protocol now carry its key as their acceptor (Oxygen spec §14.7): when a push moves a bookmark with an attestation signed by a short-lived key, the hub signs an `accepted` attestation with its own clock, keeps it beside the attestation and serves it with the bookmark. A fetch then checks the certificate at the hub's time instead of the time the key claimed. Updates signed by long-lived keys get none. Pins oxygen with step 4c; docs/keyless.md says what is built. Refs #241
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-17 18:06 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
3eb491e9489b1e0c652afca88a01ac6c9c959e478d663a28c69c447d8cacd1f7- Depends on
- 18e381ea88c2 2c31915f5c89 380996db84e5 42fa95540468 52ae6422e5a1 66fd9d6b1452 6ba07678ae86 a74ba3cb4511 d23464c0ac60
- Depended on by
- 0ee5c3fb32eb a10446c68043 8a48dd830178 8dcaeeb848c6 4f40eed0588f e5b97ac399e7 965b950f9b96 244724133fed 8aa5ef384ef1
- 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
- 13 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/hub/src/lib.rs +2 −1
| … | 111 unchanged lines |
||
| 112 | 112 | db.clone(), |
|
| 113 | 113 | config.auth.push_tokens.clone(), |
|
| 114 | 114 | tokio::runtime::Handle::current(), |
|
| 115 | - | ); |
|
| 115 | + | ) |
|
| 116 | + | .with_acceptor(hub_key.identity.clone(), keys::load_secret(&config.keys_dir())?.1); |
|
| 116 | 117 | let slot = backend.app_slot(); |
|
| 117 | 118 | let server = oxy_server::Server::new(backend); |
|
| 118 | 119 | let mail = mail::load(&db, &config.email).await; |
|
| … | 225 unchanged lines |
modified crates/hub/src/protocol.rs +15 −2
| … | 23 unchanged lines |
||
| 24 | 24 | runtime: tokio::runtime::Handle, |
|
| 25 | 25 | /// The application, set once it exists (the backend is built first). |
|
| 26 | 26 | app_hint: Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>>, |
|
| 27 | + | /// The hub's key, which countersigns when it accepted an update a key |
|
| 28 | + | /// it certified signed (oxygenhub#241 step 4c, Oxygen spec §14.7). |
|
| 29 | + | acceptor: Option<Arc<(oxy_core::identity::Identity, oxy_core::identity::SecretKey)>>, |
|
| 27 | 30 | } |
|
| 28 | 31 | ||
| 29 | 32 | /// Who a request's credential resolved to. |
|
| … | 18 unchanged lines |
||
| 48 | 51 | site_tokens: Vec<String>, |
|
| 49 | 52 | runtime: tokio::runtime::Handle, |
|
| 50 | 53 | ) -> HubBackend { |
|
| 51 | - | HubBackend { repos, wikis, pool, site_tokens, runtime, app_hint: Arc::new(std::sync::Mutex::new(None)) } |
|
| 54 | + | HubBackend { repos, wikis, pool, site_tokens, runtime, app_hint: Arc::new(std::sync::Mutex::new(None)), acceptor: None } |
|
| 52 | 55 | } |
|
| 53 | 56 | ||
| 57 | + | /// Countersign accepted updates by keys this hub certified with `identity`. |
|
| 58 | + | pub fn with_acceptor(mut self, identity: oxy_core::identity::Identity, secret: oxy_core::identity::SecretKey) -> Self { |
|
| 59 | + | self.acceptor = Some(Arc::new((identity, secret))); |
|
| 60 | + | self |
|
| 61 | + | } |
|
| 62 | + | ||
| 54 | 63 | /// The slot the application registers itself in after construction. |
|
| 55 | 64 | pub fn app_slot(&self) -> Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>> { |
|
| 56 | 65 | self.app_hint.clone() |
|
| … | 67 unchanged lines |
||
| 124 | 133 | let row = self.row(&parent)?; |
|
| 125 | 134 | let name = RepoName::new(&row.owner, &row.name).ok_or_else(not_found)?; |
|
| 126 | 135 | let store = if is_wiki { &self.wikis } else { &self.repos }; |
|
| 127 | - | store.open(&name).map_err(|e| match e { |
|
| 136 | + | let repo = store.open(&name).map_err(|e| match e { |
|
| 128 | 137 | crate::repos::RepoError::NotFound => not_found(), |
|
| 129 | 138 | other => Failure(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), |
|
| 139 | + | })?; |
|
| 140 | + | Ok(match &self.acceptor { |
|
| 141 | + | Some(a) => repo.with_acceptor(a.0.clone(), a.1.clone()), |
|
| 142 | + | None => repo, |
|
| 130 | 143 | }) |
|
| 131 | 144 | } |
|
| 132 | 145 | ||
| … | 277 unchanged lines |
modified crates/hub/tests/flow.rs +67 −0
| … | 4872 unchanged lines |
||
| 4873 | 4873 | let t_job = jobs.iter().find(|j| j.name == t_name).unwrap(); |
|
| 4874 | 4874 | assert!(t_job.receipt_note.contains("not issued by this hub"), "{}", t_job.receipt_note); |
|
| 4875 | 4875 | } |
|
| 4876 | + | ||
| 4877 | + | /// Keyless step 4c (oxygenhub#241): when a key the hub certified moves a |
|
| 4878 | + | /// bookmark, the hub signs when it accepted the update and serves that |
|
| 4879 | + | /// with the bookmark, so a fetch checks the certificate at the hub's time |
|
| 4880 | + | /// rather than the time the key claimed. |
|
| 4881 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 4882 | + | async fn the_hub_countersigns_an_update_by_a_key_it_certified() { |
|
| 4883 | + | use oxy_core::attest::AttestBody; |
|
| 4884 | + | use oxy_core::cert::{Certificate, SignedCertificate}; |
|
| 4885 | + | use oxy_core::identity::Scope; |
|
| 4886 | + | use oxy_core::repo::IdentityScope; |
|
| 4887 | + | let h = hub().await; |
|
| 4888 | + | create(&h.app, "andy", "demo").await; |
|
| 4889 | + | let url = format!("{}/andy/demo", h.base); |
|
| 4890 | + | let work = tempfile::tempdir().unwrap(); |
|
| 4891 | + | let mut a = client(&url, &work.path().join("a")).with_home(work.path().join("home-a")); |
|
| 4892 | + | a.create_identity("Andy", "[email protected]", IdentityScope::User).unwrap(); |
|
| 4893 | + | write(&work.path().join("a"), "a.txt", "a\n"); |
|
| 4894 | + | a.commit("a", &[]).unwrap(); |
|
| 4895 | + | a.policy_init().unwrap(); |
|
| 4896 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4897 | + | let hub_id = h.app.hub_key.identity.clone(); |
|
| 4898 | + | a.policy_grant_subject("agent:claude@andy", hub_id.clone(), Scope::CHANGES | Scope::REFS, None).unwrap(); |
|
| 4899 | + | assert!(a.push("origin", false).unwrap().iter().any(|r| r.name == "policy" && r.ok)); |
|
| 4900 | + | ||
| 4901 | + | // An agent whose fresh key the hub certified pushes. |
|
| 4902 | + | let mut b = Repo::clone(&url, &work.path().join("b")).unwrap().with_home(work.path().join("home-b")); |
|
| 4903 | + | b.config.set("remote.origin.token", "site").unwrap(); |
|
| 4904 | + | b.save_config().unwrap(); |
|
| 4905 | + | let (_, key) = b.create_identity("claude", "[email protected]", IdentityScope::User).unwrap(); |
|
| 4906 | + | let (_, secret) = oxygenhub::keys::load_secret(&h.app.config.keys_dir()).unwrap(); |
|
| 4907 | + | let now = db::now_ms() as u64; |
|
| 4908 | + | let cert = SignedCertificate::sign( |
|
| 4909 | + | Certificate { |
|
| 4910 | + | key, |
|
| 4911 | + | issuer: hub_id.hash(), |
|
| 4912 | + | subject: "agent:claude@andy".into(), |
|
| 4913 | + | claims: vec![], |
|
| 4914 | + | scope: Scope::CHANGES | Scope::REFS, |
|
| 4915 | + | not_before: now - 60_000, |
|
| 4916 | + | expires: now + 600_000, |
|
| 4917 | + | }, |
|
| 4918 | + | &hub_id, |
|
| 4919 | + | &secret, |
|
| 4920 | + | ); |
|
| 4921 | + | b.store().put_identity(&hub_id).unwrap(); |
|
| 4922 | + | b.store().put_certificate(&cert).unwrap(); |
|
| 4923 | + | write(&work.path().join("b"), "b.txt", "agent work\n"); |
|
| 4924 | + | b.commit("work", &[]).unwrap(); |
|
| 4925 | + | let r = b.push("origin", false).unwrap(); |
|
| 4926 | + | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 4927 | + | ||
| 4928 | + | // The hub serves its acceptance of that update, signed by its key. |
|
| 4929 | + | let refs = open_remote(&url, &RemoteOptions::default()).unwrap().refs().unwrap(); |
|
| 4930 | + | let main = refs.refs.iter().find(|r| r.name == "main").unwrap(); |
|
| 4931 | + | let (attest, acc) = (main.attestation.clone().unwrap(), main.accepted.clone().expect("an acceptance")); |
|
| 4932 | + | assert!(matches!(acc.attest.body, AttestBody::Accepted { attest: r } if r == attest.attest.hash())); |
|
| 4933 | + | assert_eq!(acc.sigs[0].key, hub_id.hash()); |
|
| 4934 | + | assert!(acc.attest.hlc.wall_ms >= now); |
|
| 4935 | + | // An update signed by a long-lived key gets none. |
|
| 4936 | + | a.pull("origin").unwrap(); |
|
| 4937 | + | write(&work.path().join("a"), "a.txt", "again\n"); |
|
| 4938 | + | a.commit("again", &[]).unwrap(); |
|
| 4939 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4940 | + | let refs = open_remote(&url, &RemoteOptions::default()).unwrap().refs().unwrap(); |
|
| 4941 | + | assert!(refs.refs.iter().find(|r| r.name == "main").unwrap().accepted.is_none()); |
|
| 4942 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/keyless.md +9 −3
| 1 | 1 | # Keyless signing: short-lived keys from an OIDC identity |
|
| 2 | 2 | ||
| 3 | 3 | Design for oxygenhub#241, written 2026-10-17. **Status: accepted** (Andy, |
|
| 4 | - | 2026-10-17, the four decisions below as recommended). Nothing here is built |
|
| 5 | - | yet. It builds on the hub's |
|
| 4 | + | 2026-10-17, the four decisions below as recommended). Built on 2026-10-17: |
|
| 5 | + | steps 1 to 4 of the order of work and the hub's acceptance attestation |
|
| 6 | + | (step 6's first half, Oxygen spec §14.7); agent grants (step 5) and the |
|
| 7 | + | log's time are next. It builds on the hub's |
|
| 6 | 8 | OIDC issuer (hub spec §13.5) and on the transparency log |
|
| 7 | 9 | (docs/transparency.md, #238/#240). |
|
| 8 | 10 | ||
| … | 116 unchanged lines |
||
| 125 | 127 | 2. **The hub's acceptance**, until the log exists: when a push carries a |
|
| 126 | 128 | certified signature, the hub countersigns its acceptance time on the |
|
| 127 | 129 | object (a small attestation, kind `accepted`). That trusts the hub's |
|
| 128 | - | clock alone, which the log later removes. |
|
| 130 | + | clock alone, which the log later removes. Built: the hub signs one with |
|
| 131 | + | its key for each reference attestation by a key it certified, keeps |
|
| 132 | + | it beside the attestation and serves it with the bookmark; a fetch |
|
| 133 | + | uses its time only when the certificate's issuer signed it for exactly |
|
| 134 | + | that attestation. |
|
| 129 | 135 | ||
| 130 | 136 | A signature whose certificate has expired, with neither proof, is |
|
| 131 | 137 | invalid. |
|
| … | 54 unchanged lines |