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 |