Seals adopt anonymous changes; oxy seal command 05e71ed6cdf1 resolution
A change whose author is the zero key (Git imports, history from before an identity existed) is authenticated by a seal from any key, which policy then authorises as usual (spec §14.4). Pushes adopt such changes in the pusher's seal; oxy seal [CHANGE].. seals explicitly. Found when pushing oxygen's own first seven changes to a hub that enforces its policy.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 17:25 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
05e71ed6cdf1cd789b178c7fe119ae2b0158a8de2b31020eca30f61ac4fd6f40- Depends on
- 30ea9c1e70fa 50a046e439e9 5259a3af8ee6 7421309f5372 750251b21a96 75167672ebcd 83e0677b07fe 96f7610fa666 982864068a59 c6aa5bd9d1c8 e02bf0cd0533 fce44ae9ad29 ff0af1ee299d
- Depended on by
- c895bc52e4e0 a2387d547373 cb8b8082fa73 7ba155f20aa0 57eaac9107df d42369abf180 3e5772ccf9da d82e44097323 be0161a88dd7 76f752503e7e 7aa2dc9369e3 b0e64e81e4fb
- 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
- 36 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified crates/oxy-core/src/policy.rs +1 −1
| … | 99 unchanged lines |
||
| 100 | 100 | (0, Value::Uint(FORMAT_VERSION)), |
|
| 101 | 101 | (1, Value::Bytes(self.repo.to_vec())), |
|
| 102 | 102 | (2, Value::Uint(self.serial)), |
|
| 103 | + | ( |
|
| 103 | 104 | 3, |
|
| 104 | 105 | Value::map([ |
|
| 105 | 106 | (0, Self::keys_value(&self.roots)), |
|
| 106 | 107 | (1, Value::Uint(self.root_threshold as u64)), |
|
| 107 | 108 | ]), |
|
| 108 | 109 | ), |
|
| 109 | - | ( |
|
| 110 | 110 | ( |
|
| 111 | 111 | 4, |
|
| 112 | 112 | Value::Array( |
|
| … | 339 unchanged lines |
modified crates/oxy-core/src/repo/identity.rs +51 −1
| … | 343 unchanged lines |
||
| 344 | 344 | .store |
|
| 345 | 345 | .get_identity(&sig.key) |
|
| 346 | 346 | .map_err(|_| format!("unknown key {}", sig.key.short(12)))?; |
|
| 347 | - | let authorised = sig.key == change.author |
|
| 347 | + | // The author-or-delegate rule (spec §14.4). A change whose author is |
|
| 348 | + | // the zero key names nobody (Git imports, history from before an |
|
| 349 | + | // identity existed), so any key may adopt it with a seal; policy |
|
| 350 | + | // then authorises that key for the reference as usual. |
|
| 351 | + | let authorised = change.author.is_zero() |
|
| 352 | + | || sig.key == change.author |
|
| 348 | 353 | || self |
|
| 349 | 354 | .store |
|
| 350 | 355 | .get_identity(&change.author) |
|
| … | 58 unchanged lines |
||
| 409 | 414 | } |
|
| 410 | 415 | ||
| 411 | 416 | /// Store and sign an attestation with the configured key. |
|
| 417 | + | /// Seal changes with the identity key (spec §14.4): a stored, indexed |
|
| 418 | + | /// seal attestation over `hashes`, pushed with the next push. Returns |
|
| 419 | + | /// the attestation's reference. |
|
| 420 | + | pub fn seal(&self, hashes: &[ChangeHash]) -> Result<ObjectRef> { |
|
| 421 | + | if hashes.is_empty() { |
|
| 422 | + | return fail("nothing to seal"); |
|
| 423 | + | } |
|
| 424 | + | if hashes.len() > crate::attest::MAX_SEAL_HASHES { |
|
| 425 | + | return fail(format!("a seal covers at most {} changes", crate::attest::MAX_SEAL_HASHES)); |
|
| 426 | + | } |
|
| 427 | + | for h in hashes { |
|
| 428 | + | if !self.store.has(h) { |
|
| 429 | + | return fail(format!("unknown change {}", h.short(12))); |
|
| 430 | + | } |
|
| 431 | + | } |
|
| 432 | + | let mut sorted = hashes.to_vec(); |
|
| 433 | + | sorted.sort(); |
|
| 434 | + | sorted.dedup(); |
|
| 435 | + | let sa = self.sign_attest(&Attest::seal(self.id()?, sorted.clone()))?; |
|
| 436 | + | let r = self.store.put_attest(&sa.attest)?; |
|
| 437 | + | for sig in &sa.sigs { |
|
| 438 | + | self.store.put_signature(&r, sig)?; |
|
| 439 | + | } |
|
| 440 | + | for h in &sorted { |
|
| 441 | + | self.store.index_seal(h, &r)?; |
|
| 442 | + | } |
|
| 443 | + | Ok(r) |
|
| 444 | + | } |
|
| 445 | + | ||
| 446 | + | /// The changes of the current bookmark this identity can authenticate |
|
| 447 | + | /// but has not: its own unsigned ones and anonymous ones. |
|
| 448 | + | pub fn sealable(&self) -> Result<Vec<ChangeHash>> { |
|
| 449 | + | let Some((key, _)) = self.secret_key()? else { return fail("no identity: run oxy identity create") }; |
|
| 450 | + | let heads = self.bookmark_heads()?; |
|
| 451 | + | let state = self.load_state(&heads)?; |
|
| 452 | + | let mut out = Vec::new(); |
|
| 453 | + | for (h, meta) in state.changes() { |
|
| 454 | + | if (meta.author == key || meta.author.is_zero()) && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) { |
|
| 455 | + | out.push(*h); |
|
| 456 | + | } |
|
| 457 | + | } |
|
| 458 | + | out.sort(); |
|
| 459 | + | Ok(out) |
|
| 460 | + | } |
|
| 461 | + | ||
| 412 | 462 | pub(crate) fn sign_attest(&self, attest: &Attest) -> Result<SignedAttest> { |
|
| 413 | 463 | let Some((key, sk)) = self.secret_key()? else { |
|
| 414 | 464 | return fail("no secret key: run `oxy identity create` first"); |
|
| … | 15 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +18 −6
| … | 641 unchanged lines |
||
| 642 | 642 | /// Refuse a remote that is another repository. An **unbound** remote |
|
| 643 | 643 | /// (id all zero: a hosted repository nobody has pushed to) is accepted |
|
| 644 | 644 | /// for pushes, which bind it to this repository's id (spec §16.1). |
|
| 645 | - | fn check_remote_id(&self, remote: &dyn crate::protocol::Remote, remote_name: &str, allow_unbound: bool) -> Result<()> { |
|
| 645 | + | fn check_remote_id( |
|
| 646 | + | &self, |
|
| 647 | + | remote: &dyn crate::protocol::Remote, |
|
| 648 | + | remote_name: &str, |
|
| 649 | + | allow_unbound: bool, |
|
| 650 | + | ) -> Result<()> { |
|
| 646 | 651 | let theirs = remote.repo_id()?; |
|
| 647 | 652 | if theirs == self.id()? { |
|
| 648 | 653 | return Ok(()); |
|
| … | 2 unchanged lines |
||
| 651 | 656 | if allow_unbound { |
|
| 652 | 657 | return Ok(()); |
|
| 653 | 658 | } |
|
| 654 | - | return fail(format!("remote {remote_name:?} is empty and unbound: push to it first")); |
|
| 659 | + | return fail(format!( |
|
| 660 | + | "remote {remote_name:?} is empty and unbound: push to it first" |
|
| 661 | + | )); |
|
| 655 | 662 | } |
|
| 656 | - | fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)")) |
|
| 663 | + | fail(format!( |
|
| 664 | + | "remote {remote_name:?} is a different repository (repository IDs differ)" |
|
| 665 | + | )) |
|
| 657 | 666 | } |
|
| 658 | 667 | ||
| 659 | 668 | fn remote_options(&self, name: &str) -> RemoteOptions { |
|
| … | 527 unchanged lines |
||
| 1187 | 1196 | let mut bundle = self.bundle_for(&to_send)?; |
|
| 1188 | 1197 | let key = self.secret_key()?.map(|(k, _)| k); |
|
| 1189 | 1198 | if let Some(key) = key { |
|
| 1190 | - | // Seal this author's changes that carry no valid authenticator. |
|
| 1199 | + | // Seal this author's changes that carry no valid authenticator, |
|
| 1200 | + | // and adopt anonymous ones (zero author, spec §14.4). |
|
| 1191 | 1201 | let mut unsigned = Vec::new(); |
|
| 1192 | 1202 | for h in &to_send { |
|
| 1193 | 1203 | let c = self.store.get_change(h)?; |
|
| 1194 | - | if c.author == key |
|
| 1204 | + | if (c.author == key || c.author.is_zero()) |
|
| 1195 | 1205 | && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) |
|
| 1196 | 1206 | { |
|
| 1197 | 1207 | unsigned.push(*h); |
|
| … | 131 unchanged lines |
||
| 1329 | 1339 | let remote = open_remote(url, &options)?; |
|
| 1330 | 1340 | let id = remote.repo_id()?; |
|
| 1331 | 1341 | if id == crate::protocol::UNBOUND_ID { |
|
| 1332 | - | return fail(format!("{url} is empty and unbound: push a repository to it first")); |
|
| 1342 | + | return fail(format!( |
|
| 1343 | + | "{url} is empty and unbound: push a repository to it first" |
|
| 1344 | + | )); |
|
| 1333 | 1345 | } |
|
| 1334 | 1346 | let mut repo = Repo::init_with_id(dest, id)?; |
|
| 1335 | 1347 | repo.config.set("remote.origin.url", url)?; |
|
| … | 32 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +15 −5
| … | 376 unchanged lines |
||
| 377 | 377 | } |
|
| 378 | 378 | ); |
|
| 379 | 379 | ||
| 380 | - | // A signature by a key that is not the author or a delegate is rejected. |
|
| 380 | + | // A signature by a key that is not the author or a delegate is rejected |
|
| 381 | + | // when the change names an author; an anonymous change (made before any |
|
| 382 | + | // identity, author zero) is adopted by whoever signs it (spec §14.4). |
|
| 381 | 383 | let other = SecretKey::generate(); |
|
| 382 | 384 | let other_id = other.identity("Mallory", "[email protected]", 0); |
|
| 383 | 385 | let other_key = repo.store().put_identity(&other_id).unwrap(); |
|
| 384 | - | let forged = other.sign(Domain::Change, &other_key, &h0); |
|
| 385 | - | repo.store().put_signature(&h0, &forged).unwrap(); |
|
| 386 | + | write(root, "a.txt", b"a\nb\nc\nd\n"); |
|
| 387 | + | repo.config.set("core.sign", "seal").unwrap(); |
|
| 388 | + | let h3 = repo.commit("ada's, unsigned", &[]).unwrap(); |
|
| 389 | + | let forged = other.sign(Domain::Change, &other_key, &h3); |
|
| 390 | + | repo.store().put_signature(&h3, &forged).unwrap(); |
|
| 386 | 391 | assert!( |
|
| 387 | - | matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author")) |
|
| 392 | + | matches!(repo.verify_change(&h3).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author")) |
|
| 388 | 393 | ); |
|
| 394 | + | let adopted = other.sign(Domain::Change, &other_key, &h0); |
|
| 395 | + | repo.store().put_signature(&h0, &adopted).unwrap(); |
|
| 396 | + | assert!( |
|
| 397 | + | matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Valid { name, .. } if name == "Mallory") |
|
| 398 | + | ); |
|
| 389 | 399 | ||
| 390 | 400 | // Tampering with the stored signature invalidates it. |
|
| 391 | 401 | let mut sig = repo.store().signatures(&h2).unwrap().remove(0); |
|
| … | 1477 unchanged lines |
||
| 1869 | 1879 | !a.snapshot("status").unwrap().changed, |
|
| 1870 | 1880 | "out-of-scope files are not removals" |
|
| 1871 | 1881 | ); |
|
| 1872 | - | <<<<<<< oxy conflict 1 of 1, side 1 of 2: deleted by change aa56fdfb "Unbound hosted repositories adopt the first pusher's id" |
|
| 1882 | + | assert!(!a.quick_status().unwrap().dirty); |
|
| 1873 | 1883 | ------- |
|
| 1874 | 1884 | assert!(!a.snapshot("status").unwrap().changed, "out-of-scope files are not removals"); |
|
| 1875 | 1885 | ======= oxy conflict 1 of 1, side 2 of 2: change 3d12c43d "Test: assert! instead of assert_eq! on a bool" |
|
| … | 2000 unchanged lines |
modified crates/oxy-server/src/lib.rs +13 −3
| … | 202 unchanged lines |
||
| 203 | 203 | .and_then(hex_id); |
|
| 204 | 204 | let server = Arc::clone(self); |
|
| 205 | 205 | let result = |
|
| 206 | - | tokio::task::spawn_blocking(move || server.respond(&name, endpoint, body, claimed)).await; |
|
| 206 | + | tokio::task::spawn_blocking(move || server.respond(&name, endpoint, body, claimed)) |
|
| 207 | + | .await; |
|
| 207 | 208 | match result { |
|
| 208 | 209 | Ok(Ok(r)) => r, |
|
| 209 | 210 | Ok(Err(f)) => f.into_response(), |
|
| … | 1 unchanged lines |
||
| 211 | 212 | } |
|
| 212 | 213 | } |
|
| 213 | 214 | ||
| 214 | - | fn respond(&self, name: &str, endpoint: Endpoint, body: Bytes, claimed: Option<[u8; 16]>) -> Result<Response, Failure> { |
|
| 215 | + | fn respond( |
|
| 216 | + | &self, |
|
| 217 | + | name: &str, |
|
| 218 | + | endpoint: Endpoint, |
|
| 219 | + | body: Bytes, |
|
| 220 | + | claimed: Option<[u8; 16]>, |
|
| 221 | + | ) -> Result<Response, Failure> { |
|
| 215 | 222 | let r = self.backend.open(name)?; |
|
| 216 | 223 | Ok(match endpoint { |
|
| 217 | 224 | Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)), |
|
| … | 37 unchanged lines |
||
| 255 | 262 | None => { |
|
| 256 | 263 | return Err(Failure( |
|
| 257 | 264 | StatusCode::BAD_REQUEST, |
|
| 258 | - | format!("this repository is unbound; push with a client that sends {}", oxy_core::protocol::REPO_ID_HEADER), |
|
| 265 | + | format!( |
|
| 266 | + | "this repository is unbound; push with a client that sends {}", |
|
| 267 | + | oxy_core::protocol::REPO_ID_HEADER |
|
| 268 | + | ), |
|
| 259 | 269 | )) |
|
| 260 | 270 | } |
|
| 261 | 271 | } |
|
| … | 116 unchanged lines |
modified crates/oxy-server/tests/http.rs +69 −3
| … | 484 unchanged lines |
||
| 485 | 485 | assert_eq!(hosted.id().unwrap(), [0; 16]); |
|
| 486 | 486 | let base = start(&served, vec![]); |
|
| 487 | 487 | let url = format!("{base}/lib"); |
|
| 488 | - | assert!(Repo::clone(&url, &root.join("early")).err().unwrap().to_string().contains("unbound")); |
|
| 488 | + | assert!(Repo::clone(&url, &root.join("early")) |
|
| 489 | + | .err() |
|
| 490 | + | .unwrap() |
|
| 491 | + | .to_string() |
|
| 492 | + | .contains("unbound")); |
|
| 489 | 493 | ||
| 490 | 494 | // An existing repository with its own id and history. |
|
| 491 | 495 | let work = root.join("work"); |
|
| … | 4 unchanged lines |
||
| 496 | 500 | local.commit("a", &[]).unwrap(); |
|
| 497 | 501 | local.config.set("remote.origin.url", &url).unwrap(); |
|
| 498 | 502 | local.save_config().unwrap(); |
|
| 499 | - | assert!(local.fetch("origin").err().unwrap().to_string().contains("unbound"), "fetching an unbound remote is refused"); |
|
| 503 | + | assert!( |
|
| 504 | + | local |
|
| 505 | + | .fetch("origin") |
|
| 506 | + | .err() |
|
| 507 | + | .unwrap() |
|
| 508 | + | .to_string() |
|
| 509 | + | .contains("unbound"), |
|
| 510 | + | "fetching an unbound remote is refused" |
|
| 511 | + | ); |
|
| 500 | 512 | let results = local.push("origin", false).unwrap(); |
|
| 501 | 513 | assert!(results.iter().all(|r| r.ok), "{results:?}"); |
|
| 502 | 514 | ||
| … | 10 unchanged lines |
||
| 513 | 525 | write(&root.join("other"), "b.txt", b"b\n"); |
|
| 514 | 526 | other.snapshot("test").unwrap(); |
|
| 515 | 527 | other.commit("b", &[]).unwrap(); |
|
| 516 | - | assert!(other.push("origin", false).err().unwrap().to_string().contains("different repository")); |
|
| 528 | + | assert!(other |
|
| 529 | + | .push("origin", false) |
|
| 530 | + | .err() |
|
| 531 | + | .unwrap() |
|
| 532 | + | .to_string() |
|
| 533 | + | .contains("different repository")); |
|
| 534 | + | } |
|
| 535 | + | ||
| 536 | + | /// History made before an identity existed (zero author) is adopted by |
|
| 537 | + | /// the pusher's seal, so it passes a server that enforces a policy. |
|
| 538 | + | #[test] |
|
| 539 | + | fn anonymous_history_is_adopted_by_the_pushers_seal() { |
|
| 540 | + | hermetic(); |
|
| 541 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 542 | + | let root = dir.path(); |
|
| 543 | + | let served = root.join("served"); |
|
| 544 | + | Repo::init_bare_unbound(&served.join("app")).unwrap(); |
|
| 545 | + | let base = start(&served, vec![]); |
|
| 546 | + | let url = format!("{base}/app"); |
|
| 547 | + | ||
| 548 | + | // Two anonymous changes, then an identity and a policy naming it. |
|
| 549 | + | let work = root.join("work"); |
|
| 550 | + | let mut local = Repo::init(&work).unwrap(); |
|
| 551 | + | write(&work, "a.txt", b"a\n"); |
|
| 552 | + | local.snapshot("test").unwrap(); |
|
| 553 | + | let anon = local.commit("before identity", &[]).unwrap(); |
|
| 554 | + | local.create_identity("Andy", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap(); |
|
| 555 | + | local.policy_init().unwrap(); |
|
| 556 | + | write(&work, "b.txt", b"b\n"); |
|
| 557 | + | local.snapshot("test").unwrap(); |
|
| 558 | + | let mine = local.commit("after identity", &[]).unwrap(); |
|
| 559 | + | assert!(matches!(local.authenticate_change(&anon).unwrap(), SignatureStatus::Unsigned)); |
|
| 560 | + | local.config.set("remote.origin.url", &url).unwrap(); |
|
| 561 | + | local.save_config().unwrap(); |
|
| 562 | + | let results = local.push("origin", false).unwrap(); |
|
| 563 | + | assert!(results.iter().all(|r| r.ok), "{results:?}"); |
|
| 564 | + | ||
| 565 | + | // The server holds the policy and both changes verify: sealed by the adopter. |
|
| 566 | + | let hosted = Repo::open(&served.join("app")).unwrap(); |
|
| 567 | + | assert!(hosted.current_policy().unwrap().is_some()); |
|
| 568 | + | for h in [anon, mine] { |
|
| 569 | + | assert!(matches!(hosted.authenticate_change(&h).unwrap(), SignatureStatus::Valid { sealed: true, name, .. } if name == "Andy"), "{h}"); |
|
| 570 | + | } |
|
| 571 | + | // A change that names another author still cannot be sealed by someone else. |
|
| 572 | + | let stranger = root.join("stranger"); |
|
| 573 | + | let mut other = Repo::init(&stranger).unwrap(); |
|
| 574 | + | other.create_identity("Mallory", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap(); |
|
| 575 | + | write(&stranger, "c.txt", b"c\n"); |
|
| 576 | + | other.snapshot("test").unwrap(); |
|
| 577 | + | let theirs = other.commit("mallory's", &[]).unwrap(); |
|
| 578 | + | assert!(matches!(other.authenticate_change(&theirs).unwrap(), SignatureStatus::Unsigned)); |
|
| 579 | + | let bundle = other.bundle_for(&[theirs]).unwrap(); |
|
| 580 | + | local.import_bundle(&bundle, false).unwrap(); |
|
| 581 | + | local.seal(&[theirs]).unwrap(); |
|
| 582 | + | assert!(matches!(local.authenticate_change(&theirs).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author nor a delegate"))); |
|
| 517 | 583 | } |
modified docs/plan.md +1 −0
| … | 82 unchanged lines |
||
| 83 | 83 | ||
| 84 | 84 | - [x] Bare repositories: `Repo::init_bare`, `is_bare`, `oxy init --bare`; `working_copy` refuses; `log`, `log_for_path` work from the bookmark state; `log_state`, `blame_in`, `log_for_path_in` for any state. |
|
| 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 | + | - [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. |
|
| 86 | 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). |
|
| 87 | 88 | - [ ] `oxy identity sign-challenge` and `verify_challenge` (hub 0.2). |
|
| 88 | 89 | - [ ] `oxy seal <hash>..` and `--proposal URL` (hub 0.3). |
|
| … | 4 unchanged lines |
modified docs/spec.md +4 −3
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.8d | |
|
| 5 | + | | **Status** | Draft 0.8e | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 616 unchanged lines |
||
| 625 | 625 | ||
| 626 | 626 | **Cadence.** With the default `core.sign = "seal"`, changes are left unsigned locally and `oxy push` creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With `core.sign = "change"`, every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts. |
|
| 627 | 627 | ||
| 628 | - | **Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate (§14.2). The same author-or-delegate rule applies to individual change signatures. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A seal's signer must additionally be authorized by policy for the reference being pushed. |
|
| 628 | + | **Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate (§14.2). The same author-or-delegate rule applies to individual change signatures. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal. A seal's signer must additionally be authorized by policy for the reference being pushed. |
|
| 629 | 629 | ||
| 630 | 630 | **Handling.** A seal is stored and transported with the changes it covers: `fetch` (§16.1) streams the seals covering every change it returns, `push` (§16.4) MUST include a seal or signature for every change, and a seal covering any reachable change is itself reachable (§21). Seals from different keys MAY overlap; a change needs at least one valid authenticator, and policy thresholds count distinct authenticating keys. |
|
| 631 | 631 | ||
| … | 199 unchanged lines |
||
| 831 | 831 | oxy remote add|remove|list oxy serve oxy lock|unlock PATH |
|
| 832 | 832 | oxy identity create|show|export|rotate|renew|revoke|delegate |
|
| 833 | 833 | oxy policy init|show|grant|revoke|root|sign oxy verify [CHANGE] |
|
| 834 | + | oxy seal [CHANGE].. seal your unauthenticated and anonymous changes (§14.4) |
|
| 834 | 835 | oxy git import|export|fetch|push (fetch and push go through a bare mirror under .oxy/git/) |
|
| 835 | 836 | oxy checkpoint oxy gc [--now] oxy config [--user] KEY VALUE |
|
| 836 | 837 | oxy root oxy cat PATH [-r CHANGE] oxy version |
|
| … | 78 unchanged lines |
||
| 915 | 916 | ||
| 916 | 917 | --- |
|
| 917 | 918 | ||
| 918 | - | *Changelog.* 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. |
|
| 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. |
|
| 919 | 920 | ||
| 920 | 921 | 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). |
|
| 921 | 922 | ||
| … | 59 unchanged lines |
modified src/cli/identity.rs +34 −0
| … | 672 unchanged lines |
||
| 673 | 673 | report_update(&u, out)?; |
|
| 674 | 674 | Ok(0) |
|
| 675 | 675 | } |
|
| 676 | + | ||
| 677 | + | /// `oxy seal [CHANGE..]`: seal the named changes, or every sealable one. |
|
| 678 | + | pub fn seal(specs: &[String], as_json: bool) -> Result<i32> { |
|
| 679 | + | let repo = Repo::open(&std::env::current_dir()?)?; |
|
| 680 | + | let hashes: Vec<_> = if specs.is_empty() { |
|
| 681 | + | repo.sealable()? |
|
| 682 | + | } else { |
|
| 683 | + | specs.iter().map(|s| repo.resolve_change(s)).collect::<std::result::Result<_, _>>()? |
|
| 684 | + | }; |
|
| 685 | + | if hashes.is_empty() { |
|
| 686 | + | if as_json { |
|
| 687 | + | println!("{{\"sealed\":[]}}"); |
|
| 688 | + | } else { |
|
| 689 | + | println!("{} nothing to seal: every change you can authenticate already is", ui::done("Sealed")); |
|
| 690 | + | } |
|
| 691 | + | return Ok(0); |
|
| 692 | + | } |
|
| 693 | + | let mut refs = Vec::new(); |
|
| 694 | + | for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) { |
|
| 695 | + | refs.push(repo.seal(chunk)?); |
|
| 696 | + | } |
|
| 697 | + | if as_json { |
|
| 698 | + | let list: Vec<String> = hashes.iter().map(|h| format!("\"{}\"", h.to_hex())).collect(); |
|
| 699 | + | let seals: Vec<String> = refs.iter().map(|r| format!("\"{}\"", r.to_hex())).collect(); |
|
| 700 | + | println!("{{\"sealed\":[{}],\"seals\":[{}]}}", list.join(","), seals.join(",")); |
|
| 701 | + | } else { |
|
| 702 | + | println!("{} {} change{} in {} seal{}; they travel with your next push", ui::done("Sealed"), hashes.len(), if hashes.len() == 1 { "" } else { "s" }, refs.len(), if refs.len() == 1 { "" } else { "s" }); |
|
| 703 | + | for h in &hashes { |
|
| 704 | + | let title = repo.store().get_change(h).map(|c| c.message.lines().next().unwrap_or("").to_string()).unwrap_or_default(); |
|
| 705 | + | println!(" {} {}", ui::hash(h.short(12)), title); |
|
| 706 | + | } |
|
| 707 | + | } |
|
| 708 | + | Ok(0) |
|
| 709 | + | } |
modified src/cli/mod.rs +7 −0
| … | 294 unchanged lines |
||
| 295 | 295 | #[command(subcommand)] |
|
| 296 | 296 | cmd: IdentityCmd, |
|
| 297 | 297 | }, |
|
| 298 | + | /// Seal changes with your key: named ones, or every change on the current bookmark you can authenticate but have not |
|
| 299 | + | Seal { |
|
| 300 | + | changes: Vec<String>, |
|
| 301 | + | #[arg(long)] |
|
| 302 | + | json: bool, |
|
| 303 | + | }, |
|
| 298 | 304 | /// Verify the signatures on a change (default: every change in the log) |
|
| 299 | 305 | Verify { |
|
| 300 | 306 | change: Option<String>, |
|
| … | 460 unchanged lines |
||
| 761 | 767 | }, |
|
| 762 | 768 | PolicyCmd::Sign { proposal, out } => identity::policy_sign(&proposal, out), |
|
| 763 | 769 | }, |
|
| 770 | + | Cmd::Seal { changes, json } => identity::seal(&changes, json), |
|
| 764 | 771 | Cmd::Verify { change, json } => identity::verify(change.as_deref(), json), |
|
| 765 | 772 | Cmd::Config { key, value, user } => identity::config(&key, &value, user), |
|
| 766 | 773 | } |
|
| … | 1 unchanged lines |