OXYGENHUB

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, &current)?;
…
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