OXYGENHUB

Challenge signatures and a push report for hosts 7aa2dc9369e3

oxy identity sign-challenge proves possession of a key to a hub (spec §14.3,
Draft 0.8f). receive_push_report returns each accepted reference's old and
new state and heads plus the changes stored; Backend::after_push gets it.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 17:54 UTC
Signature
✔ signed by Andy McCall
Hash
7aa2dc9369e35c07efc32eacec61613a931b157476a074df7c99ff639953a87e
Depends on
05e71ed6cdf1 1983686fb7a7 28fc23d7706a 50a046e439e9 750251b21a96 75167672ebcd 83e0677b07fe 96f7610fa666 982864068a59 e02bf0cd0533 fce44ae9ad29 ff0af1ee299d
Depended on by
767ac6cd0545 02e691881a4e 8e9c64d16ded cb8b8082fa73 2c9ddac3c387 ef26f9a28790 f8c695764c95 d42369abf180 eae595568832 84b9e9ac0ab4 76f752503e7e 119bbf50cb86
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
31 atoms, 0 tree operations, 9 files

Browse the tree at this change

modified crates/oxy-core/src/identity.rs +57 −0
…
150 unchanged lines
151 151
    Change,
152 152
    Attest,
153 153
    Policy,
154 +
    /// A server-issued challenge, proving possession of a key to a host
155 +
    /// (hub spec §5.3, §5.4). Never a stored object.
156 +
    Challenge,
154 157
}
155 158
156 159
impl Domain {
…
2 unchanged lines
159 162
            Domain::Change => "change",
160 163
            Domain::Attest => "attest",
161 164
            Domain::Policy => "policy",
165 +
            Domain::Challenge => "challenge",
162 166
        }
163 167
    }
164 168
}
…
74 unchanged lines
239 243
    Ed25519,
240 244
    #[error("ML-DSA-65 signature invalid")]
241 245
    MlDsa,
246 +
    #[error("a challenge is at least 16 bytes")]
247 +
    ShortChallenge,
242 248
}
243 249
244 250
/// Verify a composite signature by `identity` over `target` (spec §14.3).
…
231 unchanged lines
476 482
        assert_ne!(SecretKey::generate().ed_seed, SecretKey::generate().ed_seed);
477 483
    }
478 484
}
485 +
486 +
/// The signing target of a challenge: `BLAKE3-256("oxy1 challenge " || bytes)`,
487 +
/// so a challenge can never collide with a stored object's reference.
488 +
pub fn challenge_target(challenge: &[u8]) -> ObjectRef {
489 +
    let mut h = blake3::Hasher::new();
490 +
    h.update(b"oxy1 challenge ");
491 +
    h.update(challenge);
492 +
    ObjectRef(*h.finalize().as_bytes())
493 +
}
494 +
495 +
impl SecretKey {
496 +
    /// Sign a host's challenge (at least 16 bytes) with this key.
497 +
    pub fn sign_challenge(&self, key: &KeyId, challenge: &[u8]) -> Result<Signature, VerifyError> {
498 +
        if challenge.len() < 16 {
499 +
            return Err(VerifyError::ShortChallenge);
500 +
        }
501 +
        Ok(self.sign(Domain::Challenge, key, &challenge_target(challenge)))
502 +
    }
503 +
}
504 +
505 +
/// Check a challenge signature against the identity that claims it.
506 +
pub fn verify_challenge(identity: &Identity, sig: &Signature, challenge: &[u8]) -> Result<(), VerifyError> {
507 +
    if challenge.len() < 16 {
508 +
        return Err(VerifyError::ShortChallenge);
509 +
    }
510 +
    if sig.key != identity.hash() {
511 +
        return Err(VerifyError::KeyMismatch);
512 +
    }
513 +
    verify(identity, sig, Domain::Challenge, &challenge_target(challenge))
514 +
}
515 +
516 +
#[cfg(test)]
517 +
mod challenge_tests {
518 +
    use super::*;
519 +
520 +
    #[test]
521 +
    fn a_challenge_signature_binds_key_and_bytes() {
522 +
        let sk = SecretKey::generate();
523 +
        let id = sk.identity("A", "a@example", 1);
524 +
        let key = id.hash();
525 +
        let challenge = [7u8; 32];
526 +
        let sig = sk.sign_challenge(&key, &challenge).unwrap();
527 +
        assert!(verify_challenge(&id, &sig, &challenge).is_ok());
528 +
        assert!(verify_challenge(&id, &sig, &[8u8; 32]).is_err(), "other bytes");
529 +
        let other = SecretKey::generate().identity("B", "b@example", 1);
530 +
        assert!(verify_challenge(&other, &sig, &challenge).is_err(), "other identity");
531 +
        assert!(sk.sign_challenge(&key, b"short").is_err());
532 +
        // A challenge target never equals a change signature's target for the same bytes.
533 +
        assert_ne!(challenge_target(&challenge), ObjectRef(challenge));
534 +
    }
535 +
}
modified crates/oxy-core/src/repo/mod.rs +1 −1
…
93 unchanged lines
94 94
    Selection, SnapshotResult,
