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 |