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 |