OXYGENHUB

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