95 95
};
96 96
pub use subrepos::{SubrepoInfo, SubrepoSync};
97 -
pub use sync::{FetchReport, PullReport};
97 +
pub use sync::{FetchReport, PullReport, PushReport, RefUpdated};
98 98
pub use tags::TagInfo;
99 99
pub use workspace::{SnapshotLock, WorkspaceInfo};
100 100
…
1121 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +48 −11
…
1 unchanged lines
2 2
3 3
use super::*;
4 4
5 +
/// What a push did, for hosts (hub spec §6.12).
6 +
#[derive(Clone, Debug, Default)]
7 +
pub struct PushReport {
8 +
    pub results: Vec<RefResult>,
9 +
    /// Accepted reference updates, in request order.
10 +
    pub updated: Vec<RefUpdated>,
11 +
    /// Changes this push stored that were not already present.
12 +
    pub imported: Vec<ChangeHash>,
13 +
}
14 +
15 +
#[derive(Clone, Debug)]
16 +
pub struct RefUpdated {
17 +
    pub name: String,
18 +
    pub old_state: ObjectRef,
19 +
    pub new_state: ObjectRef,
20 +
    pub heads: Vec<ChangeHash>,
21 +
}
22 +
5 23
/// What a fetch brought in.
6 24
#[derive(Debug, Clone, PartialEq, Eq, Default)]
7 25
pub struct FetchReport {
…
440 unchanged lines
448 466
        policy: Option<SignedPolicy>,
449 467
        tags: Vec<crate::protocol::TagRef>,
450 468
    ) -> Result<Vec<RefResult>> {
451 -
        let mut results = Vec::new();
469 +
        Ok(self.receive_push_report(bundle, updates, policy, tags)?.results)
470 +
    }
471 +
472 +
    /// `receive_push` with what a host wants to log: each accepted
473 +
    /// reference's old and new state and heads, and the changes this push
474 +
    /// stored (hub spec §6.12).
475 +
    pub fn receive_push_report(
476 +
        &self,
477 +
        bundle: Bundle,
478 +
        updates: Vec<RefUpdate>,
479 +
        policy: Option<SignedPolicy>,
480 +
        tags: Vec<crate::protocol::TagRef>,
481 +
    ) -> Result<PushReport> {
482 +
        let mut report = PushReport::default();
483 +
        let results = &mut report.results;
452 484
        if let Some(sp) = policy {
453 485
            match self.adopt_policy_chain(&bundle.policies, &sp, "push received") {
454 486
                Ok(()) => {
…
16 unchanged lines
471 503
                            reason: "policy update was rejected".into(),
472 504
                        });
473 505
                    }
474 -
                    return Ok(results);
506 +
                    return Ok(report);
475 507
                }
476 508
            }
477 509
        }
478 510
        let policy = self.current_policy()?.map(|(_, sp)| sp.policy);
479 -
        if let Err(e) = self.import_bundle(&bundle, policy.is_some()) {
480 -
            for u in updates {
481 -
                results.push(RefResult {
482 -
                    name: u.name,
483 -
                    ok: false,
484 -
                    reason: e.to_string(),
485 -
                });
511 +
        match self.import_bundle(&bundle, policy.is_some()) {
512 +
            Ok(imported) => report.imported = imported,
513 +
            Err(e) => {
514 +
                for u in updates {
515 +
                    results.push(RefResult {
516 +
                        name: u.name,
517 +
                        ok: false,
518 +
                        reason: e.to_string(),
519 +
                    });
520 +
                }
521 +
                return Ok(report);
486 522
            }
487 -
            return Ok(results);
488 523
        }
489 524
        let (op_ref, op) = self.head_op()?;
490 525
        let mut view = op.view.clone();
…
6 unchanged lines
497 532
            };
498 533
            if ok {
499 534
                names.push(u.name.clone());
535 +
                let heads = self.store.get_state(&u.new_state).map(|s| s.heads).unwrap_or_default();
536 +
                report.updated.push(RefUpdated { name: u.name.clone(), old_state: u.old_state, new_state: u.new_state, heads });
500 537
            }
501 538
            results.push(RefResult {
502 539
                name: u.name,
…
26 unchanged lines
529 566
                view,
530 567
            )?;
531 568
        }
532 -
        Ok(results)
569 +
        Ok(report)
533 570
    }
