OXYGENHUB

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