OXYGENHUB

A renewed key approves as the account its predecessor is linked to (closes #236) 317d776c725b

A sealed approval signed by a key not linked to an account was refused,
although renewals and delegations (oxy identity delegate add, renew)
make a successor identity with the same key pair: Andy's current key was
refused while three of its predecessors were linked. The hub now follows
the signer's previous links, through the identities table or the
repository's store, while each step keeps the key pair; on reaching a
linked key the approval counts as that account's, and the chain is
linked. A step that changes the key pair ends the search: a new key is
linked by proving it, as before. The same rule as pushes, which already
linked such successors. Test: a renewal of Carol's linked key releases
the gate and is linked; a key that names hers as previous but has other
keys is refused.

Closes #236
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7f72273d5ef7
When
· 2026-10-18 01:08 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
317d776c725b16fe9ed5fd849f2fcb21afdd53bdce4d1c1eca038fc90f44e426
Depends on
42fa95540468 47e374020b59
Depended on by
nothing yet
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
6 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/hub/src/flow.rs +58 −7
…
309 unchanged lines
310 310
    Ok((statement, job))
311 311
}
312 312
313 +
/// An unlinked key's account, when its identity succeeds a linked key's
314 +
/// through `previous` links that each keep the key pair (a renewal or a
315 +
/// delegation, Oxygen spec §14.2), with the identities from it down to the
316 +
/// linked one, to link (oxygenhub#236). A step that changes the key pair
317 +
/// ends the search: that is a new key, linked by proving it.
318 +
async fn linked_through_chain(
319 +
    app: &Shared,
320 +
    repo: &RepoName,
321 +
    signer: &oxy_core::identity::Identity,
322 +
    links: &[(String, String)],
323 +
) -> Option<(String, Vec<oxy_core::identity::Identity>)> {
324 +
    let mut chain = vec![signer.clone()];
325 +
    let mut cur = signer.clone();
326 +
    for _ in 0..64 {
327 +
        let prev = cur.previous?;
328 +
        let hex = prev.to_hex();
329 +
        let found = match db::accounts::identity_by_key(&app.db, &hex).await.ok()? {
330 +
            Some(l) => oxy_core::identity::Identity::decode(&l.object).ok(),
331 +
            None => app.repos.read(repo, move |r, _| Ok(r.store().get_identity(&prev).ok())).await.ok().flatten(),
332 +
        };
333 +
        let id = found?;
334 +
        if id.hash() != prev || id.ed25519_pk != cur.ed25519_pk || id.mldsa65_pk != cur.mldsa65_pk {
335 +
            return None;
336 +
        }
337 +
        if let Some((_, u)) = links.iter().find(|(k, _)| *k == hex) {
338 +
            return Some((u.clone(), chain));
339 +
        }
340 +
        chain.push(id.clone());
341 +
        cur = id;
342 +
    }
343 +
    None
344 +
}
345 +
313 346
/// Decide on job `name` of `run` from a signed approval (oxygenhub#235): it
314 347
/// must verify, cover exactly this job's statement, and be signed by a key
315 348
/// linked to an account (a delegated agent key is not) that may decide; it
…
13 unchanged lines
329 362
    }
330 363
    let key = sa.signer.hash().to_hex();
331 364
    let links = db::accounts::identity_accounts(&app.db).await.map_err(|e| e.to_string())?;
332 -
    let Some((_, username)) = links.iter().find(|(k, _)| *k == key) else {
333 -
        return Err(format!(
334 -
            "key {} is not linked to an account here (link it under Settings › Keys; an agent's delegated key cannot approve)",
335 -
            &key[..12]
336 -
        ));
337 -
    };
338 -
    let who = db::accounts::account_by_name(&app.db, username).await.map_err(|e| e.to_string())?.ok_or("the key's account is gone")?;
339 365
    let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
366 +
    let username = match links.iter().find(|(k, _)| *k == key) {
367 +
        Some((_, u)) => u.clone(),
368 +
        // A renewal or delegation of a linked key keeps its key pair: it is
369 +
        // the same person's, and linked now (oxygenhub#236).
370 +
        None => match linked_through_chain(app, &repo_name, &sa.signer, &links).await {
371 +
            Some((u, chain)) => {
372 +
                if let Ok(Some(acc)) = db::accounts::account_by_name(&app.db, &u).await {
373 +
                    for id in &chain {
374 +
                        let k = id.hash().to_hex();
375 +
                        if let Ok(None) = db::accounts::identity_by_key(&app.db, &k).await {
376 +
                            db::accounts::link_identity(&app.db, acc.id, &k, &id.name, &id.email, &id.encode()).await.ok();
377 +
                        }
378 +
                    }
379 +
                }
380 +
                u
381 +
            }
382 +
            None => {
383 +
                return Err(format!(
384 +
                    "key {} is not linked to an account here, nor a renewal of a key that is (link it under Settings › Keys; an agent's delegated key cannot approve)",
385 +
                    &key[..12]
386 +
                ))
387 +
            }
388 +
        },
389 +
    };
390 +
    let who = db::accounts::account_by_name(&app.db, &username).await.map_err(|e| e.to_string())?.ok_or("the key's account is gone")?;
340 391
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?;
341 392
    let job = jobs.iter().find(|j| j.name == name).ok_or("no such job")?;
342 393
    may_decide(app, row, run, job, &who).await?;
…
2146 unchanged lines
modified crates/hub/tests/flow.rs +14 −2
…
4525 unchanged lines
4526 4526
    assert!(st == StatusCode::CONFLICT && body.contains("not linked to an account"), "{st} {body}");
4527 4527
    assert!(claim(&h.app).await.is_none(), "still waiting");
4528 4528
4529 -
    // Carol's seal releases it; it is kept in the repository; the token names her and it.
4530 -
    let (st, body) = seal(&statement, &sk, &carol_id).await;
4529 +
    // A key that claims Carol's as its predecessor but has other keys: refused.
4530 +
    let forged = oxy_core::identity::Identity { previous: Some(carol_id.hash()), ..agent_id.clone() };
4531 +
    let (st, body) = seal(&statement, &agent, &forged).await;
4532 +
    assert!(st == StatusCode::CONFLICT && body.contains("not linked"), "{st} {body}");
4533 +
4534 +
    // Carol's seal, by a renewal of her linked key never linked itself
4535 +
    // (oxygenhub#236), releases it and links it; it is kept in the
4536 +
    // repository; the token names her and it.
4537 +
    let renewed = carol_id.successor(vec![], None, 2);
4538 +
    let (st, body) = seal(&statement, &sk, &renewed).await;
4531 4539
    assert_eq!(st, StatusCode::OK, "{body}");
4540 +
    assert!(
4541 +
        db::accounts::identity_by_key(&h.app.db, &renewed.hash().to_hex()).await.unwrap().is_some_and(|l| l.account_id == carol.id),
4542 +
        "linked now"
4543 +
    );
4532 4544
    assert!(body.contains("approved by carol, sealed"), "{body}");
4533 4545
    let name = oxygenhub::repos::RepoName::parse("andy/infra").unwrap();
4534 4546
    let state = statement.state;
…
12 unchanged lines