OXYGENHUB

Tags made before a key rotation verify again e5d9b0513f3e

A tag's attestation was checked against the current policy with
Policy::authorizes, which knows nothing of rotation, so every tag signed
before a rotation was refused and a fresh clone of this repository failed
on v0.1.0. A tag signed by a predecessor of a key the rule lists is now
authorized when it is dated before that predecessor's successor was made:
old tags stay valid, and a superseded key makes nothing new. Revoked keys
never qualify. Spec 14.2 says so; the tag test rotates and clones again.

Closes #114
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-17 01:45 UTC
Signature
✔ signed by Claude Code
Hash
e5d9b0513f3ecaf20bfc8875158b1979e5857d2e4050533525524dd1817c2592
Depends on
132b884b9a8d 28fc23d7706a 3526d339519f 75167672ebcd 7ba155f20aa0 84b9e9ac0ab4 982864068a59 eae595568832 ff0af1ee299d
Depended on by
ee130dd9eef2 1ec6fdbc6bcb 767ac6cd0545
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
7 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/oxy-core/src/repo/sync.rs +43 −0
…
439 unchanged lines
440 440
        })
441 441
    }
442 442
443 +
    /// A lasting attestation (a tag) signed by a key the policy has since
444 +
    /// rotated away from (spec §14.2) is authorized as the key that
445 +
    /// succeeded it: a key the rule lists reaches the signer through
446 +
    /// `previous` links, and the attestation is dated (`at_ms`) before the
447 +
    /// signer's successor was made, so a superseded key makes nothing new.
448 +
    /// Revoked keys authorize nothing.
449 +
    pub(crate) fn predecessor_authorized(
450 +
        &self,
451 +
        p: &crate::policy::Policy,
452 +
        signer: &KeyId,
453 +
        reference: &str,
454 +
        at_ms: u64,
455 +
    ) -> bool {
456 +
        if p.is_revoked(signer) {
457 +
            return false;
458 +
        }
459 +
        let Some(rule) = p.rule_for(reference) else {
460 +
            return false;
461 +
        };
462 +
        let identity = |k: &KeyId| {
463 +
            p.identity(k)
464 +
                .cloned()
465 +
                .or_else(|| self.store.get_identity(k).ok())
466 +
        };
467 +
        rule.keys.iter().any(|k| {
468 +
            if p.is_revoked(k) {
469 +
                return false;
470 +
            }
471 +
            let mut cur = identity(k);
472 +
            for _ in 0..32 {
473 +
                let Some(id) = cur else {
474 +
                    return false;
475 +
                };
476 +
                match id.previous {
477 +
                    Some(prev) if prev == *signer => return at_ms < id.created,
478 +
                    Some(prev) if !p.is_revoked(&prev) => cur = identity(&prev),
479 +
                    _ => return false,
480 +
                }
481 +
            }
482 +
            false
483 +
        })
484 +
    }
