OXYGENHUB

A keyless agent's changes verify where its owner's identity is new (closes #134) f0ef9778e587

The hub's certificate answer may carry the grant signer's identity; the
agent keeps it (or the home's keys/ copy), and a push carries it beside
the certificate, so the receiver can check the grant.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-24 21:45 UTC
Signature
◈ sealed by Andy McCall
Hash
f0ef9778e587df647ae83232422c5fbef4ad596fc475cdd35fcf266569bb34a9
Depends on
82202a4d0025 8fd78eb52ad2 982864068a59 c895bc52e4e0 e4595038502a f0a517d28237
Depended on by
2452b4407ab7 45a6f5ec85cf
In bookmarks
identical-sides main push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
6 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A keyless agent's changes verify in a repository that has never seen its owner (#134).** An agent's ten-minute key is certified under its owner's signed grant, and a change it signed only verified where the owner's identity was already stored: in a new repository, or one the owner had not pushed to since a key rotation, `oxy log --verify` and the hub said INVALID ("grant is by an unknown author"). OxygenHub now sends the grant's signer with the certificate (`grantor_identity`, additive), `oxy` keeps it (else uses `<oxy home>/keys/<key>.identity`, or refuses with how to put it there), and a push brings it beside the certificate, so the receiver can check the grant.
6 +
5 7
## 0.5.1 (2026-10-20)
6 8
7 9
- **oxy 0.5.0 and older can clone a repository again after a keyless agent tagged it (#131).** Since #129 a release tag may be signed by a ten-minute certified key, which released clients do not know how to authorize: they refused the tag (`key … may not create tags/v0.5.0 under this repository's policy`), and with it the whole clone, fetch or `oxy submodule update`. Clients now send `User-Agent: oxy/<version>` and declare what they understand in `Oxy-Features` (`certified-tags` so far; docs/protocol.md, *Client capabilities*). Under a policy, a server leaves out of its refs answer to a client that does not declare `certified-tags` every tag that only a certificate authorizes, so that client clones the rest as if the tag did not exist. The rule is `Repo::refs_for_client` in oxy-core; `oxy-server` gains a `Backend::adjust_refs` hook, given the request headers, whose default applies it, so `oxy serve` and every embedding host do.
…
253 unchanged lines
modified crates/oxy-core/src/repo/keyless.rs +33 −1
…
50 unchanged lines
51 51
            .parent()
52 52
            .map(Path::to_path_buf)
53 53
            .unwrap_or_else(default_home);
54 -
        let creds = Credentials::at(home);
54 +
        let creds = Credentials::at(home.clone());
55 55
        let token = creds.grant(&hub).map_err(RepoError::Msg)?.ok_or_else(|| {
56 56
            RepoError::Msg(format!(
57 57
                "author.signer is keyless, but there is no agent grant for {hub}: its owner makes one with `oh agent grant NAME`, and `oxy auth grant TOKEN` keeps it"
…
62 unchanged lines
120 120
        };
121 121
        cert.verify(&issuer)
122 122
            .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?;
123 +
        // The grant's author, so the grant can be checked here and wherever
124 +
        // this pushes (oxygen#134): from the hub's answer, else this home's
125 +
        // copy of it. Without it, a repository that never saw the owner's
126 +
        // current identity reads the change as signed by an unknown author.
127 +
        if let Some(g) = &cert.cert.grant {
128 +
            let grantor = g.grant.grantor;
129 +
            if self.store.get_identity(&grantor).is_err() {
130 +
                let sent = reply["grantor_identity"]
131 +
                    .as_str()
132 +
                    .and_then(crate::dsse::base64_decode)
133 +
                    .and_then(|b| Identity::decode(&b).ok());
134 +
                let kept = || {
135 +
                    std::fs::read(
136 +
                        home.join("keys")
137 +
                            .join(format!("{}.identity", grantor.to_hex())),
138 +
                    )
139 +
                    .ok()
140 +
                    .and_then(|b| Identity::decode(&b).ok())
141 +
                };
142 +
                match sent.filter(|i| i.hash() == grantor).or_else(|| kept().filter(|i| i.hash() == grantor)) {
143 +
                    Some(i) => {
144 +
                        self.store.put_identity(&i)?;
145 +
                    }
146 +
                    None => {
147 +
                        return fail(format!(
148 +
                            "the grant is signed by {}, whose identity neither {hub} nor this home has: export it there (oxy identity export) into this home's keys/",
149 +
                            grantor.short(12)
150 +
                        ))
151 +
                    }
152 +
                }
153 +
            }
154 +
        }
123 155
        self.store.put_identity(&issuer)?;
124 156
        self.store.put_identity(&id)?;
125 157
        self.store.put_certificate(&cert)?;
…
66 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +4 −1
…
474 unchanged lines
475 475
            {
476 476
                continue;
477 477
            }
478 -
            for id in [&k, &cert.cert.issuer] {
478 +
            // An agent's certificate brings its grant's signer too, so the
479 +
            // receiver can check the grant (oxygen#134).
480 +
            let grantor = cert.cert.grant.as_ref().map(|g| g.grant.grantor);
481 +
            for id in [Some(k), Some(cert.cert.issuer), grantor].iter().flatten() {
479 482
                if let Ok(body) = self.store.get_typed(id, ObjectType::Identity) {
480 483
                    if !bundle
481 484
                        .objects
…
2165 unchanged lines