OXYGENHUB

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