OXYGENHUB

The issuer countersigns when it accepted a certified update (keyless step 4c) f53cbe7a204e

A host with an acceptor key (the issuer of the certificates it hands
out) records an `accepted` attestation when a reference attestation
signed by a short-lived key arrives: the attestation's ObjectRef, the
host's clock at the push, signed by the issuer. The host serves it with
the bookmark (refs entry key 5, only when there is one), and gc keeps it
beside the bookmark's latest attestation. A fetch now checks the
certificate at that time when the acceptance names exactly the
attestation and verifies with the certificate's issuer, else at the
attestation's own time as before: an expired key can no longer backdate
an update a hub accepted.

gc also keeps the certificate and issuer of every short-lived key that
signed a kept change or attestation, not only receipts': before, a gc
could leave a certified bookmark that no fresh clone could check.

Spec §14.4, §14.5, §14.7 and §16, and docs/protocol.md.

Refs oxygenhub#241
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-17 17:54 UTC
Signature
✔ signed by Andy McCall
Hash
f53cbe7a204ed350be16ea3e03c9e7ff3f00abd782ca2738b200ea58142974b4
Depends on
02e691881a4e 14edbaaf5291 3526d339519f 4161262eeb29 4dee277c327a 519c7c061bcb 5b9c6ad45f6e 750251b21a96 75167672ebcd 7ba155f20aa0 83e0677b07fe 982864068a59 b582948f1912 ba160f60219e be451356e3ef c63fed2e0465 c863c7a7a2d1 ee06a6c557b7 f0a517d28237 f8c695764c95 fc1a466a1e66 ff0af1ee299d
Depended on by
1ec6fdbc6bcb 028545dfa7a4 0ed150d62994 4b9d06778581 e5f8eb2a2006 c895bc52e4e0 a2387d547373
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 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
42 atoms, 0 tree operations, 10 files

Browse the tree at this change

modified crates/oxy-core/src/attest.rs +9 −0
…
49 unchanged lines
50 50
        environment: String,
51 51
        approve: bool,
52 52
    },
53 +
    /// A host accepted the reference attestation `attest`, at this
54 +
    /// attestation's own time, the host's clock (§14.7): the time a
55 +
    /// certificate of a short-lived key is checked at on a fetch.
56 +
    Accepted { attest: ObjectRef },
53 57
}
54 58
55 59
#[derive(Clone, Debug, PartialEq, Eq)]
…
14 unchanged lines
70 74
            AttestBody::Revoke { .. } => "revoke",
71 75
            AttestBody::Receipt { .. } => "receipt",
72 76
            AttestBody::Approval { .. } => "approval",
77 +
            AttestBody::Accepted { .. } => "accepted",
73 78
        }
74 79
    }
75 80
…
47 unchanged lines
123 128
                (1, Value::Bytes(state.0.to_vec())),
124 129
                (2, Value::Text(run.clone())),
125 130
            ]),
131 +
            AttestBody::Accepted { attest } => Value::map([(0, Value::Bytes(attest.0.to_vec()))]),
126 132
            AttestBody::Approval {
127 133
                run,
128 134
                job,
…
105 unchanged lines
234 240
                    },
235 241
                }
236 242
            }
243 +
            "accepted" => AttestBody::Accepted {
244 +
                attest: oref(field(b, 0, "attest")?, "attest")?,
245 +
            },
