A keyless agent's grant survives its owner's key rotation (closes #135) 45a6f5ec85cf
A grant by a key the author's identity was rotated or renewed from counts when it was made before that rotation, as a policy keeps a predecessor's signatures; one an old key made after it does not. Changelog entries for #135 and oxygenhub#301.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 4727c2534971
- When
- · 2026-10-24 22:11 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
45a6f5ec85cf42c53680f77a63424167eaf5302644f89f304c2b10fb086d6b17- Depends on
- 8fd78eb52ad2 982864068a59 a2387d547373 e4595038502a f0a517d28237 f0ef9778e587 ff0af1ee299d
- Depended on by
- d3b270909c78
- 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
- 9 atoms, 0 tree operations, 4 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 grant survives its owner's key rotation (#135).** Every delegation an owner adds or removes renews their identity, and a repository that names the newest key as `author.key` read every change their agent sealed as INVALID ("key … is neither the author nor a delegate"), and its pushes were refused, because the agent's grant was signed by the owner's earlier key. A grant by a key the author's identity was rotated or renewed from now counts when it was made before that rotation; one an old key made after it does not. |
|
| 6 | + | - **A tag can be withdrawn, by a signed withdrawal (oxygenhub#301).** There was no way to remove a tag anywhere: a mistaken release tag stayed for good. `oxy tag delete NAME [-r REASON]` records an `untag` attestation naming the tag's state, signed like a tag (a ten-minute keyless key too), and the next `oxy push` sends it to a remote that still has the tag; `oxy push --delete tags/NAME` does both at once. A receiver under a policy accepts it only from a key that may create the tag, keeps it beside the tag's creation, and lists withdrawn tags in its refs answer (key 8), so a `fetch` withdraws the tag there too (`tag v1 was withdrawn on origin, and here`). A withdrawn name is never used again, so a tag name never names two states. `oxy-server`'s `Backend` gains `vet_tag_withdrawal` (OxygenHub refuses while a release uses the tag), and `Repo::host_tag_create` and `host_tag_withdrawal` let a host make or withdraw a tag for a signed-in user. The wire change is additive, but an older server refuses a withdrawal (wrong attestation kind), and an older client keeps a withdrawn tag. |
|
| 5 | 7 | - **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 | 8 | ||
| 7 | 9 | ## 0.5.1 (2026-10-20) |
|
| … | 255 unchanged lines |
modified crates/oxy-core/src/repo/identity.rs +25 −3
| … | 502 unchanged lines |
||
| 503 | 503 | .map_err(|_| format!("key {who}'s certificate names an unknown issuer"))?; |
|
| 504 | 504 | sc.verify(&issuer) |
|
| 505 | 505 | .map_err(|e| format!("key {who}'s certificate: {e}"))?; |
|
| 506 | - | if g.grant.grantor != *author { |
|
| 506 | + | // A grant by an earlier key of the author's identity counts while |
|
| 507 | + | // it was made before that key was rotated away (oxygen#135). |
|
| 508 | + | if g.grant.grantor != *author && !self.granted_before_rotation(author, &g.grant) { |
|
| 507 | 509 | return Ok(neither()); |
|
| 508 | 510 | } |
|
| 509 | 511 | let grantor = self |
|
| 510 | - | .store |
|
| 511 | - | .get_identity(author) |
|
| 512 | + | .identity(&g.grant.grantor) |
|
| 512 | 513 | .map_err(|_| format!("key {who}'s grant is by an unknown author"))?; |
|
| 513 | 514 | g.verify(&grantor) |
|
| 514 | 515 | .map_err(|e| format!("key {who}'s grant: {e}"))?; |
|
| … | 13 unchanged lines |
||
| 528 | 529 | )); |
|
| 529 | 530 | } |
|
| 530 | 531 | Ok(None) |
|
| 532 | + | } |
|
| 533 | + | ||
| 534 | + | /// Whether `grant` is by a key `author`'s identity was rotated from, |
|
| 535 | + | /// made before that rotation (oxygen#135). An owner rotates with every |
|
| 536 | + | /// delegation, and a repository may name the newest key as the author |
|
| 537 | + | /// while the agent's grant is by an older one; the grant stays good, as |
|
| 538 | + | /// a policy keeps a predecessor's signatures from before the rotation |
|
| 539 | + | /// (spec §14.5), but a key rotated away grants nothing new. |
|
| 540 | + | fn granted_before_rotation(&self, author: &KeyId, grant: &crate::cert::Grant) -> bool { |
|
| 541 | + | let mut cur = self.identity(author).ok(); |
|
| 542 | + | for _ in 0..32 { |
|
| 543 | + | let Some(id) = cur else { |
|
| 544 | + | return false; |
|
| 545 | + | }; |
|
| 546 | + | match id.previous { |
|
| 547 | + | Some(prev) if prev == grant.grantor => return grant.not_before < id.created, |
|
| 548 | + | Some(prev) => cur = self.identity(&prev).ok(), |
|
| 549 | + | None => return false, |
|
| 550 | + | } |
|
| 551 | + | } |
|
| 552 | + | false |
|
| 531 | 553 | } |
|
| 532 | 554 | ||
| 533 | 555 | /// The subject a short-lived key was certified for (spec §14.7), such |
|
| … | 224 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +89 −0
| … | 1752 unchanged lines |
||
| 1753 | 1753 | /// subject the author granted, seals changes in the author's name; with |
|
| 1754 | 1754 | /// no grant, a stranger's, or one that had ended, it is neither the |
|
| 1755 | 1755 | /// author nor a delegate. |
|
| 1756 | + | /// oxygen#135: an owner renews or rotates with every delegation, and a |
|
| 1757 | + | /// repository may name the newest key as the author; the agent's grant by |
|
| 1758 | + | /// the earlier key still counts, but not one that key made after. |
|
| 1756 | 1759 | #[test] |
|
| 1760 | + | fn a_grant_outlives_its_owner_s_rotation() { |
|
| 1761 | + | use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant}; |
|
| 1762 | + | use crate::identity::Scope; |
|
| 1763 | + | hermetic(); |
|
| 1764 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1765 | + | let root = dir.path(); |
|
| 1766 | + | let mut a = Repo::init(&root.join("a")) |
|
| 1767 | + | .unwrap() |
|
| 1768 | + | .with_home(root.join("home-a")); |
|
| 1769 | + | let (old, _) = a |
|
| 1770 | + | .create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 1771 | + | .unwrap(); |
|
| 1772 | + | let (_, old_sk) = a.secret_key().unwrap().unwrap(); |
|
| 1773 | + | let old_id = a.identity(&old).unwrap(); |
|
| 1774 | + | let hub_sk = SecretKey::generate(); |
|
| 1775 | + | let hub = hub_sk.identity("hub", "[email protected]", 1); |
|
| 1776 | + | let now = Hlc::now().wall_ms; |
|
| 1777 | + | let grant = |from: u64| { |
|
| 1778 | + | SignedGrant::sign( |
|
| 1779 | + | Grant { |
|
| 1780 | + | grantor: old, |
|
| 1781 | + | subject: "agent:claude@ada".into(), |
|
| 1782 | + | issuer: hub.hash(), |
|
| 1783 | + | scope: Scope::CHANGES | Scope::SEAL, |
|
| 1784 | + | not_before: from, |
|
| 1785 | + | expires: now + 86_400_000, |
|
| 1786 | + | }, |
|
| 1787 | + | &old_id, |
|
| 1788 | + | &old_sk, |
|
| 1789 | + | ) |
|
| 1790 | + | }; |
|
| 1791 | + | let before = grant(now - 60_000); |
|
| 1792 | + | let after = grant(now + 60_000); |
|
| 1793 | + | let rot = a.renew_identity(None).unwrap(); |
|
| 1794 | + | assert_ne!(rot.new, old); |
|
| 1795 | + | let agent = |name: &str, g: SignedGrant| { |
|
| 1796 | + | let mut r = Repo::init(&root.join(name)) |
|
| 1797 | + | .unwrap() |
|
| 1798 | + | .with_home(root.join(format!("home-{name}"))); |
|
| 1799 | + | let (key, id) = r |
|
| 1800 | + | .create_identity(name, "[email protected]", IdentityScope::User) |
|
| 1801 | + | .unwrap(); |
|
| 1802 | + | for i in [&old_id, &rot.identity, &hub] { |
|
| 1803 | + | r.store().put_identity(i).unwrap(); |
|
| 1804 | + | } |
|
| 1805 | + | r.set_user_config("author.key", &rot.new.to_hex()).unwrap(); |
|
| 1806 | + | r.set_user_config("author.signer", &key.to_hex()).unwrap(); |
|
| 1807 | + | let cert = SignedCertificate::sign( |
|
| 1808 | + | Certificate { |
|
| 1809 | + | key: id, |
|
| 1810 | + | issuer: hub.hash(), |
|
| 1811 | + | subject: "agent:claude@ada".into(), |
|
| 1812 | + | claims: vec![], |
|
| 1813 | + | scope: Scope::CHANGES | Scope::REFS | Scope::SEAL, |
|
| 1814 | + | not_before: now - 60_000, |
|
| 1815 | + | expires: now + 600_000, |
|
| 1816 | + | grant: Some(g), |
|
| 1817 | + | }, |
|
| 1818 | + | &hub, |
|
| 1819 | + | &hub_sk, |
|
| 1820 | + | ); |
|
| 1821 | + | r.store().put_certificate(&cert).unwrap(); |
|
| 1822 | + | write(&root.join(name), "work.txt", b"agent work\n"); |
|
| 1823 | + | let h = r.commit("work", &[]).unwrap(); |
|
| 1824 | + | assert_eq!(r.store().get_change_meta(&h).unwrap().author, rot.new); |
|
| 1825 | + | r.seal(&[h]).unwrap(); |
|
| 1826 | + | (r, h) |
|
| 1827 | + | }; |
|
| 1828 | + | let (r, h) = agent("kept", before); |
|
| 1829 | + | assert!( |
|
| 1830 | + | matches!( |
|
| 1831 | + | r.authenticate_change(&h).unwrap(), |
|
| 1832 | + | SignatureStatus::Valid { .. } |
|
| 1833 | + | ), |
|
| 1834 | + | "{:?}", |
|
| 1835 | + | r.authenticate_change(&h).unwrap() |
|
| 1836 | + | ); |
|
| 1837 | + | let (r, h) = agent("late", after); |
|
| 1838 | + | assert!( |
|
| 1839 | + | matches!(r.authenticate_change(&h).unwrap(), SignatureStatus::Invalid(e) if e.contains("neither")), |
|
| 1840 | + | "a grant the old key made after the rotation: {:?}", |
|
| 1841 | + | r.authenticate_change(&h).unwrap() |
|
| 1842 | + | ); |
|
| 1843 | + | } |
|
| 1844 | + | ||
| 1845 | + | #[test] |
|
| 1757 | 1846 | fn a_certified_key_acts_for_its_author_under_a_grant() { |
|
| 1758 | 1847 | use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant}; |
|
| 1759 | 1848 | use crate::identity::{Identity, Scope}; |
|
| … | 8111 unchanged lines |
modified docs/spec.md +1 −1
| … | 780 unchanged lines |
||
| 781 | 781 | } |
|
| 782 | 782 | ``` |
|
| 783 | 783 | ||
| 784 | - | A signed grant is `[grant bytes, Signature]`: the grantor's composite signature, in domain `grant`, over the grant's ObjectRef (type `grant`). It needs no maximum lifetime: it gives nothing until an issuer certifies a key under it, and the grantor revokes it by asking the issuer to stop. The issuer puts it in each certificate it issues under it (field 8), so it travels with the certificate and a verifier needs nothing else. A signature by a key that is neither a change's author nor its delegate (§14.4) still satisfies the author-or-delegate rule when the key's certificate verifies with its issuer and carries a grant that verifies with the change's author, names the certificate's subject and issuer, gives the scope needed (`changes` for a change signature, `seal` for a seal) in both the grant and the certificate, and covers the change's `hlc.wall_ms`, as a delegation does. Whether the certificate covered the time the key signed is the policy's check (§14.5), at a time the signer did not choose. |
|
| 784 | + | A signed grant is `[grant bytes, Signature]`: the grantor's composite signature, in domain `grant`, over the grant's ObjectRef (type `grant`). It needs no maximum lifetime: it gives nothing until an issuer certifies a key under it, and the grantor revokes it by asking the issuer to stop. The issuer puts it in each certificate it issues under it (field 8), so it travels with the certificate and a verifier needs nothing else. A signature by a key that is neither a change's author nor its delegate (§14.4) still satisfies the author-or-delegate rule when the key's certificate verifies with its issuer and carries a grant that verifies with the change's author (or with a key the author's identity was rotated or renewed from, by its `previous` chain, when the grant's `not_before` is before that step's `created`: an owner's rotation keeps the grants they made, but a key rotated away grants nothing new; oxygen#135), names the certificate's subject and issuer, gives the scope needed (`changes` for a change signature, `seal` for a seal) in both the grant and the certificate, and covers the change's `hlc.wall_ms`, as a delegation does. Whether the certificate covered the time the key signed is the policy's check (§14.5), at a time the signer did not choose. |
|
| 785 | 785 | ||
| 786 | 786 | **Acceptance.** A host that issues certificates records when it accepted a reference or tag attestation signed by a short-lived key: an `accepted` attestation (§14.4) naming that attestation's ObjectRef, its `hlc` the host's clock at the push, signed in domain `attest` by the issuer's key. The host keeps it indexed by the attestation it accepts (gc keeps it beside the bookmark's latest attestation or the tag's) and serves it with the bookmark (§16, `refs` entry key 5) or the tag (tag key 4). A fetch checks the certificate at the acceptance's time only when the acceptance names exactly the attestation being checked and verifies with the certificate's issuer; otherwise, including when there is none, at the attestation's own time for a bookmark, and not at all for a tag, which is left out (§14.5). Tags are kept for good and fetched long after a ten-minute certificate expired, so the issuer's acceptance travels with the tag: a client that fetched one keeps it and serves it with the tag, a push carries it, and a receiver checks a tag that comes with its issuer's acceptance at that time when its own clock is past the certificate's window (a tag passed on from the host that accepted it to another), keeping the acceptance rather than recording its own. An acceptance by any other key changes nothing: the issuer is already trusted to vouch for the key, so it adds no trust it did not have. A fetch that has the attestation's integrated time (§14.8) checks at that instead: the acceptance is the issuer's clock, the integrated time its witnesses'. |
|
| 787 | 787 | ||
| … | 401 unchanged lines |