OXYGENHUB

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&#39;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