Fetch: carry attestation signers' identities; skip an unverifiable bookmark d36ba2f79f95
A fetch bundle now carries the identities and certificates (and their issuers' identities) of the keys that signed every bookmark's latest and every tag's attestation, also when the fetch names no change. A delegate or a short-lived key that moved a bookmark and signed no change left every client, old ones included, failing the whole fetch with 'unknown key'; an unchanged client imports these objects and verifies the move. A client that still cannot verify a non-current bookmark's attestation (unknown signer or bad signature) leaves that bookmark as it was, warns naming it and why, and fetches the rest. The current bookmark, the one a pull takes or a clone checks out, still fails, as does a missing, unauthorized or rolled-back attestation. Refs andymccall/oxygenhub#266
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-19 11:28 UTC
- Signature
- ◈ sealed by Andy McCall
- Hash
d36ba2f79f959ad0e4a2264335c3c2971913fe5fade9d39441c99ec1b27b755a- Depends on
- 028545dfa7a4 096f447ccda0 132b884b9a8d 135212de5370 14edbaaf5291 2863456a0f6d 2c75672fb2fe 4b9d06778581 54cd08a1e7bf 68a924846379 6caacfff1d4f 75167672ebcd 7ba155f20aa0 7d9dab131937 83e0677b07fe 84b9e9ac0ab4 86e682c242c6 982864068a59 9f9e72b93e4e a07f60a37fb2 b3c5a59a875a c6aa5bd9d1c8 f0a517d28237 f6dccc4533c8 f8c695764c95 fce44ae9ad29 ff0af1ee299d ff1f62fee654
- Depended on by
- d8524a9f0117 e10ba80000e1
- In bookmarks
- doctor-trust identical-sides keyless-tags main push-notes subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 45 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/repo/hosted.rs +144 −0
| … | 665 unchanged lines |
||
| 666 | 666 | ); |
|
| 667 | 667 | } |
|
| 668 | 668 | ||
| 669 | + | /// oxygenhub#266: a bookmark whose latest attestation a delegate made, |
|
| 670 | + | /// signing no change, is fetched by a third party. The fetch bundle |
|
| 671 | + | /// carries the delegate's identity even when it asks for no change, |
|
| 672 | + | /// so a client as it was (import the bundle, then check each |
|
| 673 | + | /// bookmark's attestation) verifies the move instead of failing the |
|
| 674 | + | /// whole fetch with "unknown key". |
|
| 675 | + | #[test] |
|
| 676 | + | fn a_bookmark_attested_by_a_key_that_signed_no_change_is_fetched() { |
|
| 677 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 678 | + | let (host, mut user, _, _, _) = setup(dir.path(), true, false); |
|
| 679 | + | let agent_home = dir.path().join("agent-home"); |
|
| 680 | + | let mut agent_repo = Repo::init(&dir.path().join("agent-work")) |
|
| 681 | + | .unwrap() |
|
| 682 | + | .with_home(agent_home.clone()); |
|
| 683 | + | let (agent, agent_id) = agent_repo |
|
| 684 | + | .create_identity("agent", "agent@example", IdentityScope::User) |
|
| 685 | + | .unwrap(); |
|
| 686 | + | user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None) |
|
| 687 | + | .unwrap(); |
|
| 688 | + | assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 689 | + | let mut as_agent = Repo::open(&dir.path().join("work")) |
|
| 690 | + | .unwrap() |
|
| 691 | + | .with_home(agent_home); |
|
| 692 | + | as_agent |
|
| 693 | + | .set_user_config("author.signer", &agent.to_hex()) |
|
| 694 | + | .unwrap(); |
|
| 695 | + | let heads = as_agent.bookmark_heads().unwrap(); |
|
| 696 | + | as_agent.bookmark_create("feature", Some(&heads)).unwrap(); |
|
| 697 | + | let r = as_agent.push_bookmark("origin", "feature", false).unwrap(); |
|
| 698 | + | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 699 | + | host.refresh().unwrap(); |
|
| 700 | + | let (_, sa) = host.latest_ref_attest("feature").unwrap().unwrap(); |
|
| 701 | + | assert_eq!(sa.sigs[0].key, agent, "the agent attested the move"); |
|
| 702 | + | ||
| 703 | + | // What a fetch that names no change brings: the agent's identity. |
|
| 704 | + | let remote = crate::protocol::LocalRemote::open(&dir.path().join("host")).unwrap(); |
|
| 705 | + | let carries = |b: &Bundle| { |
|
| 706 | + | b.objects |
|
| 707 | + | .iter() |
|
| 708 | + | .any(|(t, body)| *t == ObjectType::Identity && hash_object(*t, body) == agent) |
|
| 709 | + | }; |
|
| 710 | + | assert!(carries( |
|
| 711 | + | &crate::protocol::Remote::fetch(&remote, &[], None).unwrap() |
|
| 712 | + | )); |
|
| 713 | + | assert!(carries(&host.bundle_for_filtered(&[], None).unwrap())); |
|
| 714 | + | ||
| 715 | + | // A third party's clone takes both bookmarks, verified. |
|
| 716 | + | let theirs = Repo::clone( |
|
| 717 | + | dir.path().join("host").to_str().unwrap(), |
|
| 718 | + | &dir.path().join("theirs"), |
|
| 719 | + | ) |
|
| 720 | + | .unwrap(); |
|
| 721 | + | let tracked = theirs.view().unwrap().remotes["origin"].clone(); |
|
| 722 | + | assert_eq!( |
|
| 723 | + | tracked.get("feature"), |
|
| 724 | + | host.view().unwrap().bookmarks.get("feature") |
|
| 725 | + | ); |
|
| 726 | + | assert!(theirs.store().get_identity(&agent).is_ok()); |
|
| 727 | + | let report = theirs.fetch("origin").unwrap(); |
|
| 728 | + | assert!(report.skipped.is_empty(), "{:?}", report.skipped); |
|
| 729 | + | } |
|
| 730 | + | ||
| 731 | + | /// Make `bookmark`'s latest attestation on `host` one signed as `key` |
|
| 732 | + | /// with `sk`: by a key the host never saw (as a host that did not send |
|
| 733 | + | /// the signer's identity looked), or a bad signature. |
|
| 734 | + | fn reattest(host: &Repo, bookmark: &str, key: KeyId, sk: &SecretKey) { |
|
| 735 | + | let state = host.view().unwrap().bookmarks[bookmark]; |
|
| 736 | + | let (_, last) = host.latest_ref_attest(bookmark).unwrap().unwrap(); |
|
| 737 | + | let AttestBody::Ref { |
|
| 738 | + | old_state, serial, .. |
|
| 739 | + | } = last.attest.body |
|
| 740 | + | else { |
|
| 741 | + | panic!("not a ref attestation") |
|
| 742 | + | }; |
|
| 743 | + | let attest = Attest { |
|
| 744 | + | repo: host.id().unwrap(), |
|
| 745 | + | hlc: Hlc::now(), |
|
| 746 | + | body: AttestBody::Ref { |
|
| 747 | + | name: bookmark.into(), |
|
| 748 | + | old_state, |
|
| 749 | + | new_state: state, |
|
| 750 | + | serial: serial + 1, |
|
| 751 | + | }, |
|
| 752 | + | }; |
|
| 753 | + | let r = host.store().put_attest(&attest).unwrap(); |
|
| 754 | + | host.store() |
|
| 755 | + | .put_signature(&r, &sk.sign(Domain::Attest, &key, &attest.hash())) |
|
| 756 | + | .unwrap(); |
|
| 757 | + | host.set_ref_attest(bookmark, &r).unwrap(); |
|
| 758 | + | } |
|
| 759 | + | ||
| 760 | + | /// oxygenhub#266: a bookmark whose attestation a client cannot verify |
|
| 761 | + | /// (its signer unknown, or its signature bad) is left as it was, named |
|
| 762 | + | /// with why, and the rest are fetched. The current bookmark is not |
|
| 763 | + | /// skipped: a clone, fetch or pull that would take it fails. |
|
| 764 | + | #[test] |
|
| 765 | + | fn an_unverifiable_bookmark_is_skipped_but_never_the_current_one() { |
|
| 766 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 767 | + | let (host, user, _, _, _) = setup(dir.path(), false, false); |
|
| 768 | + | let author = user.config.author_key.unwrap(); |
|
| 769 | + | let heads = user.bookmark_heads().unwrap(); |
|
| 770 | + | for b in ["unknown", "forged", "fine"] { |
|
| 771 | + | user.bookmark_create(b, Some(&heads)).unwrap(); |
|
| 772 | + | let r = user.push_bookmark("origin", b, false).unwrap(); |
|
| 773 | + | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 774 | + | } |
|
| 775 | + | host.refresh().unwrap(); |
|
| 776 | + | let stranger_sk = SecretKey::generate(); |
|
| 777 | + | let stranger = stranger_sk.identity("s", "s@example", 1).hash(); |
|
| 778 | + | reattest(&host, "unknown", stranger, &stranger_sk); |
|
| 779 | + | reattest(&host, "forged", author, &stranger_sk); |
|
| 780 | + | ||
| 781 | + | let url = dir.path().join("host").to_string_lossy().to_string(); |
|
| 782 | + | let (theirs, skipped) = |
|
| 783 | + | Repo::clone_reporting(&url, &dir.path().join("theirs"), None, &[], None).unwrap(); |
|
| 784 | + | let names: Vec<&str> = skipped.iter().map(|(n, _)| n.as_str()).collect(); |
|
| 785 | + | assert_eq!(names, ["forged", "unknown"]); |
|
| 786 | + | assert!(skipped[1].1.contains("unknown key"), "{skipped:?}"); |
|
| 787 | + | assert!(skipped[1].1.contains("origin/unknown"), "{skipped:?}"); |
|
| 788 | + | let tracked = theirs.view().unwrap().remotes["origin"].clone(); |
|
| 789 | + | assert!(tracked.contains_key("main") && tracked.contains_key("fine")); |
|
| 790 | + | assert!(!tracked.contains_key("unknown") && !tracked.contains_key("forged")); |
|
| 791 | + | assert_eq!(theirs.config.bookmark, "main"); |
|
| 792 | + | assert_eq!(theirs.fetch("origin").unwrap().skipped.len(), 2); |
|
| 793 | + | assert_eq!(theirs.pull("origin").unwrap().skipped.len(), 2); |
|
| 794 | + | ||
| 795 | + | // The current bookmark's attestation unverifiable: a fetch and a |
|
| 796 | + | // pull fail, and so does a fresh clone. |
|
| 797 | + | reattest(&host, "main", stranger, &stranger_sk); |
|
| 798 | + | for err in [ |
|
| 799 | + | theirs.fetch("origin").unwrap_err(), |
|
| 800 | + | theirs.pull("origin").unwrap_err(), |
|
| 801 | + | Repo::clone(&url, &dir.path().join("again")) |
|
| 802 | + | .err() |
|
| 803 | + | .expect("the clone fails"), |
|
| 804 | + | ] { |
|
| 805 | + | let err = err.to_string(); |
|
| 806 | + | assert!( |
|
| 807 | + | err.contains("attestation for origin/main: unknown key"), |
|
| 808 | + | "{err}" |
|
| 809 | + | ); |
|
| 810 | + | } |
|
| 811 | + | } |
|
| 812 | + | ||
| 669 | 813 | /// A delegation past its expiry authenticates nothing made after it, |
|
| 670 | 814 | /// and the delegator may revoke the key; a stranger's key, they may not. |
|
| 671 | 815 | #[test] |
|
| … | 125 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +130 −20
| … | 33 unchanged lines |
||
| 34 | 34 | /// (spec §14.8): attestations not logged yet, a log that could not be |
|
| 35 | 35 | /// read. A fork fails the fetch instead. |
|
| 36 | 36 | pub log_notes: Vec<String>, |
|
| 37 | + | /// Bookmarks left as they were because their attestation could not be |
|
| 38 | + | /// verified (its signer unknown here, or its signature bad), each with |
|
| 39 | + | /// why (oxygenhub#266). The current bookmark is never skipped: its |
|
| 40 | + | /// fetch fails instead. |
|
| 41 | + | pub skipped: Vec<(String, String)>, |
|
| 37 | 42 | } |
|
| 38 | 43 | ||
| 39 | 44 | #[derive(Debug, Clone, PartialEq, Eq)] |
|
| … | 6 unchanged lines |
||
| 46 | 51 | /// Other bookmarks the pull moved: each local bookmark tracks the |
|
| 47 | 52 | /// remote's of the same name. |
|
| 48 | 53 | pub others: Vec<String>, |
|
| 54 | + | /// What the fetch left as it was ([`FetchReport::skipped`]). |
|
| 55 | + | pub skipped: Vec<(String, String)>, |
|
| 49 | 56 | } |
|
| 50 | 57 | ||
| 51 | 58 | /// The result a push of objects alone reports (a batch of a long history): |
|
| … | 211 unchanged lines |
||
| 263 | 270 | /// Bundle changes (in the given dependency order) with their signatures, |
|
| 264 | 271 | /// seals, identities, blobs, chunks. |
|
| 265 | 272 | pub fn bundle_for(&self, changes: &[ChangeHash]) -> Result<Bundle> { |
|
| 266 | - | self.bundle_for_filtered(changes, None) |
|
| 273 | + | self.bundle_of(changes, None, false, false) |
|
| 267 | 274 | } |
|
| 268 | 275 | ||
| 269 | 276 | /// Objects by reference for the `objects` endpoint; a blob brings its chunks. |
|
| … | 19 unchanged lines |
||
| 289 | 296 | ||
| 290 | 297 | /// Like `bundle_for`, leaving out blobs larger than `max_blob` bytes |
|
| 291 | 298 | /// (and their chunks) so the receiver fetches them lazily (spec §13.5). |
|
| 299 | + | /// |
|
| 300 | + | /// This is what a server answers a fetch with, so it also carries what |
|
| 301 | + | /// verifies the attestations it serves beside it (see |
|
| 302 | + | /// [`Repo::served_attest_signers`]), also when `changes` is empty. |
|
| 292 | 303 | pub fn bundle_for_filtered( |
|
| 293 | 304 | &self, |
|
| 294 | 305 | changes: &[ChangeHash], |
|
| 295 | 306 | max_blob: Option<u64>, |
|
| 296 | 307 | ) -> Result<Bundle> { |
|
| 297 | - | self.bundle_of(changes, max_blob, false) |
|
| 308 | + | self.bundle_of(changes, max_blob, false, true) |
|
| 298 | 309 | } |
|
| 299 | 310 | ||
| 300 | 311 | /// Like [`Repo::bundle_for_filtered`] for a fetch between repositories |
|
| … | 1 unchanged lines |
||
| 302 | 313 | /// with their text blobs and chunks among the objects, so neither side |
|
| 303 | 314 | /// decodes or encodes their text. |
|
| 304 | 315 | pub fn bundle_raw_for(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> { |
|
| 305 | - | self.bundle_of(changes, max_blob, true) |
|
| 316 | + | self.bundle_of(changes, max_blob, true, true) |
|
| 306 | 317 | } |
|
| 307 | 318 | ||
| 319 | + | /// The keys that signed the attestations this repository serves with |
|
| 320 | + | /// its references: each bookmark's latest and each tag's. A fetch |
|
| 321 | + | /// bundle carries their identities and certificates (spec §16.2), so a |
|
| 322 | + | /// client can check an attestation whose signer signed none of the |
|
| 323 | + | /// changes it receives, a delegate or a short-lived key that only |
|
| 324 | + | /// moved a bookmark (oxygenhub#266). One pointer and one signature |
|
| 325 | + | /// list per reference; nothing per change. |
|
| 326 | + | pub fn served_attest_signers(&self) -> Result<BTreeSet<KeyId>> { |
|
| 327 | + | let view = self.view()?; |
|
| 328 | + | let pointers = view |
|
| 329 | + | .bookmarks |
|
| 330 | + | .keys() |
|
| 331 | + | .map(|b| self.ref_attest_path(b)) |
|
| 332 | + | .chain(view.tags.keys().map(|t| self.tag_attest_path(t))); |
|
| 333 | + | let mut keys = BTreeSet::new(); |
|
| 334 | + | for p in pointers { |
|
| 335 | + | let Ok(text) = fs::read_to_string(&p) else { |
|
| 336 | + | continue; |
|
| 337 | + | }; |
|
| 338 | + | let Some(r) = ObjectRef::from_hex(text.trim()) else { |
|
| 339 | + | continue; |
|
| 340 | + | }; |
|
| 341 | + | keys.extend(self.store.signatures(&r)?.into_iter().map(|s| s.key)); |
|
| 342 | + | } |
|
| 343 | + | Ok(keys) |
|
| 344 | + | } |
|
| 345 | + | ||
| 346 | + | /// `serving`: a fetch's answer, which carries the identities and |
|
| 347 | + | /// certificates of [`Repo::served_attest_signers`] too. |
|
| 308 | 348 | fn bundle_of( |
|
| 309 | 349 | &self, |
|
| 310 | 350 | changes: &[ChangeHash], |
|
| 311 | 351 | max_blob: Option<u64>, |
|
| 312 | 352 | raw: bool, |
|
| 353 | + | serving: bool, |
|
| 313 | 354 | ) -> Result<Bundle> { |
|
| 314 | 355 | let mut bundle = Bundle::default(); |
|
| 315 | 356 | let mut objects: BTreeSet<ObjectRef> = BTreeSet::new(); |
|
| … | 60 unchanged lines |
||
| 376 | 417 | } |
|
| 377 | 418 | bundle.attests.push(SignedAttest { attest, sigs }); |
|
| 378 | 419 | } |
|
| 420 | + | let signers: Vec<KeyId> = if serving { |
|
| 421 | + | let signers = self.served_attest_signers()?; |
|
| 422 | + | for k in &signers { |
|
| 423 | + | add_object(k, &mut objects); |
|
| 424 | + | } |
|
| 425 | + | signers.into_iter().collect() |
|
| 426 | + | } else { |
|
| 427 | + | Vec::new() |
|
| 428 | + | }; |
|
| 379 | 429 | for r in objects { |
|
| 380 | 430 | let (ty, body) = self.store.get(&r)?; |
|
| 381 | 431 | bundle.objects.push((ty, body)); |
|
| 382 | 432 | } |
|
| 383 | - | self.attach_certificates(&mut bundle)?; |
|
| 433 | + | self.attach_certificates_for(&mut bundle, &signers)?; |
|
| 384 | 434 | Ok(bundle) |
|
| 385 | 435 | } |
|
| 386 | 436 | ||
| … | 51 unchanged lines |
||
| 438 | 488 | bundle.certificates.push(cert); |
|
| 439 | 489 | } |
|
| 440 | 490 | Ok(()) |
|
| 441 | - | } |
|
| 442 | - | ||
| 443 | - | fn attach_certificates(&self, bundle: &mut Bundle) -> Result<()> { |
|
| 444 | - | self.attach_certificates_for(bundle, &[]) |
|
| 445 | 491 | } |
|
| 446 | 492 | ||
| 447 | 493 | /// Keep the certificates a bundle brings once each verifies with the |
|
| … | 220 unchanged lines |
||
| 668 | 714 | } |
|
| 669 | 715 | ||
| 670 | 716 | fn check_ref_attest( |
|
| 717 | + | &self, |
|
| 718 | + | sa: &SignedAttest, |
|
| 719 | + | name: &str, |
|
| 720 | + | old_state: &ObjectRef, |
|
| 721 | + | new_state: &ObjectRef, |
|
| 722 | + | last_serial: Option<u64>, |
|
| 723 | + | allow_equal: bool, |
|
| 724 | + | ) -> std::result::Result<u64, String> { |
|
| 725 | + | let serial = |
|
| 726 | + | self.check_ref_attest_body(sa, name, old_state, new_state, last_serial, allow_equal)?; |
|
| 727 | + | self.verify_attest_signature(sa)?; |
|
| 728 | + | Ok(serial) |
|
| 729 | + | } |
|
| 730 | + | ||
| 731 | + | /// What [`Repo::check_ref_attest`] checks short of the signature: the |
|
| 732 | + | /// attestation describes this update of this repository with a serial |
|
| 733 | + | /// past the last seen. |
|
| 734 | + | fn check_ref_attest_body( |
|
| 671 | 735 | &self, |
|
| 672 | 736 | sa: &SignedAttest, |
|
| 673 | 737 | name: &str, |
|
| … | 24 unchanged lines |
||
| 698 | 762 | )); |
|
| 699 | 763 | } |
|
| 700 | 764 | } |
|
| 765 | + | Ok(*serial) |
|
| 766 | + | } |
|
| 767 | + | ||
| 768 | + | /// An attestation's first signature verifies with its signer's |
|
| 769 | + | /// identity, which must be stored here. |
|
| 770 | + | fn verify_attest_signature(&self, sa: &SignedAttest) -> std::result::Result<(), String> { |
|
| 701 | 771 | let r = sa.attest.hash(); |
|
| 702 | 772 | let sig = sa.sigs.first().ok_or("attestation carries no signature")?; |
|
| 703 | 773 | let id = self |
|
| 704 | 774 | .store |
|
| 705 | 775 | .get_identity(&sig.key) |
|
| 706 | 776 | .map_err(|_| format!("unknown key {}", sig.key.short(12)))?; |
|
| 707 | - | verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?; |
|
| 708 | - | Ok(*serial) |
|
| 777 | + | verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string()) |
|
| 709 | 778 | } |
|
| 710 | 779 | ||
| 711 | 780 | /// Accept a push (spec §16.4). Without a trust policy every repository |
|
| … | 817 unchanged lines |
||
| 1529 | 1598 | let mut view = op.view.clone(); |
|
| 1530 | 1599 | let have = self.known_heads(&view)?; |
|
| 1531 | 1600 | let mut report = Vec::new(); |
|
| 1601 | + | let mut skipped = Vec::new(); |
|
| 1532 | 1602 | let max_blob = self.config.max_blob; |
|
| 1533 | 1603 | for r in resp.refs { |
|
| 1534 | 1604 | let needed = { |
|
| … | 53 unchanged lines |
||
| 1588 | 1658 | }; |
|
| 1589 | 1659 | let (old_state, new_state) = (*old_state, *new_state); |
|
| 1590 | 1660 | let serial = self |
|
| 1591 | - | .check_ref_attest( |
|
| 1661 | + | .check_ref_attest_body( |
|
| 1592 | 1662 | sa, |
|
| 1593 | 1663 | &r.name, |
|
| 1594 | 1664 | &old_state, |
|
| … | 10 unchanged lines |
||
| 1605 | 1675 | remote_name, r.name |
|
| 1606 | 1676 | )); |
|
| 1607 | 1677 | } |
|
| 1678 | + | // A signature this client cannot verify (its signer unknown |
|
| 1679 | + | // here, as from a host that did not send the identity, or |
|
| 1680 | + | // bad) fails that bookmark alone: it stays as it was and |
|
| 1681 | + | // the rest are fetched (oxygenhub#266). The current |
|
| 1682 | + | // bookmark, which a pull would take, still fails the fetch. |
|
| 1683 | + | // A policy's requirements were checked above and still fail. |
|
| 1684 | + | if let Err(e) = self.verify_attest_signature(sa) { |
|
| 1685 | + | let why = format!("attestation for {remote_name}/{}: {e}", r.name); |
|
| 1686 | + | if r.name == self.config.bookmark { |
|
| 1687 | + | return fail(why); |
|
| 1688 | + | } |
|
| 1689 | + | skipped.push((r.name, why)); |
|
| 1690 | + | continue; |
|
| 1691 | + | } |
|
| 1608 | 1692 | let ar = self.store.put_attest(&sa.attest)?; |
|
| 1609 | 1693 | for sig in &sa.sigs { |
|
| 1610 | 1694 | self.store.put_signature(&ar, sig)?; |
|
| … | 48 unchanged lines |
||
| 1659 | 1743 | unverified, |
|
| 1660 | 1744 | new_tags, |
|
| 1661 | 1745 | log_notes, |
|
| 1746 | + | skipped, |
|
| 1662 | 1747 | }) |
|
| 1663 | 1748 | } |
|
| 1664 | 1749 | ||
| … | 64 unchanged lines |
||
| 1729 | 1814 | /// remote's: no merge change, conflicts become reified (spec §9.4). |
|
| 1730 | 1815 | pub fn pull(&self, remote_name: &str) -> Result<PullReport> { |
|
| 1731 | 1816 | let _wc = self.lock_working_copy()?; |
|
| 1732 | - | self.fetch(remote_name)?; |
|
| 1817 | + | let skipped = self.fetch(remote_name)?.skipped; |
|
| 1733 | 1818 | let (op_ref, op) = self.head_op()?; |
|
| 1734 | 1819 | let current = self.config.bookmark.clone(); |
|
| 1735 | 1820 | let mut local_current = self.heads_of(&op.view, ¤t)?; |
|
| … | 60 unchanged lines |
||
| 1796 | 1881 | changed, |
|
| 1797 | 1882 | checked_out, |
|
| 1798 | 1883 | others, |
|
| 1884 | + | skipped, |
|
| 1799 | 1885 | }) |
|
| 1800 | 1886 | } |
|
| 1801 | 1887 | ||
| … | 489 unchanged lines |
||
| 2291 | 2377 | sparse: &[String], |
|
| 2292 | 2378 | depth: Option<u32>, |
|
| 2293 | 2379 | ) -> Result<Repo> { |
|
| 2380 | + | Ok(Self::clone_reporting(url, dest, max_blob, sparse, depth)?.0) |
|
| 2381 | + | } |
|
| 2382 | + | ||
| 2383 | + | /// Clone as [`Repo::clone_with`] (or [`Repo::clone_shallow`] with a |
|
| 2384 | + | /// `depth`), also returning the bookmarks the fetch left out because |
|
| 2385 | + | /// their attestation could not be verified ([`FetchReport::skipped`]), |
|
| 2386 | + | /// for the caller to warn about. The bookmark checked out is never one |
|
| 2387 | + | /// of them: that fails the clone. |
|
| 2388 | + | pub fn clone_reporting( |
|
| 2389 | + | url: &str, |
|
| 2390 | + | dest: &Path, |
|
| 2391 | + | max_blob: Option<u64>, |
|
| 2392 | + | sparse: &[String], |
|
| 2393 | + | depth: Option<u32>, |
|
| 2394 | + | ) -> Result<(Repo, Vec<(String, String)>)> { |
|
| 2294 | 2395 | let options = crate::credentials::Credentials::user().options_for(url); |
|
| 2295 | 2396 | Self::clone_using(url, dest, max_blob, sparse, depth, &options) |
|
| 2296 | 2397 | } |
|
| … | 3 unchanged lines |
||
| 2300 | 2401 | /// subrepo: it finds the same ones again through its parent. |
|
| 2301 | 2402 | pub(crate) fn clone_as_subrepo(&self, url: &str, dest: &Path) -> Result<Repo> { |
|
| 2302 | 2403 | let options = self.credentials_for(url); |
|
| 2303 | - | Self::clone_using(url, dest, None, &[], None, &options) |
|
| 2404 | + | Ok(Self::clone_using(url, dest, None, &[], None, &options)?.0) |
|
| 2304 | 2405 | } |
|
| 2305 | 2406 | ||
| 2306 | 2407 | /// The name of a remote of this repository on the same hub (scheme, |
|
| … | 45 unchanged lines |
||
| 2352 | 2453 | sparse: &[String], |
|
| 2353 | 2454 | depth: Option<u32>, |
|
| 2354 | 2455 | options: &RemoteOptions, |
|
| 2355 | - | ) -> Result<Repo> { |
|
| 2456 | + | ) -> Result<(Repo, Vec<(String, String)>)> { |
|
| 2356 | 2457 | let remote = open_remote(url, options)?; |
|
| 2357 | 2458 | let id = remote.repo_id()?; |
|
| 2358 | 2459 | if id == crate::protocol::UNBOUND_ID { |
|
| … | 13 unchanged lines |
||
| 2372 | 2473 | repo.config.set("filter.paths", &sparse.join(","))?; |
|
| 2373 | 2474 | } |
|
| 2374 | 2475 | repo.save_config()?; |
|
| 2375 | - | if let Some(depth) = depth { |
|
| 2476 | + | let skipped = if let Some(depth) = depth { |
|
| 2376 | 2477 | repo.fetch_shallow("origin", depth)?; |
|
| 2478 | + | Vec::new() |
|
| 2377 | 2479 | } else { |
|
| 2378 | - | repo.fetch("origin")?; |
|
| 2379 | - | } |
|
| 2480 | + | repo.fetch("origin")?.skipped |
|
| 2481 | + | }; |
|
| 2380 | 2482 | let (op_ref, op) = repo.head_op()?; |
|
| 2381 | - | // The remote's default bookmark, else ours (main), else its only one. |
|
| 2382 | - | let theirs = op.view.remotes.get("origin").cloned().unwrap_or_default(); |
|
| 2483 | + | // The remote's default bookmark, else ours (main), else its only |
|
| 2484 | + | // one; a bookmark the fetch skipped counts, so the clone fails |
|
| 2485 | + | // rather than check out another (oxygenhub#266). |
|
| 2486 | + | let mut theirs = op.view.remotes.get("origin").cloned().unwrap_or_default(); |
|
| 2487 | + | for (name, _) in &skipped { |
|
| 2488 | + | theirs.insert(name.clone(), ObjectRef::ZERO); |
|
| 2489 | + | } |
|
| 2383 | 2490 | // Asked of the remote as now configured, so a store-backed one |
|
| 2384 | 2491 | // answers from the mirror the fetch just filled. |
|
| 2385 | 2492 | let advertised = repo |
|
| … | 14 unchanged lines |
||
| 2400 | 2507 | .flatten() |
|
| 2401 | 2508 | }) |
|
| 2402 | 2509 | .unwrap_or_else(|| repo.config.bookmark.clone()); |
|
| 2510 | + | if let Some((_, why)) = skipped.iter().find(|(name, _)| *name == bookmark) { |
|
| 2511 | + | return fail(format!("cannot check out {bookmark}: {why}")); |
|
| 2512 | + | } |
|
| 2403 | 2513 | let initial = repo.config.bookmark.clone(); |
|
| 2404 | 2514 | if bookmark != repo.config.bookmark { |
|
| 2405 | 2515 | fs::create_dir_all(repo.workspace_bookmark_path().parent().unwrap())?; |
|
| … | 11 unchanged lines |
||
| 2417 | 2527 | repo.record_op(&[op_ref], &format!("oxy clone {url}"), view)?; |
|
| 2418 | 2528 | repo.checkout(&heads, false, "oxy clone (checkout)")?; |
|
| 2419 | 2529 | } |
|
| 2420 | - | Ok(repo) |
|
| 2530 | + | Ok((repo, skipped)) |
|
| 2421 | 2531 | } |
|
| 2422 | 2532 | } |
|
| 2423 | 2533 | ||
| … | 10 unchanged lines |
modified crates/oxy-core/src/repo/tags.rs +1 −1
| … | 15 unchanged lines |
||
| 16 | 16 | } |
|
| 17 | 17 | ||
| 18 | 18 | impl Repo { |
|
| 19 | - | fn tag_attest_path(&self, name: &str) -> PathBuf { |
|
| 19 | + | pub(crate) fn tag_attest_path(&self, name: &str) -> PathBuf { |
|
| 20 | 20 | self.oxy.join("attest").join("tags").join(name_file(name)) |
|
| 21 | 21 | } |
|
| 22 | 22 | ||
| … | 222 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +85 −0
| … | 1126 unchanged lines |
||
| 1127 | 1127 | ); |
|
| 1128 | 1128 | } |
|
| 1129 | 1129 | ||
| 1130 | + | /// oxygenhub#266: a short-lived key its issuer certified moves a bookmark |
|
| 1131 | + | /// and signs no change (a keyless agent naming a bookmark). A third |
|
| 1132 | + | /// party's clone gets the key's identity, its certificate and the |
|
| 1133 | + | /// issuer's identity with the fetch, so the policy's subject grant |
|
| 1134 | + | /// authorizes the move and the signature verifies. |
|
| 1135 | + | #[test] |
|
| 1136 | + | fn a_bookmark_moved_by_a_certified_key_alone_is_fetched() { |
|
| 1137 | + | use crate::cert::{Certificate, SignedCertificate}; |
|
| 1138 | + | use crate::identity::Scope; |
|
| 1139 | + | hermetic(); |
|
| 1140 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1141 | + | let root = dir.path(); |
|
| 1142 | + | let _origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 1143 | + | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 1144 | + | let mut a = Repo::clone(&url, &root.join("a")) |
|
| 1145 | + | .unwrap() |
|
| 1146 | + | .with_home(root.join("home-a")); |
|
| 1147 | + | a.create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 1148 | + | .unwrap(); |
|
| 1149 | + | write(&root.join("a"), "a.txt", b"a\n"); |
|
| 1150 | + | a.commit("a", &[]).unwrap(); |
|
| 1151 | + | a.policy_init().unwrap(); |
|
| 1152 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1153 | + | let hub_sk = SecretKey::generate(); |
|
| 1154 | + | let hub = hub_sk.identity("hub", "[email protected]", 1); |
|
| 1155 | + | a.policy_grant_subject( |
|
| 1156 | + | "agent:claude@ada", |
|
| 1157 | + | hub.clone(), |
|
| 1158 | + | Scope::CHANGES | Scope::REFS, |
|
| 1159 | + | None, |
|
| 1160 | + | ) |
|
| 1161 | + | .unwrap(); |
|
| 1162 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1163 | + | ||
| 1164 | + | let now = Hlc::now().wall_ms; |
|
| 1165 | + | let mut agent = Repo::clone(&url, &root.join("agent")) |
|
| 1166 | + | .unwrap() |
|
| 1167 | + | .with_home(root.join("home-agent")); |
|
| 1168 | + | let (key, id) = agent |
|
| 1169 | + | .create_identity("claude", "[email protected]", IdentityScope::User) |
|
| 1170 | + | .unwrap(); |
|
| 1171 | + | let cert = SignedCertificate::sign( |
|
| 1172 | + | Certificate { |
|
| 1173 | + | key: id, |
|
| 1174 | + | issuer: hub.hash(), |
|
| 1175 | + | subject: "agent:claude@ada".into(), |
|
| 1176 | + | claims: vec![], |
|
| 1177 | + | scope: Scope::CHANGES | Scope::REFS, |
|
| 1178 | + | not_before: now - 60_000, |
|
| 1179 | + | expires: now + 600_000, |
|
| 1180 | + | grant: None, |
|
| 1181 | + | }, |
|
| 1182 | + | &hub, |
|
| 1183 | + | &hub_sk, |
|
| 1184 | + | ); |
|
| 1185 | + | agent.store().put_identity(&hub).unwrap(); |
|
| 1186 | + | agent.store().put_certificate(&cert).unwrap(); |
|
| 1187 | + | let heads = agent.bookmark_heads().unwrap(); |
|
| 1188 | + | agent.bookmark_create("agent-work", Some(&heads)).unwrap(); |
|
| 1189 | + | let r = agent.push_bookmark("origin", "agent-work", false).unwrap(); |
|
| 1190 | + | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 1191 | + | let host = Repo::open(&root.join("origin")).unwrap(); |
|
| 1192 | + | let (_, sa) = host.latest_ref_attest("agent-work").unwrap().unwrap(); |
|
| 1193 | + | assert_eq!(sa.sigs[0].key, key, "the certified key alone moved it"); |
|
| 1194 | + | ||
| 1195 | + | // A fetch naming no change brings the certificate and both identities. |
|
| 1196 | + | let bundle = host.bundle_raw_for(&[], None).unwrap(); |
|
| 1197 | + | assert!(bundle.changes.is_empty() && bundle.raw_changes.is_empty()); |
|
| 1198 | + | assert_eq!(bundle.certificates.len(), 1); |
|
| 1199 | + | for k in [key, hub.hash()] { |
|
| 1200 | + | assert!(bundle |
|
| 1201 | + | .objects |
|
| 1202 | + | .iter() |
|
| 1203 | + | .any(|(t, b)| *t == ObjectType::Identity && hash_object(*t, b) == k)); |
|
| 1204 | + | } |
|
| 1205 | + | ||
| 1206 | + | let c = Repo::clone(&url, &root.join("c")).unwrap(); |
|
| 1207 | + | let tracked = c.view().unwrap().remotes["origin"].clone(); |
|
| 1208 | + | assert_eq!( |
|
| 1209 | + | tracked.get("agent-work"), |
|
| 1210 | + | host.view().unwrap().bookmarks.get("agent-work") |
|
| 1211 | + | ); |
|
| 1212 | + | assert!(c.store().certificate_of(&key).unwrap().is_some()); |
|
| 1213 | + | } |
|
| 1214 | + | ||
| 1130 | 1215 | /// Trusted time (oxygenhub#240, spec §14.8): a fetched attestation by a |
|
| 1131 | 1216 | /// short-lived key is judged at its integrated time, the earliest time a |
|
| 1132 | 1217 | /// pinned witness cosigned a checkpoint holding it. A certificate valid |
|
| … | 7941 unchanged lines |
modified docs/spec.md +2 −2
| … | 431 unchanged lines |
||
| 432 | 432 | ||
| 433 | 433 | ### 10.2 Reference attestations (G2, G3) |
|
| 434 | 434 | ||
| 435 | - | Every published bookmark update is accompanied by an attestation (§14.4) of kind `ref` that binds the repository ID, the reference name, the previous state, the new state, and a per-reference monotonically increasing `serial`. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a `remotes/` reference and MUST reject an attestation whose `serial` is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's `hlc.wall_ms` is older than `remote.max-age` (default 30 days) and the remote claims no update. |
|
| 435 | + | Every published bookmark update is accompanied by an attestation (§14.4) of kind `ref` that binds the repository ID, the reference name, the previous state, the new state, and a per-reference monotonically increasing `serial`. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a `remotes/` reference and MUST reject an attestation whose `serial` is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's `hlc.wall_ms` is older than `remote.max-age` (default 30 days) and the remote claims no update. A fetched bookmark whose attestation cannot be verified because its signer's identity is unknown to the client or its signature does not verify is skipped, not fatal, when it is not the current bookmark: its `remotes/` reference stays as it was, the client warns naming it and why, and the other bookmarks are fetched (oxygenhub#266). For the current bookmark (the one a `pull` takes, or a `clone` checks out) the fetch, pull or clone still fails, and an attestation that is missing under a policy, unauthorized, rolled back or not describing the update fails the fetch as before. |
|
| 436 | 436 | ||
| 437 | 437 | Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name, and any receiver MUST refuse a tag whose name it already holds with a different state. `oxy tag create NAME [CHANGE..]` tags the named state (default the bookmark's heads); tags travel with `refs` and `push` (§16.3), and a policy rule for `tags/<name>` governs who may create them. |
|
| 438 | 438 | ||
| … | 431 unchanged lines |
||
| 870 | 870 | | Signed policy | `[policy bytes, [Signature]]` | |
|
| 871 | 871 | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; key 5 (optional, text or null) names the default bookmark, which a clone checks out; the attestation is present only when it describes the current state; an entry's key 5 (optional, only when there is one) is the host's acceptance of that attestation (§14.7); an entry's key 6 (optional) is the attestation's place in the host's transparency log, `[entry number, entry bytes, [proof hash]]`, an inclusion proof against the checkpoint in the response's key 6 (§14.8); an entry's key 7 (optional, only beside key 6 and when there are any) lists the cosigned checkpoints that first held that log entry, `[[checkpoint note text, [proof hash]]]`, each with the entry's inclusion proof against it, from which its integrated time is read (§14.8); the response's key 6 (optional, text) is the host's latest checkpoint, a signed note; the response's key 7 (optional, only when there are any) lists the witnesses the host asks to cosign, `[[name, identity bytes]]`, which a client pins (§14.8); a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` | |
|
| 872 | 872 | | `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order | |
|
| 873 | - | | `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size | |
|
| 873 | + | | `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size; also when it names no change, the bundle carries the identities of the keys that signed the attestations served with `refs` (each bookmark's latest and each tag's) and, for a short-lived key, its certificate and its issuer's identity (§14.7), each once, so a client can check an attestation whose signer signed none of the changes it receives, such as a delegate's or a keyless agent's bookmark move (oxygenhub#266) | |
|
| 874 | 874 | | `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks | |
|
| 875 | 875 | | `checkpoint` request | `{0: [heads]}`; response a `Bundle` of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks | |
|
| 876 | 876 | | `shallow` request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`, the changes within `depth` steps of the heads, dependencies first | |
|
| … | 280 unchanged lines |
modified src/cli/sync.rs +29 −5
| … | 25 unchanged lines |
||
| 26 | 26 | }); |
|
| 27 | 27 | let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?; |
|
| 28 | 28 | let sp = ui::spinner(&format!("Cloning {url}…")); |
|
| 29 | - | let repo = match depth { |
|
| 30 | - | Some(d) => Repo::clone_shallow(url, &dest, d, max_blob, sparse)?, |
|
| 31 | - | None => Repo::clone_with(url, &dest, max_blob, sparse)?, |
|
| 32 | - | }; |
|
| 29 | + | let (repo, skipped) = Repo::clone_reporting(url, &dest, max_blob, sparse, depth)?; |
|
| 33 | 30 | let heads = repo.bookmark_heads()?; |
|
| 34 | 31 | sp.done(&format!( |
|
| 35 | 32 | "{} into {} {}", |
|
| … | 5 unchanged lines |
||
| 41 | 38 | plural(heads.len(), "head") |
|
| 42 | 39 | )) |
|
| 43 | 40 | )); |
|
| 41 | + | warn_skipped(&skipped); |
|
| 44 | 42 | let others = repo.remote_only_bookmarks()?.len(); |
|
| 45 | 43 | if others > 0 { |
|
| 46 | 44 | println!( |
|
| … | 359 unchanged lines |
||
| 406 | 404 | "log_notes", |
|
| 407 | 405 | json::arr(report.log_notes.iter().map(|n| json::s(n))), |
|
| 408 | 406 | ), |
|
| 407 | + | ("skipped", skipped_json(&report.skipped)), |
|
| 409 | 408 | ]) |
|
| 410 | 409 | ); |
|
| 411 | 410 | return Ok(0); |
|
| … | 59 unchanged lines |
||
| 471 | 470 | for n in &report.log_notes { |
|
| 472 | 471 | eprintln!("{} {n}", ui::caution()); |
|
| 473 | 472 | } |
|
| 473 | + | warn_skipped(&report.skipped); |
|
| 474 | 474 | Ok(0) |
|
| 475 | + | } |
|
| 476 | + | ||
| 477 | + | /// Bookmarks a fetch left as they were, their attestation unverifiable |
|
| 478 | + | /// (oxygenhub#266): one warning each, naming it and why. |
|
| 479 | + | fn warn_skipped(skipped: &[(String, String)]) { |
|
| 480 | + | for (name, why) in skipped { |
|
| 481 | + | eprintln!( |
|
| 482 | + | "{} {} {}", |
|
| 483 | + | ui::caution(), |
|
| 484 | + | ui::warn(format!("{name} not updated:")), |
|
| 485 | + | ui::dim(why) |
|
| 486 | + | ); |
|
| 487 | + | } |
|
| 488 | + | } |
|
| 489 | + | ||
| 490 | + | fn skipped_json(skipped: &[(String, String)]) -> String { |
|
| 491 | + | json::arr( |
|
| 492 | + | skipped |
|
| 493 | + | .iter() |
|
| 494 | + | .map(|(name, why)| json::obj([("name", json::s(name)), ("reason", json::s(why))])), |
|
| 495 | + | ) |
|
| 475 | 496 | } |
|
| 476 | 497 | ||
| 477 | 498 | /// Pull acts on the current bookmark only: name the others the fetch |
|
| … | 45 unchanged lines |
||
| 523 | 544 | ("changed", json::b(r.changed)), |
|
| 524 | 545 | ("checked_out", json::b(r.checked_out)), |
|
| 525 | 546 | ("conflicted", json::b(conflicted)), |
|
| 526 | - | ("others", json::arr(r.others.iter().map(|o| json::s(o)))) |
|
| 547 | + | ("others", json::arr(r.others.iter().map(|o| json::s(o)))), |
|
| 548 | + | ("skipped", skipped_json(&r.skipped)) |
|
| 527 | 549 | ]) |
|
| 528 | 550 | ); |
|
| 529 | 551 | return Ok(0); |
|
| … | 14 unchanged lines |
||
| 544 | 566 | )); |
|
| 545 | 567 | others_pulled(&r.others); |
|
| 546 | 568 | others_behind(&repo, &remote); |
|
| 569 | + | warn_skipped(&r.skipped); |
|
| 547 | 570 | if r.checked_out { |
|
| 548 | 571 | super::workspace::where_now(&repo); |
|
| 549 | 572 | } |
|
| … | 20 unchanged lines |
||
| 570 | 593 | } |
|
| 571 | 594 | others_pulled(&r.others); |
|
| 572 | 595 | others_behind(&repo, &remote); |
|
| 596 | + | warn_skipped(&r.skipped); |
|
| 573 | 597 | subrepos_behind(&repo); |
|
| 574 | 598 | super::workspace::where_now(&repo); |
|
| 575 | 599 | Ok(0) |
|
| … | 570 unchanged lines |