534 571
535 572
    fn apply_update(
…
844 unchanged lines
modified crates/oxy-server/src/lib.rs +6 −5
…
59 unchanged lines
60 60
    fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure>;
61 61
62 62
    /// Called after a push was processed, with its per-reference results
63 -
    /// (accepted or refused), for logs and cache invalidation.
64 -
    fn after_push(&self, _name: &str, _results: &[oxy_core::protocol::RefResult]) {}
63 +
    /// (accepted or refused), the states each accepted reference moved
64 +
    /// between, and the changes stored, for logs and cache invalidation.
65 +
    fn after_push(&self, _name: &str, _report: &oxy_core::repo::PushReport) {}
65 66
}
66 67
67 68
/// A protocol server over any [`Backend`], serialising the pushes to each
…
202 unchanged lines
270 271
                        }
271 272
                    }
272 273
                }
273 -
                let results = r.receive_push(bundle, updates, policy, tags)?;
274 -
                self.backend.after_push(name, &results);
275 -
                ok(wire::encode_push_response(&results))
274 +
                let report = r.receive_push_report(bundle, updates, policy, tags)?;
275 +
                self.backend.after_push(name, &report);
276 +
                ok(wire::encode_push_response(&report.results))
276 277
            }
277 278
        })
278 279
    }
…
109 unchanged lines
modified crates/oxy-server/tests/http.rs +7 −4
…
391 unchanged lines
392 392
            }
393 393
            Ok(())
394 394
        }
395 -
        fn after_push(&self, name: &str, results: &[oxy_core::protocol::RefResult]) {
395 +
        fn after_push(&self, name: &str, report: &oxy_core::repo::PushReport) {
396 396
            self.pushes.lock().unwrap().push(format!(
397 -
                "{name}: {}",
398 -
                results.iter().filter(|r| r.ok).count()
397 +
                "{name}: {} ok, {} updated to {} head(s), {} imported",
398 +
                report.results.iter().filter(|r| r.ok).count(),
399 +
                report.updated.len(),
400 +
                report.updated.first().map(|u| u.heads.len()).unwrap_or(0),
401 +
                report.imported.len()
399 402
            ));
400 403
        }
401 404
    }
…
64 unchanged lines
466 469
    client.config.set("remote.origin.token", "s3cret").unwrap();
467 470
    let results = client.push("origin", false).unwrap();
468 471
    assert!(results.iter().all(|r| r.ok), "{results:?}");
469 -
    assert_eq!(pushes.lock().unwrap().as_slice(), ["andy/app: 1"]);
472 +
    assert_eq!(pushes.lock().unwrap().as_slice(), ["andy/app: 1 ok, 1 updated to 1 head(s), 1 imported"]);
470 473
471 474
    let hosted = Repo::open(&root.join("repos/andy/app")).unwrap();
472 475
    assert_eq!(hosted.bookmark_heads().unwrap().len(), 1);
