Linking the identity that delegates to the hub; New drop form full width (closes #281) 787632570bf9
The notice on pages that commit for you read as if the hub's key file was what to link, and Settings › Keys then asked for a signature only the hub can make. It now says to export your own identity again after delegating; the hub's own key is refused saying so, and a challenge signed by another key names both keys. The New drop form runs the page's width, and its buttons are the same height in one row.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f722f257dd47
- When
- · 2026-10-20 17:50 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
787632570bf924647f982368186b693468134d3617cec41ca11ed19451205d46- Depends on
- 145306044896 19bb31ff1f5c a549ddd5941f a7e6e8e6094c b43a9ab0498f b5668108487f d23464c0ac60
- Depended on by
- 858eca0d03c4
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- 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
- 13 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Linking the identity that delegates to the hub (#281):** the notice on pages that commit for you (New drop, web edits) read as if the hub's key file was what to link under Settings › Keys, which then asked for a signature nobody but the hub can make. It now says to export your own identity again after `oxy identity delegate add` and upload that; uploading the hub's own key is refused saying so, uploading an already linked key says to export again after delegating, and a challenge signed by another key than the one uploaded names both keys. The New drop form runs the page's width, and CREATE DROP and CANCEL are the same height in one row. |
|
| 8 | + | ||
| 7 | 9 | ## 0.6.1 (2026-10-20) |
|
| 8 | 10 | ||
| 9 | 11 | - **Tidier forms and headers:** the New drop page is laid out like every other page (it ran the full width when its notice did), a form's Cancel lines up with its buttons (drops and web edits), and the Drops page's + NEW DROP sits beside its title instead of against the text above. |
|
| … | 262 unchanged lines |
modified crates/hub/src/web/edit.rs +1 −1
| … | 40 unchanged lines |
||
| 41 | 41 | ||
| 42 | 42 | pub async fn signer_for(app: &Shared, account_id: i64) -> Result<Signer, String> { |
|
| 43 | 43 | let author = delegated_identity(app, account_id).await.ok_or_else(|| { |
|
| 44 | - | "You have not delegated to this hub's key yet. From a checkout with your identity: download the hub key from /.well-known/oxyhub-key, run `oxy identity delegate add oxyhub-key.identity --scope changes,refs`, then link the new identity under Settings › Keys (`oxy identity export`).".to_string() |
|
| 44 | + | "You have not delegated to this hub's key yet. In a checkout with your identity, download the hub's key from /.well-known/oxyhub-key and run `oxy identity delegate add oxyhub-key.identity --scope changes,refs`. That makes a new version of your own identity: export it there (`oxy identity export`), upload that file under Settings › Keys and sign the challenge in the same checkout. The hub's key file is not the one to upload.".to_string() |
|
| 45 | 45 | })?; |
|
| 46 | 46 | let (hub_key, hub_secret) = crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| format!("the hub key is unavailable: {e}"))?; |
|
| 47 | 47 | Ok(Signer { author, hub_key, hub_secret, hub_identity: app.hub_key.identity.clone() }) |
|
| … | 237 unchanged lines |
modified crates/hub/src/web/settings.rs +17 −10
| … | 576 unchanged lines |
||
| 577 | 577 | Ok(i) => i, |
|
| 578 | 578 | Err(e) => return keys_page(&app, shell, me.account, None, Some(format!("That is not an identity file: {e}")), None).await, |
|
| 579 | 579 | }; |
|
| 580 | + | if identity.hash() == app.hub_key.key { |
|
| 581 | + | let msg = "That is this hub's own key, which only the hub holds. Upload your identity: after `oxy identity delegate add` with the hub's key, run `oxy identity export` in that checkout and upload the file it writes."; |
|
| 582 | + | return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await; |
|
| 583 | + | } |
|
| 580 | 584 | let key = identity.hash().to_hex(); |
|
| 581 | 585 | if let Some(existing) = accounts::identity_by_key(&app.db, &key).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 582 | 586 | let msg = if existing.account_id == me.account.id { |
|
| 583 | - | "That key is already linked to you." |
|
| 587 | + | "That key is already linked to you. If you have delegated since (`oxy identity delegate add`), export again: a delegation makes a new version of your identity." |
|
| 584 | 588 | } else { |
|
| 585 | 589 | "That key is linked to another account." |
|
| 586 | 590 | }; |
|
| … | 40 unchanged lines |
||
| 627 | 631 | let challenge = hex::decode(&bytes).unwrap_or_default(); |
|
| 628 | 632 | let ok = sigs.first().is_some_and(|sig| oxy_core::identity::verify_challenge(&identity, sig, &challenge).is_ok()); |
|
| 629 | 633 | if !ok { |
|
| 630 | - | return keys_page( |
|
| 631 | - | &app, |
|
| 632 | - | shell, |
|
| 633 | - | me.account, |
|
| 634 | - | None, |
|
| 635 | - | Some("The signature does not verify against that identity and challenge.".into()), |
|
| 636 | - | None, |
|
| 637 | - | ) |
|
| 638 | - | .await; |
|
| 634 | + | // Signed by another key than the one uploaded: say which, as it is |
|
| 635 | + | // usually the wrong checkout or an `author.signer` set there. |
|
| 636 | + | let msg = match sigs.first() { |
|
| 637 | + | Some(sig) if sig.key != identity.hash() => format!( |
|
| 638 | + | "That signature is by key {}, but the identity uploaded is {} ({}). Sign in a checkout whose `oxy identity export` gives that identity, without an `author.signer` set, or upload the identity that checkout exports.", |
|
| 639 | + | sig.key.short(12), |
|
| 640 | + | identity.hash().short(12), |
|
| 641 | + | identity.name |
|
| 642 | + | ), |
|
| 643 | + | _ => "The signature does not verify against that identity and challenge.".into(), |
|
| 644 | + | }; |
|
| 645 | + | return keys_page(&app, shell, me.account, None, Some(msg), None).await; |
|
| 639 | 646 | } |
|
| 640 | 647 | let key = identity.hash().to_hex(); |
|
| 641 | 648 | accounts::link_identity(&app.db, me.account.id, &key, &identity.name, &identity.email, &object) |
|
| … | 172 unchanged lines |
modified crates/hub/tests/accounts.rs +38 −16
| … | 119 unchanged lines |
||
| 120 | 120 | r.cookie.expect("session cookie") |
|
| 121 | 121 | } |
|
| 122 | 122 | ||
| 123 | + | /// Upload an identity under Settings › Keys; the page it answers with. |
|
| 124 | + | async fn upload_identity(app: &Shared, cookie: &str, csrf: &str, object: &[u8]) -> String { |
|
| 125 | + | let boundary = "XYZ"; |
|
| 126 | + | let mut multipart = Vec::new(); |
|
| 127 | + | multipart.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"_csrf\"\r\n\r\n{csrf}\r\n").as_bytes()); |
|
| 128 | + | multipart.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"identity\"; filename=\"me.identity\"\r\nContent-Type: application/octet-stream\r\n\r\n").as_bytes()); |
|
| 129 | + | multipart.extend_from_slice(object); |
|
| 130 | + | multipart.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); |
|
| 131 | + | let req = Request::builder() |
|
| 132 | + | .method("POST") |
|
| 133 | + | .uri("/settings/keys/upload") |
|
| 134 | + | .header(header::COOKIE, format!("oxyhub={cookie}")) |
|
| 135 | + | .header(header::CONTENT_TYPE, format!("multipart/form-data; boundary={boundary}")) |
|
| 136 | + | .body(Body::from(multipart)) |
|
| 137 | + | .unwrap(); |
|
| 138 | + | let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
|
| 139 | + | assert_eq!(res.status(), StatusCode::OK); |
|
| 140 | + | String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned() |
|
| 141 | + | } |
|
| 142 | + | ||
| 123 | 143 | fn csrf_of(page: &str) -> String { |
|
| 124 | 144 | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 125 | 145 | page[i..].split('"').next().unwrap().to_string() |
|
| … | 105 unchanged lines |
||
| 231 | 251 | }; |
|
| 232 | 252 | let keys_page = get(app, "/settings/keys", Some(&andy)).await; |
|
| 233 | 253 | let csrf = csrf_of(&keys_page.body); |
|
| 234 | - | let boundary = "XYZ"; |
|
| 235 | - | let mut multipart = Vec::new(); |
|
| 236 | - | multipart.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"_csrf\"\r\n\r\n{csrf}\r\n").as_bytes()); |
|
| 237 | - | multipart.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"identity\"; filename=\"me.identity\"\r\nContent-Type: application/octet-stream\r\n\r\n").as_bytes()); |
|
| 238 | - | multipart.extend_from_slice(&identity.encode()); |
|
| 239 | - | multipart.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); |
|
| 240 | - | let req = Request::builder() |
|
| 241 | - | .method("POST") |
|
| 242 | - | .uri("/settings/keys/upload") |
|
| 243 | - | .header(header::COOKIE, format!("oxyhub={andy}")) |
|
| 244 | - | .header(header::CONTENT_TYPE, format!("multipart/form-data; boundary={boundary}")) |
|
| 245 | - | .body(Body::from(multipart)) |
|
| 246 | - | .unwrap(); |
|
| 247 | - | let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
|
| 248 | - | assert_eq!(res.status(), StatusCode::OK); |
|
| 249 | - | let body = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned(); |
|
| 254 | + | let body = upload_identity(app, &andy, &csrf, &identity.encode()).await; |
|
| 250 | 255 | let challenge_hex = body.split("sign-challenge ").nth(1).unwrap().split('<').next().unwrap().trim().to_string(); |
|
| 251 | 256 | let challenge_id = body.split("name=\"challenge\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string(); |
|
| 252 | 257 | let (_, sk) = client.secret_key().unwrap().unwrap(); |
|
| … | 8 unchanged lines |
||
| 261 | 266 | // A forged signature is refused. |
|
| 262 | 267 | let r = post(app, "/settings/keys/verify", Some(&andy), &format!("_csrf={csrf}&challenge=999&signature={sig_hex}")).await; |
|
| 263 | 268 | assert!(r.body.contains("expired"), "{}", r.body); |
|
| 269 | + | // oxygenhub#281: the hub's own key is refused with what to upload |
|
| 270 | + | // instead, and a challenge signed by another key names both keys. |
|
| 271 | + | let body = upload_identity(app, &andy, &csrf, &app.hub_key.identity.encode()).await; |
|
| 272 | + | assert!(body.contains("this hub's own key") || body.contains("this hub's own key"), "{body}"); |
|
| 273 | + | assert!(!body.contains("sign-challenge "), "no challenge for the hub's key: {body}"); |
|
| 274 | + | let other = oxy_core::identity::SecretKey::generate().identity("Other", "[email protected]", 1); |
|
| 275 | + | let body = upload_identity(app, &andy, &csrf, &other.encode()).await; |
|
| 276 | + | let challenge_hex = body.split("sign-challenge ").nth(1).unwrap().split('<').next().unwrap().trim().to_string(); |
|
| 277 | + | let challenge_id = body.split("name=\"challenge\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string(); |
|
| 278 | + | let sig = sk.sign_challenge(&key, &hex::decode(&challenge_hex).unwrap()).unwrap(); |
|
| 279 | + | let sig_hex = hex::encode(oxy_core::identity::Signature::encode_list(&[sig])); |
|
| 280 | + | let r = post(app, "/settings/keys/verify", Some(&andy), &format!("_csrf={csrf}&challenge={challenge_id}&signature={sig_hex}")).await; |
|
| 281 | + | assert!( |
|
| 282 | + | r.body.contains(&format!("That signature is by key {}", key.short(12))) && r.body.contains(&other.hash().short(12)), |
|
| 283 | + | "{}", |
|
| 284 | + | r.body |
|
| 285 | + | ); |
|
| 264 | 286 | ||
| 265 | 287 | // Organisations: a private org repository is visible to members through the default role. |
|
| 266 | 288 | let org_page = get(app, "/orgs/new", Some(&andy)).await; |
|
| … | 1457 unchanged lines |
modified templates/style.css +5 −1
| … | 124 unchanged lines |
||
| 125 | 125 | a.go { display: inline-block; line-height: normal; } |
|
| 126 | 126 | /* A form's buttons in one aligned row: a CANCEL link the same height as |
|
| 127 | 127 | the buttons beside it. */ |
|
| 128 | - | form .actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 12px 0 0; } |
|
| 128 | + | form .actions { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; margin: 12px 0 0; } |
|
| 129 | + | form .actions > *, form.card .actions > button[type=submit] { margin: 0; } |
|
| 130 | + | form .actions > a.go { display: inline-flex; align-items: center; } |
|
| 131 | + | /* A drop's form takes the page's width, as the notice above it does. */ |
|
| 132 | + | form.card.drop-form { max-width: none; } |
|
| 129 | 133 | /* A page title with an action (+ NEW DROP): title left, action right, a |
|
| 130 | 134 | gap between them; on a phone the action wraps below with room. */ |
|
| 131 | 135 | .head.with-action { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px 20px; flex-wrap: wrap; } |
|
| … | 1264 unchanged lines |