237 246
            other => {
238 247
                return Err(ObjectError::Schema(format!(
239 248
                    "unknown attestation kind {other:?}"
…
68 unchanged lines
modified crates/oxy-core/src/protocol.rs +18 −2
…
78 unchanged lines
79 79
    /// Serial of the latest attestation ever recorded, stale or not, so the
80 80
    /// next attestation continues the sequence.
81 81
    pub last_serial: Option<u64>,
82 +
    /// The host's acceptance of `attestation` when a short-lived key signed
83 +
    /// it (spec §14.7): the time its certificate is checked at.
84 +
    pub accepted: Option<SignedAttest>,
82 85
}
83 86
84 87
/// Everything needed to add a set of changes to a repository.
…
832 unchanged lines
917 920
                    resp.refs
918 921
                        .iter()
919 922
                        .map(|r| {
920 -
                            Value::map([
923 +
                            let mut v = Value::map([
921 924
                                (0, Value::Text(r.name.clone())),
922 925
                                (1, Value::Bytes(r.state.0.to_vec())),
923 926
                                (2, hashes_value(&r.heads)),
924 927
                                (3, opt_attest_value(&r.attestation)),
925 928
                                (4, r.last_serial.map(Value::Uint).unwrap_or(Value::Null)),
926 -
                            ])
929 +
                            ]);
930 +
                            // Only when there is one; older peers ignore it.
931 +
                            if r.accepted.is_some() {
932 +
                                if let Value::Map(m) = &mut v {
933 +
                                    m.insert(5, opt_attest_value(&r.accepted));
934 +
                                }
935 +
                            }
936 +
                            v
927 937
                        })
928 938
                        .collect(),
929 939
                ),
…
33 unchanged lines
963 973
                    None
964 974
                } else {
965 975
                    Some(o::uint(ls, "last serial")?)
976 +
                },
977 +
                accepted: match rm.get(&5) {
978 +
                    Some(a) => opt_attest_from(a)?,
979 +
                    None => None,
966 980
                },
967 981
            });
968 982
        }
…
275 unchanged lines
1244 1258
                    sigs: vec![sig.clone()],
1245 1259
                }),
1246 1260
                last_serial: Some(4),
1261 +
                accepted: None,
1247 1262
            },
1248 1263
            RemoteRef {
1249 1264
                name: "dev".into(),
…
1 unchanged lines
1251 1266
                heads: vec![],
1252 1267
                attestation: None,
1253 1268
                last_serial: None,
1269 +
                accepted: None,
1254 1270
            },
1255 1271
        ];
1256 1272
        let tag = TagRef {
…
97 unchanged lines
modified crates/oxy-core/src/repo/gc.rs +19 −2
…
92 unchanged lines
93 93
        // Reference attestations.
94 94
        for name in self.view()?.bookmarks.keys() {
95 95
            if let Some((r, _)) = self.latest_ref_attest(name)? {
96 +
                // With a host's acceptance of it (spec §14.7).
97 +
                if let Some(acc) = self.acceptance_of(&r)? {
98 +
                    attests.push(acc.attest.hash());
99 +
                }
96 100
                attests.push(r);
97 101
            }
98 102
        }
…
45 unchanged lines
144 148
                keep.insert(c.author);
145 149
            }
146 150
            for sig in self.store.signatures(&h)? {
147 -
                keep.insert(sig.key);
151 +
                self.keep_signer(&mut keep, sig.key)?;
148 152
            }