…
111 unchanged lines
modified docs/plan.md +2 −2
…
84 unchanged lines
85 85
- [x] Embeddable server: `oxy_server::{Backend, Server, Endpoint, Access, Failure, DirBackend, bearer_token}`; `router` is `DirBackend` on `/{repo}/oxy/v1/{endpoint}`; `after_push` hook.
86 86
- [x] Anonymous (zero-author) changes are adopted by the pusher's seal (spec §14.4), found when pushing oxygen's own pre-identity history to the hub.
87 87
- [x] Unbound hosted repositories (`Repo::init_bare_unbound`, `bind_id`, `Oxy-Repo-Id` header): an existing repository pushed to an empty hosted one keeps its id (protocol.md §2).
88 -
- [ ] `oxy identity sign-challenge` and `verify_challenge` (hub 0.2).
88 +
- [x] `oxy identity sign-challenge` and `identity::{sign_challenge, verify_challenge, challenge_target}` (hub 0.2).
89 89
- [ ] `oxy seal <hash>..` and `--proposal URL` (hub 0.3).
90 90
- [ ] `oxy proposal show|land URL` (hub 0.3).
91 91
- [ ] Delegate-signed changes from an edit set without a workspace (hub 0.6).
92 92
- [ ] `Repo::log_window` and a shared `graph::layout` (hub 0.1 uses `log_state` and its own layout until then).
93 -
- [ ] `receive_push` reporting old/new state and new changes per reference (hub 0.2 pushes log).
93 +
- [x] `receive_push_report` with `PushReport { results, updated: [RefUpdated { name, old_state, new_state, heads }], imported }`; `Backend::after_push` receives it.
modified docs/spec.md +5 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8e |
5 +
| **Status** | Draft 0.8f |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
594 unchanged lines
603 603
604 604
Profiles that cannot compute ML-DSA (§22) do not sign; they produce changes that a proxy signs.
605 605
606 +
**Challenges.** A host that must know a client holds a key (linking a key to an account, signing in to a server) sends at least 16 random bytes; the client returns a composite signature in domain `challenge` over `BLAKE3-256("oxy1 challenge " || bytes)`, which can never equal a stored object's reference, so a challenge signature authenticates nothing but the challenge. `oxy identity sign-challenge HEX` prints it.
607 +
606 608
### 14.4 Attestations
607 609
608 610
```
…
220 unchanged lines
829 831
oxy workspace add|list|forget       oxy sparse [PATH]..          oxy deepen [N]
830 832
oxy fetch [REMOTE]                  oxy pull [REMOTE]            oxy push [REMOTE] [--force]
831 833
oxy remote add|remove|list          oxy serve                    oxy lock|unlock PATH
832 -
oxy identity create|show|export|rotate|renew|revoke|delegate
834 +
oxy identity create|show|export|rotate|renew|revoke|delegate|sign-challenge
833 835
oxy policy init|show|grant|revoke|root|sign                      oxy verify [CHANGE]
834 836
oxy seal [CHANGE]..                 seal your unauthenticated and anonymous changes (§14.4)
835 837
oxy git import|export|fetch|push   (fetch and push go through a bare mirror under .oxy/git/)
…
80 unchanged lines
916 918
917 919
---
918 920
919 -
*Changelog.* Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
921 +
*Changelog.* Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
920 922
921 923
Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).
922 924
…
59 unchanged lines
modified src/cli/identity.rs +29 −0
…
706 unchanged lines
707 707
    }
708 708
    Ok(0)
709 709
}
710 +
711 +
/// `oxy identity sign-challenge HEX`: the composite signature over a
712 +
/// host's challenge, as hex of the CBOR signature (hub spec §5.3).
713 +
pub fn sign_challenge(challenge_hex: &str, as_json: bool) -> Result<i32> {
714 +
    let repo = open()?;
715 +
    let Some((key, sk)) = repo.secret_key()? else {
716 +
        anyhow::bail!("no secret key: run `oxy identity create` first");
717 +
    };
718 +
    let challenge = hex_bytes(challenge_hex.trim()).ok_or_else(|| anyhow::anyhow!("the challenge must be hex"))?;
719 +
    let sig = sk.sign_challenge(&key, &challenge).map_err(|e| anyhow::anyhow!("{e}"))?;
720 +
    let encoded = hex_string(&oxy_core::identity::Signature::encode_list(&[sig]));
721 +
    if as_json {
722 +
        println!("{{\"key\":\"{}\",\"signature\":\"{}\"}}", key.to_hex(), encoded);
723 +
    } else {
724 +
        println!("{encoded}");
725 +
    }
726 +
    Ok(0)
727 +
}
728 +
729 +
fn hex_bytes(s: &str) -> Option<Vec<u8>> {
730 +
    if !s.len().is_multiple_of(2) {
731 +
        return None;
732 +
    }
733 +
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
734 +
}
735 +
736 +
fn hex_string(b: &[u8]) -> String {
737 +
    b.iter().map(|x| format!("{x:02x}")).collect()
738 +
}
modified src/cli/mod.rs +7 −0
…
467 unchanged lines
468 468
    Show,
469 469
    /// Write your identity object to a file, to hand to a policy holder
470 470
    Export { path: Option<PathBuf> },
471 +
    /// Sign a host's challenge (hex, at least 16 bytes) to prove you hold your key; prints the signature as hex
472 +
    SignChallenge {
473 +
        challenge: String,
474 +
        #[arg(long)]
475 +
        json: bool,
476 +
    },
471 477
    /// Move to a fresh key pair; the policy follows if it names your key
472 478
    Rotate,
473 479
    /// Re-issue your identity with a new expiry (in days from now; 0 = never)
…
249 unchanged lines
723 729
            } => identity::create(name, email, local, expires),
724 730
            IdentityCmd::Show => identity::show(),
725 731
            IdentityCmd::Export { path } => identity::export(path),
732 +
            IdentityCmd::SignChallenge { challenge, json } => identity::sign_challenge(&challenge, json),
726 733
            IdentityCmd::Rotate => identity::rotate(),
727 734
            IdentityCmd::Renew { expires } => identity::renew(expires),
728 735
            IdentityCmd::Revoke { key, reason } => identity::revoke(key.as_deref(), &reason),
…
46 unchanged lines