Certified keys may push under a subject grant (keyless step 4b) f0a517d28237
Bundles carry signed certificates (field 4, only when there are any; older peers ignore it) for the short-lived keys that signed their changes, seals or the push's attestation, with the keys' and issuers' identities. A receiver keeps each that verifies with its issuer and refuses a bundle with one that does not. A signer is authorized for a reference or a change when its certificate verifies, gives the scope (refs, changes), covers the receiver's own clock on a push (the attestation's time on a fetch until step 4c) and names a subject the policy grants. Revoking a key no longer drops a rule of subjects alone. Spec 14.5, 16.3. Tests: a certified agent pushes and a fetch accepts it; an expired certificate and an ungranted subject are refused; certificates round-trip in a bundle, which encodes as before without them. Refs andymccall/oxygenhub#241
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-17 17:31 UTC
- Signature
- ✔ signed by Claude Code
- Hash
f0a517d282376d51a83b818987835d0b01e0c63fc120873c8b71304a8f7e1308- Depends on
- 096f447ccda0 28fc23d7706a 2c75672fb2fe 3526d339519f 3a6effc74835 5b9c6ad45f6e 68a924846379 6eae06b6ec04 6f8aeb996558 750251b21a96 75167672ebcd 7ba155f20aa0 83e0677b07fe 86e682c242c6 982864068a59 f8c695764c95 ff0af1ee299d
- Depended on by
- 45a6f5ec85cf f0ef9778e587 1ec6fdbc6bcb d8524a9f0117 d36ba2f79f95 028545dfa7a4 a2387d547373 f53cbe7a204e
- 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
- 26 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/oxy-core/src/protocol.rs +57 −2
| … | 95 unchanged lines |
||
| 96 | 96 | /// fetch between repositories on one machine: their text blobs and |
|
| 97 | 97 | /// chunks are among `objects`. Never on the wire. |
|
| 98 | 98 | pub raw_changes: Vec<(Vec<u8>, Vec<Signature>)>, |
|
| 99 | + | /// Certificates of short-lived keys that signed what the bundle carries |
|
| 100 | + | /// (spec §14.7), their issuers' identities among `objects`. |
|
| 101 | + | pub certificates: Vec<crate::cert::SignedCertificate>, |
|
| 99 | 102 | } |
|
| 100 | 103 | ||
| 101 | 104 | impl Bundle { |
|
| … | 629 unchanged lines |
||
| 731 | 734 | } |
|
| 732 | 735 | changes.push(Value::Array(vec![Value::Bytes(bytes), sigs_value(s)])); |
|
| 733 | 736 | } |
|
| 734 | - | Value::map([ |
|
| 737 | + | let mut v = Value::map([ |
|
| 735 | 738 | (0, Value::Array(changes)), |
|
| 736 | 739 | ( |
|
| 737 | 740 | 1, |
|
| … | 18 unchanged lines |
||
| 756 | 759 | 3, |
|
| 757 | 760 | Value::Array(b.policies.iter().map(policy_value).collect()), |
|
| 758 | 761 | ), |
|
| 759 | - | ]) |
|
| 762 | + | ]); |
|
| 763 | + | // Only when there are any; older peers ignore the field. |
|
| 764 | + | if !b.certificates.is_empty() { |
|
| 765 | + | if let Value::Map(m) = &mut v { |
|
| 766 | + | m.insert( |
|
| 767 | + | 4, |
|
| 768 | + | Value::Array( |
|
| 769 | + | b.certificates |
|
| 770 | + | .iter() |
|
| 771 | + | .map(|c| Value::Bytes(c.encode())) |
|
| 772 | + | .collect(), |
|
| 773 | + | ), |
|
| 774 | + | ); |
|
| 775 | + | } |
|
| 776 | + | } |
|
| 777 | + | v |
|
| 760 | 778 | } |
|
| 761 | 779 | ||
| 762 | 780 | pub fn bundle_from_value(v: &Value) -> Result<Bundle> { |
|
| … | 51 unchanged lines |
||
| 814 | 832 | if let Some(ps) = m.get(&3) { |
|
| 815 | 833 | for p in o::array(ps, "policies")? { |
|
| 816 | 834 | b.policies.push(policy_from(p)?); |
|
| 835 | + | } |
|
| 836 | + | } |
|
| 837 | + | if let Some(cs) = m.get(&4) { |
|
| 838 | + | for c in o::array(cs, "certificates")? { |
|
| 839 | + | b.certificates.push( |
|
| 840 | + | crate::cert::SignedCertificate::decode(o::bytes(c, "certificate")?) |
|
| 841 | + | .map_err(|e| schema(&format!("certificate: {e}")))?, |
|
| 842 | + | ); |
|
| 817 | 843 | } |
|
| 818 | 844 | } |
|
| 819 | 845 | Ok(b) |
|
| … | 332 unchanged lines |
||
| 1152 | 1178 | }], |
|
| 1153 | 1179 | hashes: vec![], |
|
| 1154 | 1180 | raw_changes: vec![], |
|
| 1181 | + | certificates: vec![], |
|
| 1155 | 1182 | }; |
|
| 1156 | 1183 | let d = wire::decode_bundle(&wire::encode_bundle(&bundle)).unwrap(); |
|
| 1157 | 1184 | assert_eq!( |
|
| … | 5 unchanged lines |
||
| 1163 | 1190 | assert_eq!(d.attests, bundle.attests); |
|
| 1164 | 1191 | assert_eq!(d.objects, bundle.objects); |
|
| 1165 | 1192 | assert_eq!(d.policies, bundle.policies); |
|
| 1193 | + | assert!(d.certificates.is_empty()); |
|
| 1194 | + | // Certificates (keyless, oxygenhub#241) travel as field 4, only |
|
| 1195 | + | // when there are any: a bundle without them encodes as before. |
|
| 1196 | + | let plain = wire::encode_bundle(&bundle); |
|
| 1197 | + | let mut with = bundle.clone(); |
|
| 1198 | + | let issuer_sk = crate::identity::SecretKey::generate(); |
|
| 1199 | + | let issuer = issuer_sk.identity("hub", "h@x", 1); |
|
| 1200 | + | with.certificates.push(crate::cert::SignedCertificate::sign( |
|
| 1201 | + | crate::cert::Certificate { |
|
| 1202 | + | key: id.clone(), |
|
| 1203 | + | issuer: issuer.hash(), |
|
| 1204 | + | subject: "agent:a@b".into(), |
|
| 1205 | + | claims: vec![], |
|
| 1206 | + | scope: crate::identity::Scope::CHANGES, |
|
| 1207 | + | not_before: 1, |
|
| 1208 | + | expires: 2, |
|
| 1209 | + | }, |
|
| 1210 | + | &issuer, |
|
| 1211 | + | &issuer_sk, |
|
| 1212 | + | )); |
|
| 1213 | + | let encoded = wire::encode_bundle(&with); |
|
| 1214 | + | assert_ne!(encoded, plain); |
|
| 1215 | + | assert_eq!( |
|
| 1216 | + | wire::decode_bundle(&encoded).unwrap().certificates, |
|
| 1217 | + | with.certificates |
|
| 1218 | + | ); |
|
| 1219 | + | with.certificates.clear(); |
|
| 1220 | + | assert_eq!(wire::encode_bundle(&with), plain); |
|
| 1166 | 1221 | ||
| 1167 | 1222 | let att = Attest { |
|
| 1168 | 1223 | repo: [3; 16], |
|
| … | 125 unchanged lines |
modified crates/oxy-core/src/repo/policy.rs +13 −2
| … | 409 unchanged lines |
||
| 410 | 410 | for r in &mut p.rules { |
|
| 411 | 411 | r.keys.retain(|k| k != key); |
|
| 412 | 412 | } |
|
| 413 | - | p.rules.retain(|r| !r.keys.is_empty()); |
|
| 413 | + | // A rule of certified subjects alone stays. |
|
| 414 | + | p.rules |
|
| 415 | + | .retain(|r| !r.keys.is_empty() || !r.subjects.is_empty()); |
|
| 414 | 416 | if !p.revoked.contains(key) { |
|
| 415 | 417 | p.revoked.push(*key); |
|
| 416 | 418 | p.revoked.sort(); |
|
| … | 30 unchanged lines |
||
| 447 | 449 | .map(|c| c.author) |
|
| 448 | 450 | .map_err(|e| e.to_string())?; |
|
| 449 | 451 | let principal = if author.is_zero() { key } else { author }; |
|
| 452 | + | // A change is checked when it is received: now is a time |
|
| 453 | + | // its signer did not choose. |
|
| 450 | 454 | let covered = rule |
|
| 451 | 455 | .keys |
|
| 452 | 456 | .iter() |
|
| 453 | - | .any(|k| policy.succeeds(k, &key) || policy.succeeds(k, &principal)); |
|
| 457 | + | .any(|k| policy.succeeds(k, &key) || policy.succeeds(k, &principal)) |
|
| 458 | + | || self.certified_authorized( |
|
| 459 | + | policy, |
|
| 460 | + | &key, |
|
| 461 | + | reference, |
|
| 462 | + | crate::identity::Scope::CHANGES, |
|
| 463 | + | crate::objects::Hlc::now().wall_ms, |
|
| 464 | + | ); |
|
| 454 | 465 | if !covered { |
|
| 455 | 466 | return Err(format!( |
|
| 456 | 467 | "change {} is signed by {}, which may not update {reference:?}", |
|
| … | 48 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +129 −0
| … | 362 unchanged lines |
||
| 363 | 363 | let (ty, body) = self.store.get(&r)?; |
|
| 364 | 364 | bundle.objects.push((ty, body)); |
|
| 365 | 365 | } |
|
| 366 | + | self.attach_certificates(&mut bundle)?; |
|
| 366 | 367 | Ok(bundle) |
|
| 367 | 368 | } |
|
| 368 | 369 | ||
| 370 | + | /// Add the certificates of short-lived keys that signed what `bundle` |
|
| 371 | + | /// carries, and their issuers' identities, so a receiver can tell who |
|
| 372 | + | /// they stand for (spec §14.7). `also` names more signers (a reference |
|
| 373 | + | /// attestation sent beside the bundle). |
|
| 374 | + | pub(crate) fn attach_certificates_for( |
|
| 375 | + | &self, |
|
| 376 | + | bundle: &mut Bundle, |
|
| 377 | + | also: &[KeyId], |
|
| 378 | + | ) -> Result<()> { |
|
| 379 | + | let mut keys: BTreeSet<KeyId> = also.iter().copied().collect(); |
|
| 380 | + | keys.extend( |
|
| 381 | + | bundle |
|
| 382 | + | .changes |
|
| 383 | + | .iter() |
|
| 384 | + | .flat_map(|(_, s)| s.iter().map(|s| s.key)), |
|
| 385 | + | ); |
|
| 386 | + | keys.extend( |
|
| 387 | + | bundle |
|
| 388 | + | .raw_changes |
|
| 389 | + | .iter() |
|
| 390 | + | .flat_map(|(_, s)| s.iter().map(|s| s.key)), |
|
| 391 | + | ); |
|
| 392 | + | keys.extend( |
|
| 393 | + | bundle |
|
| 394 | + | .attests |
|
| 395 | + | .iter() |
|
| 396 | + | .flat_map(|a| a.sigs.iter().map(|s| s.key)), |
|
| 397 | + | ); |
|
| 398 | + | for k in keys { |
|
| 399 | + | let Some(r) = self.store.certificate_of(&k)? else { |
|
| 400 | + | continue; |
|
| 401 | + | }; |
|
| 402 | + | let cert = self.store.get_certificate(&r)?; |
|
| 403 | + | if bundle |
|
| 404 | + | .certificates |
|
| 405 | + | .iter() |
|
| 406 | + | .any(|c| c.cert.hash() == cert.cert.hash()) |
|
| 407 | + | { |
|
| 408 | + | continue; |
|
| 409 | + | } |
|
| 410 | + | for id in [&k, &cert.cert.issuer] { |
|
| 411 | + | if let Ok(body) = self.store.get_typed(id, ObjectType::Identity) { |
|
| 412 | + | if !bundle |
|
| 413 | + | .objects |
|
| 414 | + | .iter() |
|
| 415 | + | .any(|(t, b)| *t == ObjectType::Identity && *b == body) |
|
| 416 | + | { |
|
| 417 | + | bundle.objects.push((ObjectType::Identity, body)); |
|
| 418 | + | } |
|
| 419 | + | } |
|
| 420 | + | } |
|
| 421 | + | bundle.certificates.push(cert); |
|
| 422 | + | } |
|
| 423 | + | Ok(()) |
|
| 424 | + | } |
|
| 425 | + | ||
| 426 | + | fn attach_certificates(&self, bundle: &mut Bundle) -> Result<()> { |
|
| 427 | + | self.attach_certificates_for(bundle, &[]) |
|
| 428 | + | } |
|
| 429 | + | ||
| 430 | + | /// Keep the certificates a bundle brings once each verifies with the |
|
| 431 | + | /// identity of the issuer it names; one that does not refuses the bundle. |
|
| 432 | + | fn adopt_certificates(&self, certs: &[crate::cert::SignedCertificate]) -> Result<()> { |
|
| 433 | + | for c in certs { |
|
| 434 | + | let issuer = self.store.get_identity(&c.cert.issuer).map_err(|_| { |
|
| 435 | + | RepoError::Msg(format!( |
|
| 436 | + | "a certificate names issuer {}, whose identity did not come with it", |
|
| 437 | + | c.cert.issuer.short(12) |
|
| 438 | + | )) |
|
| 439 | + | })?; |
|
| 440 | + | c.verify(&issuer) |
|
| 441 | + | .map_err(|e| RepoError::Msg(format!("a certificate does not verify: {e}")))?; |
|
| 442 | + | self.store.put_identity(&c.cert.key)?; |
|
| 443 | + | self.store.put_certificate(c)?; |
|
| 444 | + | } |
|
| 445 | + | Ok(()) |
|
| 446 | + | } |
|
| 447 | + | ||
| 448 | + | /// A signer authorized for `reference` by what an issuer certified it |
|
| 449 | + | /// as (spec §14.7): a short-lived key with a kept certificate that |
|
| 450 | + | /// verifies with its issuer, gives `scope`, covers `at_ms` (a time the |
|
| 451 | + | /// signer did not choose) and names a subject the policy grants. |
|
| 452 | + | pub(crate) fn certified_authorized( |
|
| 453 | + | &self, |
|
| 454 | + | p: &crate::policy::Policy, |
|
| 455 | + | signer: &KeyId, |
|
| 456 | + | reference: &str, |
|
| 457 | + | scope: Scope, |
|
| 458 | + | at_ms: u64, |
|
| 459 | + | ) -> bool { |
|
| 460 | + | let Ok(Some(r)) = self.store.certificate_of(signer) else { |
|
| 461 | + | return false; |
|
| 462 | + | }; |
|
| 463 | + | let Ok(c) = self.store.get_certificate(&r) else { |
|
| 464 | + | return false; |
|
| 465 | + | }; |
|
| 466 | + | let Some(issuer) = p |
|
| 467 | + | .identity(&c.cert.issuer) |
|
| 468 | + | .cloned() |
|
| 469 | + | .or_else(|| self.store.get_identity(&c.cert.issuer).ok()) |
|
| 470 | + | else { |
|
| 471 | + | return false; |
|
| 472 | + | }; |
|
| 473 | + | c.verify(&issuer).is_ok() |
|
| 474 | + | && c.cert.key_id() == *signer |
|
| 475 | + | && c.cert.covers(scope, at_ms) |
|
| 476 | + | && p.subject_grant(reference, &c.cert.issuer, &c.cert.subject, scope) |
|
| 477 | + | } |
|
| 478 | + | ||
| 369 | 479 | /// Store a bundle's contents. With `strict`, every change must |
|
| 370 | 480 | /// authenticate (signature or seal by its author or a delegate). |
|
| 371 | 481 | pub fn import_bundle(&self, bundle: &Bundle, strict: bool) -> Result<Vec<ChangeHash>> { |
|
| … | 43 unchanged lines |
||
| 415 | 525 | .map(|(ty, body)| (*ty, body.as_slice())) |
|
| 416 | 526 | .collect(); |
|
| 417 | 527 | self.store.put_many(&objects)?; |
|
| 528 | + | self.adopt_certificates(&bundle.certificates)?; |
|
| 418 | 529 | for sa in &bundle.attests { |
|
| 419 | 530 | let r = self.store.put_attest(&sa.attest)?; |
|
| 420 | 531 | for sig in &sa.sigs { |
|
| … | 348 unchanged lines |
||
| 769 | 880 | .first() |
|
| 770 | 881 | .map(|s| s.key) |
|
| 771 | 882 | .ok_or("attestation carries no signature")?; |
|
| 883 | + | // Received now: the receiver's own clock is the trusted time. |
|
| 884 | + | let now = Hlc::now().wall_ms; |
|
| 772 | 885 | if !p.authorizes(&signer, &u.name) |
|
| 773 | 886 | && !self.delegate_authorized(p, &signer, &u.name, sa.attest.hlc.wall_ms) |
|
| 887 | + | && !self.certified_authorized(p, &signer, &u.name, Scope::REFS, now) |
|
| 774 | 888 | { |
|
| 775 | 889 | return Err(format!( |
|
| 776 | 890 | "key {} may not delete {:?} under this repository's policy", |
|
| … | 68 unchanged lines |
||
| 845 | 959 | .first() |
|
| 846 | 960 | .map(|s| s.key) |
|
| 847 | 961 | .ok_or("attestation carries no signature")?; |
|
| 962 | + | // Received now: the receiver's own clock is the trusted time. |
|
| 963 | + | let now = Hlc::now().wall_ms; |
|
| 848 | 964 | if !p.authorizes(&signer, &u.name) |
|
| 849 | 965 | && !self.delegate_authorized(p, &signer, &u.name, sa.attest.hlc.wall_ms) |
|
| 966 | + | && !self.certified_authorized(p, &signer, &u.name, Scope::REFS, now) |
|
| 850 | 967 | { |
|
| 851 | 968 | return Err(format!( |
|
| 852 | 969 | "key {} may not update {:?} under this repository's policy", |
|
| … | 402 unchanged lines |
||
| 1255 | 1372 | .first() |
|
| 1256 | 1373 | .map(|s| s.key) |
|
| 1257 | 1374 | .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. |
|
| 1258 | 1377 | if !p.authorizes(&signer, &r.name) |
|
| 1259 | 1378 | && !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 | + | ) |
|
| 1260 | 1386 | { |
|
| 1261 | 1387 | return fail(format!( |
|
| 1262 | 1388 | "{remote_name}/{}: attested by {}, which the policy does not authorize for this bookmark; a policy owner can grant it with `oxy policy grant`", |
|
| … | 434 unchanged lines |
||
| 1697 | 1823 | } |
|
| 1698 | 1824 | } |
|
| 1699 | 1825 | } |
|
| 1826 | + | // A short-lived signing key's certificate goes with it (§14.7). |
|
| 1827 | + | let signers: Vec<KeyId> = self.signing_key().into_iter().collect(); |
|
| 1828 | + | self.attach_certificates_for(&mut bundle, &signers)?; |
|
| 1700 | 1829 | } |
|
| 1701 | 1830 | let serial = attestation.as_ref().and_then(|a| match a.attest.body { |
|
| 1702 | 1831 | AttestBody::Ref { serial, .. } => Some(serial), |
|
| … | 240 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +92 −0
| … | 985 unchanged lines |
||
| 986 | 986 | ); |
|
| 987 | 987 | } |
|
| 988 | 988 | ||
| 989 | + | /// Keyless step 4 (oxygenhub#241): an agent signing with a short-lived |
|
| 990 | + | /// key whose certificate an issuer gave pushes when the policy grants its |
|
| 991 | + | /// subject, and a fetch accepts what it attested; an expired certificate, |
|
| 992 | + | /// or a subject the policy does not grant, may not move the bookmark. |
|
| 989 | 993 | #[test] |
|
| 994 | + | fn a_certified_key_pushes_under_a_subject_grant() { |
|
| 995 | + | use crate::cert::{Certificate, SignedCertificate}; |
|
| 996 | + | use crate::identity::Scope; |
|
| 997 | + | hermetic(); |
|
| 998 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 999 | + | let root = dir.path(); |
|
| 1000 | + | let _origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 1001 | + | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 1002 | + | let mut a = Repo::clone(&url, &root.join("a")) |
|
| 1003 | + | .unwrap() |
|
| 1004 | + | .with_home(root.join("home-a")); |
|
| 1005 | + | a.create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 1006 | + | .unwrap(); |
|
| 1007 | + | write(&root.join("a"), "a.txt", b"a\n"); |
|
| 1008 | + | a.commit("a", &[]).unwrap(); |
|
| 1009 | + | a.policy_init().unwrap(); |
|
| 1010 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1011 | + | let hub_sk = SecretKey::generate(); |
|
| 1012 | + | let hub = hub_sk.identity("hub", "[email protected]", 1); |
|
| 1013 | + | a.policy_grant_subject( |
|
| 1014 | + | "agent:claude@ada", |
|
| 1015 | + | hub.clone(), |
|
| 1016 | + | Scope::CHANGES | Scope::REFS, |
|
| 1017 | + | None, |
|
| 1018 | + | ) |
|
| 1019 | + | .unwrap(); |
|
| 1020 | + | assert!(a |
|
| 1021 | + | .push("origin", false) |
|
| 1022 | + | .unwrap() |
|
| 1023 | + | .iter() |
|
| 1024 | + | .any(|r| r.name == "policy" && r.ok)); |
|
| 1025 | + | ||
| 1026 | + | let now = Hlc::now().wall_ms; |
|
| 1027 | + | // An agent repository whose key the hub certified for `subject`, from |
|
| 1028 | + | // `from` to `to`. |
|
| 1029 | + | let agent = |name: &str, subject: &str, from: u64, to: u64| { |
|
| 1030 | + | let mut r = Repo::clone(&url, &root.join(name)) |
|
| 1031 | + | .unwrap() |
|
| 1032 | + | .with_home(root.join(format!("home-{name}"))); |
|
| 1033 | + | let (_, id) = r |
|
| 1034 | + | .create_identity(name, "[email protected]", IdentityScope::User) |
|
| 1035 | + | .unwrap(); |
|
| 1036 | + | let cert = SignedCertificate::sign( |
|
| 1037 | + | Certificate { |
|
| 1038 | + | key: id, |
|
| 1039 | + | issuer: hub.hash(), |
|
| 1040 | + | subject: subject.into(), |
|
| 1041 | + | claims: vec![], |
|
| 1042 | + | scope: Scope::CHANGES | Scope::REFS, |
|
| 1043 | + | not_before: from, |
|
| 1044 | + | expires: to, |
|
| 1045 | + | }, |
|
| 1046 | + | &hub, |
|
| 1047 | + | &hub_sk, |
|
| 1048 | + | ); |
|
| 1049 | + | r.store().put_identity(&hub).unwrap(); |
|
| 1050 | + | r.store().put_certificate(&cert).unwrap(); |
|
| 1051 | + | write(&root.join(name), &format!("{name}.txt"), b"agent work\n"); |
|
| 1052 | + | r.commit("work", &[]).unwrap(); |
|
| 1053 | + | r |
|
| 1054 | + | }; |
|
| 1055 | + | ||
| 1056 | + | let claude = agent("claude", "agent:claude@ada", now - 60_000, now + 600_000); |
|
| 1057 | + | let r = claude.push("origin", false).unwrap(); |
|
| 1058 | + | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 1059 | + | // Ada's fetch accepts the bookmark the certified key attested. |
|
| 1060 | + | a.pull("origin").unwrap(); |
|
| 1061 | + | assert!(a.bookmark_heads().unwrap().iter().any(|h| a |
|
| 1062 | + | .store() |
|
| 1063 | + | .get_change(h) |
|
| 1064 | + | .map(|c| c.message == "work") |
|
| 1065 | + | .unwrap_or(false))); |
|
| 1066 | + | ||
| 1067 | + | let late = agent("late", "agent:claude@ada", now - 1_200_000, now - 600_000); |
|
| 1068 | + | let r = late.push("origin", false).unwrap(); |
|
| 1069 | + | assert!( |
|
| 1070 | + | !r[0].ok && r[0].reason.contains("may not update"), |
|
| 1071 | + | "an expired certificate: {r:?}" |
|
| 1072 | + | ); |
|
| 1073 | + | let other = agent("other", "agent:mallory@ada", now - 60_000, now + 600_000); |
|
| 1074 | + | let r = other.push("origin", false).unwrap(); |
|
| 1075 | + | assert!( |
|
| 1076 | + | !r[0].ok && r[0].reason.contains("may not update"), |
|
| 1077 | + | "a subject not granted: {r:?}" |
|
| 1078 | + | ); |
|
| 1079 | + | } |
|
| 1080 | + | ||
| 1081 | + | #[test] |
|
| 990 | 1082 | fn trust_policy_governs_pushes_and_fetches() { |
|
| 991 | 1083 | hermetic(); |
|
| 992 | 1084 | let dir = tempfile::tempdir().unwrap(); |
|
| … | 5979 unchanged lines |
modified docs/spec.md +2 −2
| … | 673 unchanged lines |
||
| 674 | 674 | - 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. |
|
| 675 | 675 | - 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. |
|
| 676 | 676 | - 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. |
|
| 677 | - | - 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. A policy without subjects encodes as before (field 5 is left out), so its hash is unchanged. |
|
| 677 | + | - 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. |
|
| 678 | 678 | - 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`. |
|
| 679 | 679 | - 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. |
|
| 680 | 680 | - 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. |
|
| … | 82 unchanged lines |
||
| 763 | 763 | | Message | Encoding | |
|
| 764 | 764 | |---|---| |
|
| 765 | 765 | | Signed attestation | `[attest bytes, [Signature]]` | |
|
| 766 | - | | `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 | |
|
| 766 | + | | `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 | |
|
| 767 | 767 | | `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` | |
|
| 768 | 768 | | Signed policy | `[policy bytes, [Signature]]` | |
|
| 769 | 769 | | `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}` | |
|
| … | 283 unchanged lines |