A stale author.key can no longer fork the identity (closes #163) aded4110f0bd
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 3d2ea1156094
- When
- · 2026-11-03 21:17 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
aded4110f0bd8b60dee14d0a67a7a16193e58ca1e124670960cf2b5f3d4989b3- Depends on
- 135bdcadec4a 28fc23d7706a 2f0b4b0651ea 614f55e14eee 75167672ebcd 8fd78eb52ad2 982864068a59 fce44ae9ad29
- Depended on by
- 835ceb9b3ea7 232aabb5452d 284fe47d98b9
- In bookmarks
- main
- In tags
- v0.11.0
- Edits
- 9 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified CHANGELOG.md +4 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## Unreleased |
|
| 4 | + | ||
| 5 | + | - **Fixed: a stale `author.key` can no longer fork your identity (#163).** A checkout that pinned `author.key` to a key a rotation made elsewhere had already succeeded built a second successor of it on `oxy identity delegate add`, splitting the identity in two. `rotate`, `renew` and `delegate add`/`remove` now refuse to start from a key that already has a successor in your home, naming the newest key and the `oxy config` that points at it; their report says which key they started from and which config named it; and `oxy identity show` warns when the configured key is not the newest, and names the key where an identity forks. |
|
| 6 | + | ||
| 3 | 7 | ## 0.10.0 (2026-11-01) |
|
| 4 | 8 | ||
| 5 | 9 | - **Fixed: fetch, pull and clone are far faster with many bookmarks (#158).** They fetched each bookmark in its own exchange with the hub, and changes that bookmarks share were sent again for each one; a pull with nothing new still asked for a bundle per bookmark. Now every bookmark's heads are fetched in one exchange, and none when nothing is new. On a 47 MB repository with 160 bookmarks: a pull with nothing new 169 s → 3.5 s, a clone 324 s → 26 s. `OXY_TIMINGS` now also shows the refs, transparency log and per-bookmark checks of a fetch. |
|
| … | 313 unchanged lines |
modified crates/oxy-core/src/repo/identity.rs +65 −0
| … | 171 unchanged lines |
||
| 172 | 172 | if new.previous != Some(old) { |
|
| 173 | 173 | return fail("the successor identity must name the current key as previous"); |
|
| 174 | 174 | } |
|
| 175 | + | // A key that already has a successor is not the identity's newest: |
|
| 176 | + | // building another from it forks the identity into two branches |
|
| 177 | + | // (oxygen#163), as a checkout whose stale `author.key` pin outlived |
|
| 178 | + | // a rotation made elsewhere once did. |
|
| 179 | + | if let Some(newer) = self.successors(&old)?.into_iter().next() { |
|
| 180 | + | let newest = self.newest(&newer)?; |
|
| 181 | + | let fix = if self.config.is_repo_set("author.key") { |
|
| 182 | + | format!( |
|
| 183 | + | "This repository's config pins author.key to the older key: `oxy config author.key {}` here \ |
|
| 184 | + | (or `oxy config --unset author.key` to follow the user config), then run it again.", |
|
| 185 | + | newest.to_hex() |
|
| 186 | + | ) |
|
| 187 | + | } else { |
|
| 188 | + | format!( |
|
| 189 | + | "The user config names the older key: `oxy config --user author.key {}`, then run it again.", |
|
| 190 | + | newest.to_hex() |
|
| 191 | + | ) |
|
| 192 | + | }; |
|
| 193 | + | return fail(format!( |
|
| 194 | + | "`{command}` would start from key {}, which is not your identity's newest ({} succeeds it, and {} is the newest). \ |
|
| 195 | + | Another successor would fork the identity into two branches.\n{fix}", |
|
| 196 | + | old.short(12), |
|
| 197 | + | newer.short(12), |
|
| 198 | + | newest.short(12), |
|
| 199 | + | )); |
|
| 200 | + | } |
|
| 175 | 201 | let new_key = self.install_key(&new, &sk)?; |
|
| 176 | 202 | let attest = Attest { |
|
| 177 | 203 | repo: self.id()?, |
|
| … | 18 unchanged lines |
||
| 196 | 222 | identity: new, |
|
| 197 | 223 | policy, |
|
| 198 | 224 | }) |
|
| 225 | + | } |
|
| 226 | + | ||
| 227 | + | /// Keys in this home whose identity names `key` as `previous`: the |
|
| 228 | + | /// successors already made from it. More than one means a fork. |
|
| 229 | + | pub fn successors(&self, key: &KeyId) -> Result<Vec<KeyId>> { |
|
| 230 | + | let mut found = Vec::new(); |
|
| 231 | + | let entries = match fs::read_dir(&self.keys_dir) { |
|
| 232 | + | Ok(e) => e, |
|
| 233 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found), |
|
| 234 | + | Err(e) => return Err(e.into()), |
|
| 235 | + | }; |
|
| 236 | + | for entry in entries { |
|
| 237 | + | let path = entry?.path(); |
|
| 238 | + | if path.extension().and_then(|e| e.to_str()) != Some("identity") { |
|
| 239 | + | continue; |
|
| 240 | + | } |
|
| 241 | + | let Ok(bytes) = fs::read(&path) else { continue }; |
|
| 242 | + | let Ok(id) = Identity::decode(&bytes) else { |
|
| 243 | + | continue; |
|
| 244 | + | }; |
|
| 245 | + | if id.previous == Some(*key) { |
|
| 246 | + | found.push(id.hash()); |
|
| 247 | + | } |
|
| 248 | + | } |
|
| 249 | + | found.sort(); |
|
| 250 | + | Ok(found) |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | /// The newest key of the line through `key` in this home: its |
|
| 254 | + | /// successors followed to the end (the first, where there is a fork). |
|
| 255 | + | pub fn newest(&self, key: &KeyId) -> Result<KeyId> { |
|
| 256 | + | let mut cur = *key; |
|
| 257 | + | for _ in 0..256 { |
|
| 258 | + | match self.successors(&cur)?.into_iter().next() { |
|
| 259 | + | Some(next) => cur = next, |
|
| 260 | + | None => break, |
|
| 261 | + | } |
|
| 262 | + | } |
|
| 263 | + | Ok(cur) |
|
| 199 | 264 | } |
|
| 200 | 265 | ||
| 201 | 266 | /// Rotate to a fresh key pair; the new identity names the old one as |
|
| … | 613 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +49 −0
| … | 11116 unchanged lines |
||
| 11117 | 11117 | report.refs |
|
| 11118 | 11118 | ); |
|
| 11119 | 11119 | } |
|
| 11120 | + | ||
| 11121 | + | #[test] |
|
| 11122 | + | fn a_stale_pinned_key_cannot_fork_the_identity() { |
|
| 11123 | + | // oxygen#163: a checkout pinned author.key to a key that a rotation made |
|
| 11124 | + | // elsewhere had already succeeded; delegating there built a second |
|
| 11125 | + | // successor of it, splitting the identity in two. |
|
| 11126 | + | hermetic(); |
|
| 11127 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 11128 | + | let root = dir.path(); |
|
| 11129 | + | let home = root.join("home"); |
|
| 11130 | + | let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home); |
|
| 11131 | + | let (first, _) = a |
|
| 11132 | + | .create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 11133 | + | .unwrap(); |
|
| 11134 | + | let r = a.rotate_identity().unwrap(); |
|
| 11135 | + | assert_eq!((r.old, a.config.author_key), (first, Some(r.new))); |
|
| 11136 | + | assert_eq!(a.successors(&first).unwrap(), vec![r.new]); |
|
| 11137 | + | assert_eq!(a.newest(&first).unwrap(), r.new); |
|
| 11138 | + | ||
| 11139 | + | // Another checkout still pins the first key. |
|
| 11140 | + | let mut b = Repo::init(&root.join("b")).unwrap().with_home(&home); |
|
| 11141 | + | b.config.set("author.key", &first.to_hex()).unwrap(); |
|
| 11142 | + | b.save_config().unwrap(); |
|
| 11143 | + | let mut b = Repo::open(&root.join("b")).unwrap().with_home(&home); |
|
| 11144 | + | assert_eq!(b.config.author_key, Some(first)); |
|
| 11145 | + | for e in [ |
|
| 11146 | + | b.rotate_identity().unwrap_err().to_string(), |
|
| 11147 | + | b.renew_identity(None).unwrap_err().to_string(), |
|
| 11148 | + | ] { |
|
| 11149 | + | assert!(e.contains("fork") && e.contains(&r.new.short(12)), "{e}"); |
|
| 11150 | + | assert!( |
|
| 11151 | + | e.contains(&format!("oxy config author.key {}", r.new.to_hex())), |
|
| 11152 | + | "{e}" |
|
| 11153 | + | ); |
|
| 11154 | + | } |
|
| 11155 | + | assert_eq!( |
|
| 11156 | + | b.successors(&first).unwrap(), |
|
| 11157 | + | vec![r.new], |
|
| 11158 | + | "nothing was made" |
|
| 11159 | + | ); |
|
| 11160 | + | ||
| 11161 | + | // Pointed at the newest key, it rotates as usual. |
|
| 11162 | + | b.config.set("author.key", &r.new.to_hex()).unwrap(); |
|
| 11163 | + | b.save_config().unwrap(); |
|
| 11164 | + | let mut b = Repo::open(&root.join("b")).unwrap().with_home(&home); |
|
| 11165 | + | let r2 = b.rotate_identity().unwrap(); |
|
| 11166 | + | assert_eq!(r2.old, r.new); |
|
| 11167 | + | assert_eq!(b.newest(&first).unwrap(), r2.new); |
|
| 11168 | + | } |
modified src/cli/identity.rs +49 −1
| … | 131 unchanged lines |
||
| 132 | 132 | ui::author(&r.identity.name), |
|
| 133 | 133 | r.identity.email |
|
| 134 | 134 | ); |
|
| 135 | - | println!("{}", ui::kv("old key", ui::dim(r.old.to_hex()))); |
|
| 135 | + | // Which key it started from, and where that came from (oxygen#163). |
|
| 136 | + | let from = if repo.config.is_repo_set("author.key") { |
|
| 137 | + | "this repository's author.key" |
|
| 138 | + | } else { |
|
| 139 | + | "the user config's author.key" |
|
| 140 | + | }; |
|
| 141 | + | println!( |
|
| 142 | + | "{}", |
|
| 143 | + | ui::kv( |
|
| 144 | + | "old key", |
|
| 145 | + | format!( |
|
| 146 | + | "{} {}", |
|
| 147 | + | ui::dim(r.old.to_hex()), |
|
| 148 | + | ui::dim(format!("(from {from})")) |
|
| 149 | + | ) |
|
| 150 | + | ) |
|
| 151 | + | ); |
|
| 136 | 152 | println!("{}", ui::kv("new key", r.new.to_hex())); |
|
| 137 | 153 | print_delegates(repo, &r.identity.delegates); |
|
| 138 | 154 | if let Some(e) = r.identity.expires { |
|
| … | 175 unchanged lines |
||
| 314 | 330 | pinned.short(12), |
|
| 315 | 331 | u.short(12), |
|
| 316 | 332 | pinned.to_hex() |
|
| 333 | + | )) |
|
| 334 | + | ); |
|
| 335 | + | } |
|
| 336 | + | } |
|
| 337 | + | // A key with a successor is stale, and a key with two is a fork: both |
|
| 338 | + | // are how an identity splits without anyone noticing (oxygen#163). |
|
| 339 | + | let next = repo.successors(&key)?; |
|
| 340 | + | if let Some(n) = next.first() { |
|
| 341 | + | println!( |
|
| 342 | + | " {} {}", |
|
| 343 | + | ui::caution(), |
|
| 344 | + | ui::warn(format!( |
|
| 345 | + | "this key is not your identity's newest: {} succeeds it, {} is the newest. Rotating or delegating from it would fork the identity; point author.key at the newest", |
|
| 346 | + | n.short(12), |
|
| 347 | + | repo.newest(n)?.short(12) |
|
| 348 | + | )) |
|
| 349 | + | ); |
|
| 350 | + | } |
|
| 351 | + | let mut cur = Some(key); |
|
| 352 | + | for _ in 0..256 { |
|
| 353 | + | let Some(k) = cur else { break }; |
|
| 354 | + | let after = repo.successors(&k)?; |
|
| 355 | + | if after.len() > 1 { |
|
| 356 | + | println!( |
|
| 357 | + | " {} {}", |
|
| 358 | + | ui::caution(), |
|
| 359 | + | ui::err(format!( |
|
| 360 | + | "the identity forks at {}: {} each succeed it. Keep one branch: revoke the others' keys (oxy identity revoke KEY) and point every home's author.key at the one you keep", |
|
| 361 | + | k.short(12), |
|
| 362 | + | after.iter().map(|a| a.short(12)).collect::<Vec<_>>().join(" and ") |
|
| 317 | 363 | )) |
|
| 318 | 364 | ); |
|
| 365 | + | break; |
|
| 319 | 366 | } |
|
| 367 | + | cur = repo.identity(&k).ok().and_then(|i| i.previous); |
|
| 320 | 368 | } |
|
| 321 | 369 | print_delegates(&repo, &id.delegates); |
|
| 322 | 370 | if repo.is_revoked(&key)? { |
|
| … | 666 unchanged lines |