485 +
443 486
    fn check_ref_attest(
444 487
        &self,
445 488
        sa: &SignedAttest,
…
1207 unchanged lines
modified crates/oxy-core/src/repo/tags.rs +3 −1
…
210 unchanged lines
211 211
            verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
212 212
            if let Some(p) = policy {
213 213
                let reference = format!("tags/{}", t.name);
214 +
                let at = sa.attest.hlc.wall_ms;
214 215
                if !p.authorizes(&sig.key, &reference)
215 -
                    && !self.delegate_authorized(p, &sig.key, &reference, sa.attest.hlc.wall_ms)
216 +
                    && !self.delegate_authorized(p, &sig.key, &reference, at)
217 +
                    && !self.predecessor_authorized(p, &sig.key, &reference, at)
216 218
                {
217 219
                    return Err(format!(
218 220
                        "key {} may not create tags/{} under this repository's policy",
…
26 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +17 −0
…
2104 unchanged lines
2105 2105
    assert!(r.iter().any(|x| x.name == "tags/v3" && x.ok), "{r:?}");
2106 2106
    assert!(a.gc(true).is_ok());
2107 2107
    assert_eq!(a.tags().unwrap().len(), 3, "tags survive gc");
2108 +
2109 +
    // After a rotation the policy names only the new key, yet a fresh clone
2110 +
    // still takes the tags the old key made before it; the old key makes
2111 +
    // nothing new.
2112 +
    let rot = a.rotate_identity().unwrap();
2113 +
    let r = a.push("origin", false).unwrap();
2114 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
2115 +
    let c = Repo::clone(&url, &root.join("c"))
2116 +
        .unwrap()
2117 +
        .with_home(root.join("home-c"));
2118 +
    assert_eq!(c.tag_heads("v3").unwrap(), Some(vec![h2]));
2119 +
    let (_, sp) = c.current_policy().unwrap().unwrap();
2120 +
    assert!(!sp.policy.authorizes(&key_a, "tags/v9"));
2121 +
    let made = c.identity(&rot.new).unwrap().created;
2122 +
    assert!(c.predecessor_authorized(&sp.policy, &key_a, "tags/v9", made - 1));
2123 +
    assert!(!c.predecessor_authorized(&sp.policy, &key_a, "tags/v9", made));
2124 +
    assert!(!c.predecessor_authorized(&sp.policy, &rot.new, "tags/v9", 0));
2108 2125
}
2109 2126
2110 2127
#[test]
…
2946 unchanged lines
modified docs/spec.md +1 −1
…
598 unchanged lines
599 599
600 600
The two public keys live in one object, so a `KeyId` names the pair. `delegates` lists keys that may act for this identity, each within a **scope**: `changes` lets the key sign changes whose `author` is this identity, `seal` lets it seal them, and `refs` lets it attest references and tags as this identity would (§14.5). A proxy acting for a Thin client (§22) or a host that commits browser edits for a user gets `changes,refs`; an AI agent gets `changes` alone, so its signature authenticates the changes it makes but it can never move a reference, whatever its owner may do. A delegation with `expires` authenticates nothing whose `hlc.wall_ms` is later than that, so an agent can be let in for a session without changing the owner's own expiry. Delegation is one level deep and is not transitive. A bare `KeyId` in `delegates`, as identities carried before scopes existed, means every scope with no expiry. Rotation is a new identity with `previous` set, plus an attestation of kind `rotate` signed by the previous key. Revocation is an attestation of kind `revoke` signed by the revoked key, by the identity that delegated to it, or by a policy root key (§14.5); the delegator's revocation marks the changes the key already signed as valid but revoked, and the delegator then re-issues their identity without it.
601 601
602 -
**Lifecycle rules.** Because the `KeyId` is the hash of the whole object, any change to an identity, including its `delegates` or `expires`, is a rotation: a successor object with `previous` set. A successor MAY keep the same key pair (`oxy identity renew`, `oxy identity delegate add|remove`) or carry a fresh one (`oxy identity rotate`); either way earlier signatures remain verifiable under the earlier object, which stays stored. A rotation **supersedes** the previous KeyId: a policy that named it MUST be updated to name the successor (§14.5), and a rule key covers every predecessor in its `previous` chain that is not revoked, so changes signed before a rotation remain authorized. Revocation is for compromised or abandoned keys: a revoked key authorizes nothing, whatever its chain. `expires` bounds the changes a key can authenticate: a signature or seal over a change whose `hlc.wall_ms` is later than the identity's `expires` is invalid. A client records the revocations it makes or verifies under `.oxy/revoked/` and reports signatures by such keys as valid but revoked.
602 +
**Lifecycle rules.** Because the `KeyId` is the hash of the whole object, any change to an identity, including its `delegates` or `expires`, is a rotation: a successor object with `previous` set. A successor MAY keep the same key pair (`oxy identity renew`, `oxy identity delegate add|remove`) or carry a fresh one (`oxy identity rotate`); either way earlier signatures remain verifiable under the earlier object, which stays stored. A rotation **supersedes** the previous KeyId: a policy that named it MUST be updated to name the successor (§14.5), and a rule key covers every predecessor in its `previous` chain that is not revoked, so changes signed before a rotation remain authorized. A tag's attestation (§10) signed by such a predecessor is authorized only when its `hlc.wall_ms` is earlier than the `created` of the identity that succeeded the signer, so tags made before a rotation stay valid and a superseded key makes no new ones. Revocation is for compromised or abandoned keys: a revoked key authorizes nothing, whatever its chain. `expires` bounds the changes a key can authenticate: a signature or seal over a change whose `hlc.wall_ms` is later than the identity's `expires` is invalid. A client records the revocations it makes or verifies under `.oxy/revoked/` and reports signatures by such keys as valid but revoked.
603 603
604 604
### 14.3 Composite signature (G2, G4)
605 605
…
411 unchanged lines