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 |