149 153
            for e in &c.edits {
150 154
                if let Edit::Replace { old, new, .. } = e {
…
12 unchanged lines
163 167
        for a in attests {
164 168
            if keep.insert(a) {
165 169
                for sig in self.store.signatures(&a)? {
166 -
                    keep.insert(sig.key);
170 +
                    self.keep_signer(&mut keep, sig.key)?;
167 171
                }
168 172
            }
169 173
        }
170 174
        Ok(keep)
175 +
    }
176 +
177 +
    /// Keep a signer's identity and, for a short-lived key, its
178 +
    /// certificate and the certificate's issuer (spec §14.7).
179 +
    pub(crate) fn keep_signer(&self, keep: &mut HashSet<ObjectRef>, key: ObjectRef) -> Result<()> {
180 +
        keep.insert(key);
181 +
        if let Some(c) = self.store.certificate_of(&key)? {
182 +
            keep.insert(c);
183 +
            if let Ok(cert) = self.store.get_certificate(&c) {
184 +
                keep.insert(cert.cert.issuer);
185 +
            }
186 +
        }
187 +
        Ok(())
171 188
    }
172 189
}
modified crates/oxy-core/src/repo/mod.rs +15 −0
…
205 unchanged lines
206 206
}
207 207
208 208
pub struct Repo {
209 +
    /// The key a host accepts certified updates with (spec §14.7): when
210 +
    /// set, accepting a reference update signed by a short-lived key also
211 +
    /// records the host's acceptance and its time.
212 +
    pub(crate) acceptor: Option<std::sync::Arc<(Identity, crate::identity::SecretKey)>>,
209 213
    /// Oxygen-pinned subrepos skipped by the running Git export.
210 214
    pub(crate) git_export_skipped: std::cell::Cell<usize>,
211 215
    /// The manifest as last read, with the file identity it was read from,
…
94 unchanged lines
306 310
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
307 311
        let store = Store::open(oxy.join("objects"))?;
308 312
        let repo = Repo {
313 +
            acceptor: None,
309 314
            last_change: Default::default(),
310 315
            at_op: None,
311 316
            walked_dirs: Default::default(),
…
49 unchanged lines
361 366
        config.save(&oxy.join("config"))?;
362 367
        let store = Store::open(oxy.join("objects"))?;
363 368
        let repo = Repo {
369 +
            acceptor: None,
364 370
            last_change: Default::default(),
365 371
            at_op: None,
366 372
            walked_dirs: Default::default(),
…
71 unchanged lines
438 444
                let config = Config::load(Some(&default_user_config()), &shared.join("config"))?;
439 445
                let store = Store::open(shared.join("objects"))?;
440 446
                let mut repo = Repo {
447 +
                    acceptor: None,
441 448
                    last_change: Default::default(),
442 449
                    at_op: None,
443 450
                    walked_dirs: Default::default(),
…
26 unchanged lines
470 477
                };
471 478
                let store = Store::open(oxy.join("objects"))?;
472 479
                let mut repo = Repo {
480 +
                    acceptor: None,
473 481
                    last_change: Default::default(),
474 482
                    at_op: None,
475 483
                    walked_dirs: Default::default(),
…
114 unchanged lines
590 598
    /// (tests, or `$OXY_HOME`). Reloads the effective configuration.
591 599
    /// Act as if the working copy were on another platform's file system
592 600
    /// (tests of Windows and macOS behaviour on any host).
601 +
    /// Accept certified updates as `identity` (a host's key), recording when
602 +
    /// (spec §14.7).
603 +
    pub fn with_acceptor(mut self, identity: Identity, sk: crate::identity::SecretKey) -> Repo {
604 +
        self.acceptor = Some(std::sync::Arc::new((identity, sk)));
605 +
        self
606 +
    }
607 +
593 608
    pub fn with_platform(mut self, platform: crate::portable::Platform) -> Repo {
594 609
        self.platform = platform;
595 610
        self
…
1552 unchanged lines
modified crates/oxy-core/src/repo/receipts.rs +1 −8
…
110 unchanged lines
111 111
            for a in self.store.receipts_for(&state)? {
112 112
                keep.insert(a);
113 113
                for sig in self.store.signatures(&a)? {
114 -
                    keep.insert(sig.key);
115 -
                    // A short-lived key's certificate and its issuer.
116 -
                    if let Some(c) = self.store.certificate_of(&sig.key)? {
117 -
                        keep.insert(c);
118 -
                        if let Ok(cert) = self.store.get_certificate(&c) {
119 -
                            keep.insert(cert.cert.issuer);
120 -
                        }
121 -
                    }
114 +
                    self.keep_signer(keep, sig.key)?;
122 115
                }
123 116
                let Ok(attest) = self.store.get_attest(&a) else {
124 117
                    continue;
…
20 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +88 −10
…
84 unchanged lines
85 85
                _ => None,
86 86
            });
87 87
            let attestation = latest.filter(|a| matches!(&a.attest.body, AttestBody::Ref { new_state, .. } if new_state == state));
88 +
            let accepted = match &attestation {
89 +
                Some(a) => self.acceptance_of(&a.attest.hash())?,
90 +
                None => None,
91 +
            };
88 92
            out.push(RemoteRef {
89 93
                name: name.clone(),
90 94
                state: *state,
91 95
                heads: self.store.get_state(state)?.heads,
92 96
                attestation,
93 97
                last_serial,
98 +
                accepted,
94 99
            });
95 100
        }
96 101
        let policy = self.current_policy()?.map(|(_, sp)| sp);
…
742 unchanged lines
839 844
        let r = self.store.put_attest(&sa.attest).map_err(|e| store(&e))?;
840 845
        for sig in &sa.sigs {
841 846
            self.store.put_signature(&r, sig).map_err(|e| store(&e))?;
847 +
        }
848 +
        self.set_ref_attest(name, &r).map_err(|e| store(&e))?;
849 +
        // Signed by a short-lived key: a host records when it accepted it.
850 +
        let certified = sa
851 +
            .sigs
852 +
            .first()
853 +
            .is_some_and(|s| self.store.certificate_of(&s.key).ok().flatten().is_some());
854 +
        if certified {
855 +
            self.record_acceptance(&r).map_err(|e| store(&e))?;
842 856
        }
843 -
        self.set_ref_attest(name, &r).map_err(|e| store(&e))
857 +
        Ok(())
844 858
    }
845 859
860 +
    /// Record that this host accepted attestation `r` now, signed with its
861 +
    /// acceptor key (spec §14.7); nothing without one.
862 +
    pub(crate) fn record_acceptance(&self, r: &ObjectRef) -> Result<()> {
863 +
        let Some(acceptor) = &self.acceptor else {
864 +
            return Ok(());
865 +
        };
866 +
        let (id, sk) = (&acceptor.0, &acceptor.1);
867 +
        let attest = Attest {
868 +
            repo: self.id()?,
869 +
            hlc: Hlc::now(),
870 +
            body: AttestBody::Accepted { attest: *r },
871 +
        };
872 +
        let sig = sk.sign(Domain::Attest, &id.hash(), &attest.hash());
873 +
        self.store.put_identity(id)?;
874 +
        let a = self.store.put_attest(&attest)?;
875 +
        self.store.put_signature(&a, &sig)?;
876 +
        self.store.index_add_pub("accepted", r, &a)?;
877 +
        Ok(())
878 +
    }
879 +
880 +
    /// The host's acceptance of attestation `r`, if one was recorded.
881 +
    pub fn acceptance_of(&self, r: &ObjectRef) -> Result<Option<SignedAttest>> {
882 +
        let Some(a) = self.store.index_get_pub("accepted", r)?.into_iter().next() else {
883 +
            return Ok(None);
884 +
        };
885 +
        Ok(Some(SignedAttest {
886 +
            attest: self.store.get_attest(&a)?,
887 +
            sigs: self.store.signatures(&a)?,
888 +
        }))
889 +
    }
890 +
891 +
    /// When a certified signer's attestation `sa` is checked on a fetch: the
892 +
    /// host's acceptance time, if `accepted` is signed by the issuer of the
893 +
    /// signer's certificate and accepts `sa`; else the attestation's own
894 +
    /// time, which the signer chose.
895 +
    pub(crate) fn certified_time(
896 +
        &self,
897 +
        signer: &KeyId,
898 +
        sa: &SignedAttest,
899 +
        accepted: Option<&SignedAttest>,
900 +
    ) -> u64 {
901 +
        let own = sa.attest.hlc.wall_ms;
902 +
        let Some(acc) = accepted else {
903 +
            return own;
904 +
        };
905 +
        let Ok(Some(c)) = self.store.certificate_of(signer) else {
906 +
            return own;
907 +
        };
908 +
        let Ok(cert) = self.store.get_certificate(&c) else {
909 +
            return own;
910 +
        };
911 +
        let AttestBody::Accepted { attest } = &acc.attest.body else {
912 +
            return own;
913 +
        };
914 +
        let Some(sig) = acc.sigs.iter().find(|s| s.key == cert.cert.issuer) else {
915 +
            return own;
916 +
        };
917 +
        let Ok(issuer) = self.store.get_identity(&cert.cert.issuer) else {
918 +
            return own;
919 +
        };
920 +
        if *attest == sa.attest.hash()
921 +
            && verify_signature(&issuer, sig, Domain::Attest, &acc.attest.hash()).is_ok()
922 +
        {
923 +
            acc.attest.hlc.wall_ms
924 +
        } else {
925 +
            own
926 +
        }
927 +
    }
928 +
846 929
    /// A deletion (spec §16.4): `new_state` is zero. The bookmark must exist
847 930
    /// at `old_state`, is never the bookmark a non-bare repository has
848 931
    /// checked out (a bare one's default is its host's to protect, see
…
523 unchanged lines
1372 1455
                    .first()
1373 1456
                    .map(|s| s.key)
1374 1457
                    .ok_or_else(|| RepoError::Msg("attestation carries no signature".into()))?;
1375 -
                // Until the host countersigns when it accepted the update,
1376 -
                // the attestation's own time is all a fetch has.
1458 +
                // A certified signer is checked at the host's acceptance,
1459 +
                // when it countersigned one; else at the attestation's time.
1460 +
                let at = self.certified_time(&signer, sa, r.accepted.as_ref());
1377 1461
                if !p.authorizes(&signer, &r.name)
1378 1462
                    && !self.delegate_authorized(p, &signer, &r.name, sa.attest.hlc.wall_ms)
1379 -
                    && !self.certified_authorized(
1380 -
                        p,
1381 -
                        &signer,
1382 -
                        &r.name,
1383 -
                        Scope::REFS,
1384 -
                        sa.attest.hlc.wall_ms,
1385 -
                    )
1463 +
                    && !self.certified_authorized(p, &signer, &r.name, Scope::REFS, at)
1386 1464
                {
1387 1465
                    return fail(format!(
1388 1466
                        "{remote_name}/{}: attested by {}, which the policy does not authorize for this bookmark; a policy owner can grant it with `oxy policy grant`",
…
683 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +48 −0
…
1063 unchanged lines
1064 1064
        .map(|c| c.message == "work")
1065 1065
        .unwrap_or(false)));
1066 1066
1067 +
    // Step 4c: a host with an acceptor key records when it accepted the
1068 +
    // certified update and serves it; a fetch checks the certificate at
1069 +
    // that time only when the certificate's issuer signed it for exactly
1070 +
    // this attestation, else at the attestation's own time.
1071 +
    let host = Repo::open(&root.join("origin"))
1072 +
        .unwrap()
1073 +
        .with_acceptor(hub.clone(), hub_sk.clone());
1074 +
    let (attest_ref, sa) = host.latest_ref_attest("main").unwrap().unwrap();
1075 +
    host.record_acceptance(&attest_ref).unwrap();
1076 +
    let served = host.remote_refs().unwrap();
1077 +
    let acc = served
1078 +
        .refs
1079 +
        .iter()
1080 +
        .find(|r| r.name == "main")
1081 +
        .unwrap()
1082 +
        .accepted
1083 +
        .clone()
1084 +
        .expect("served with the refs");
1085 +
    let signer = sa.sigs[0].key;
1086 +
    assert_eq!(
1087 +
        a.certified_time(&signer, &sa, Some(&acc)),
1088 +
        acc.attest.hlc.wall_ms
1089 +
    );
1090 +
    assert_eq!(a.certified_time(&signer, &sa, None), sa.attest.hlc.wall_ms);
1091 +
    let stranger_sk = SecretKey::generate();
1092 +
    let stranger = stranger_sk.identity("stranger", "s@x", 1);
1093 +
    let mut forged = acc.clone();
1094 +
    forged.sigs = vec![stranger_sk.sign(Domain::Attest, &stranger.hash(), &acc.attest.hash())];
1095 +
    assert_eq!(
1096 +
        a.certified_time(&signer, &sa, Some(&forged)),
1097 +
        sa.attest.hlc.wall_ms,
1098 +
        "not the issuer's"
1099 +
    );
1100 +
    let mut elsewhere = acc.clone();
1101 +
    elsewhere.attest.body = AttestBody::Accepted {
1102 +
        attest: ObjectRef([7; 32]),
1103 +
    };
1104 +
    elsewhere.sigs = vec![hub_sk.sign(Domain::Attest, &hub.hash(), &elsewhere.attest.hash())];
1105 +
    assert_eq!(
1106 +
        a.certified_time(&signer, &sa, Some(&elsewhere)),
1107 +
        sa.attest.hlc.wall_ms,
1108 +
        "another attestation's"
1109 +
    );
1110 +
    a.pull("origin").unwrap();
1111 +
    // gc keeps the acceptance with the attestation.
1112 +
    host.gc(true).unwrap();
1113 +
    assert!(host.acceptance_of(&attest_ref).unwrap().is_some());
1114 +
1067 1115
    let late = agent("late", "agent:claude@ada", now - 1_200_000, now - 600_000);
1068 1116
    let r = late.push("origin", false).unwrap();
1069 1117
    assert!(
…
6042 unchanged lines
modified crates/oxy-core/src/store.rs +16 −0
…
1372 unchanged lines
1373 1373
            .find(|r| self.has(r)))
1374 1374
    }
1375 1375
1376 +
    /// Record `value` under `key` in index `index` (for repository code).
1377 +
    pub(crate) fn index_add_pub(
1378 +
        &self,
1379 +
        index: &str,
1380 +
        key: &ObjectRef,
1381 +
        value: &ObjectRef,
1382 +
    ) -> Result<()> {
1383 +
        self.index_add(index, key, value)
1384 +
    }
1385 +
1386 +
    /// The values recorded under `key` in index `index` (which has no
1387 +
    /// older one-list form: the legacy name only has to differ from it).
1388 +
    pub(crate) fn index_get_pub(&self, index: &str, key: &ObjectRef) -> Result<Vec<ObjectRef>> {
1389 +
        self.index_get(index, &format!("{index}-lists"), key)
1390 +
    }
1391 +
1376 1392
    pub fn index_receipt(&self, state: &ObjectRef, attest: &ObjectRef) -> Result<()> {
1377 1393
        self.index_add("receipts", state, attest)
1378 1394
    }
…
696 unchanged lines
modified docs/protocol.md +1 −1
…
87 unchanged lines
88 88
| Signed policy | `[policy bytes, [Signature]]` (also the policy proposal file format) |
89 89
| Capabilities | `{0: [protocol versions], 1: repo id bytes(16), 2: [feature names]}`; version `1`; features currently `["seal"]` |
90 90
| Refs response | `{0: [ref], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}` |
91 -
| ref | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}`; the attestation is present only when it describes the current state; `last serial` is the highest attestation serial ever recorded for the name |
91 +
| ref | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}`; the attestation is present only when it describes the current state; `last serial` is the highest attestation serial ever recorded for the name; key 5 (optional, only when there is one) is the host's `accepted` attestation of that attestation (spec §14.7) |
92 92
| tag | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` |
93 93
| Negotiate request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order (no change before its dependencies) |
94 94
| Fetch request | `{0: [hashes], 1: max blob size or null}`; response a Bundle whose blobs above the size (and their chunks) are omitted |
…
109 unchanged lines
modified docs/spec.md +6 −3
…
629 unchanged lines
630 630
```
631 631
Attest {
632 632
  version [0]: 1
633 -
  kind    [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt" | "approval"
633 +
  kind    [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt" | "approval" | "accepted"
634 634
  repo    [2]: bytes(16)               // repository ID, chosen at init
635 635
  hlc     [3]: HLC
636 636
  body    [4]: map                     // per kind
…
7 unchanged lines
644 644
receipt: { receipt: ObjectRef, state: ObjectRef, run: text }   // §14.6
645 645
approval: { run: text, job: text, job_hash: ObjectRef, state: ObjectRef,
646 646
            inputs: [ObjectRef], environment: text, approve: 0 | 1 }  // sorted, unique inputs
647 +
accepted: { attest: ObjectRef }        // §14.7
647 648
```
648 649
649 650
An **approval** is a person's decision about one job of one OxygenFlow run (an approval gate, OxygenFlow spec §5.3.2): the job as hashed with its sandbox, on that state, taking exactly those input blobs (for a deploy, the reviewed plan), for that environment. It approves nothing else: another run, state, plan or environment needs another approval. It is handed to the hub as `[attest bytes, [Signature], identity bytes]`, signed in domain `attest` by the approver's own key (`oxy seal --flow URL --job NAME`, `--reject` to reject), so it verifies with nothing else; whether the signer may approve is the hub's rule (a key linked to an account with the gate's role; delegated keys only where the repository allows). The hub keeps it in the repository, so it can be verified without the hub.
…
28 unchanged lines
678 679
- The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys of the policy it replaces and have a higher `serial`. An update that a single holder cannot complete is a **proposal**: the signed policy (`[policy bytes, [Signature]]`, §16.3) is passed to other root holders, each of whom adds a signature with `oxy policy sign`; whoever completes the threshold applies it. Rotating a root key (§14.2) substitutes the successor in the roots and rules; such a policy is signed by both the previous and the successor key so a fresh clone can bootstrap from it.
679 680
- A change's signer is authorized by a rule if the rule lists it, or lists a successor of it, or lists the change's `author` (or a successor) when the signer is that author's delegate.
680 681
- A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose `delegates` name it with the `refs` scope, not expired at the attestation's `hlc.wall_ms` (§14.2). Delegation is the identity's own signed statement and the policy names that identity, so a delegate with `refs` moves references exactly as far as its delegator may; revoking either key, or the delegation expiring, ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key. A delegate without `refs` (an agent) is never authorized for a reference or tag, even when its delegator is.
681 -
- A rule may also grant **subjects an issuer certifies** (§14.7): `oxy policy grant-subject SUBJECT --issuer IDENTITY --scope SCOPES` adds `{pattern, issuer, scope}` and embeds the issuer's identity. A signer is then authorized when it is a short-lived key whose certificate verifies with that issuer, names a subject the pattern matches, gives the scope needed (`changes` for changes, `refs` for reference attestations and tags) and covers a time the signer did not choose; it counts as one signer toward the threshold. A rule with subjects only has no keys, and its threshold counts keys and subjects together. The time is the receiver's own clock when it receives a push (changes and the reference attestation), and, until a host countersigns when it accepted an update (oxygenhub docs/keyless.md, step 4c), the attestation's own time on a fetch, which is weaker: a key whose certificate expired could backdate an attestation within its scope. A policy without subjects encodes as before (field 5 is left out), so its hash is unchanged.
682 +
- A rule may also grant **subjects an issuer certifies** (§14.7): `oxy policy grant-subject SUBJECT --issuer IDENTITY --scope SCOPES` adds `{pattern, issuer, scope}` and embeds the issuer's identity. A signer is then authorized when it is a short-lived key whose certificate verifies with that issuer, names a subject the pattern matches, gives the scope needed (`changes` for changes, `refs` for reference attestations and tags) and covers a time the signer did not choose; it counts as one signer toward the threshold. A rule with subjects only has no keys, and its threshold counts keys and subjects together. The time is the receiver's own clock when it receives a push (changes and the reference attestation), and, on a fetch, the time the certificate's issuer says it accepted the update (§14.7, *Acceptance*), or the attestation's own time when there is no such acceptance, which is weaker: a key whose certificate expired could backdate an attestation within its scope. A policy without subjects encodes as before (field 5 is left out), so its hash is unchanged.
682 683
- A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of `new_state` not previously in the closure of `old_state`. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`.
683 684
- Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push.
684 685
- A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy policy init` creates one with the author's key as the sole root; `oxy policy grant` and `oxy policy revoke` produce successors.
…
46 unchanged lines
731 732
732 733
A signed certificate is `[certificate bytes, Signature]`: the issuer's composite signature, in domain `certificate` (§14.3), over the certificate's ObjectRef (type `certificate`). It is valid when the signature verifies with the identity its `issuer` names, `expires` is after `not_before` and at most an hour later, and its scope is not empty. A signature by the short-lived key names the key's KeyId as any signature does; it counts only within the certificate's scope and window, and the time it is checked at must be one the signer did not choose (a host's record of when it accepted the signature, or the transparency log's), never the signed object's own clock. Which issuers a repository trusts, and for which subjects, its trust policy says (§14.5).
733 734
735 +
**Acceptance.** A host that issues certificates records when it accepted a reference attestation signed by a short-lived key: an `accepted` attestation (§14.4) naming that attestation's ObjectRef, its `hlc` the host's clock at the push, signed in domain `attest` by the issuer's key. The host keeps it indexed by the attestation it accepts (gc keeps it beside the bookmark's latest attestation) and serves it with the bookmark (§16, `refs` key 5). A fetch checks the certificate at the acceptance's time only when the acceptance names exactly the attestation being checked and verifies with the certificate's issuer; otherwise, including when there is none, at the attestation's own time. An acceptance by any other key changes nothing: the issuer is already trusted to vouch for the key, so it adds no trust it did not have.
736 +
734 737
## 15. Transport
735 738
736 739
Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel.
…
54 unchanged lines
791 794
| `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own; a push that moves a bookmark also carries the identity that signed the move and, for a delegate, the identity it acts for, so a receiver that has seen no change by that key can check it (oxygenhub#129); key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending; key 4 (optional, only when there are any) holds signed certificates (§14.7) of short-lived keys that signed what the bundle carries or the push's attestation, their keys' and issuers' identities among the objects; a receiver keeps each that verifies with its issuer and refuses a bundle with one that does not |
792 795
| `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` |
793 796
| Signed policy | `[policy bytes, [Signature]]` |
794 -
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 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; a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` |
797 +
| `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); a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` |
795 798
| `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order |
796 799
| `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size |
797 800
| `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks |
…
280 unchanged lines