Grants let certified keys act for an author (keyless step 5a) a2387d547373
An author signs a grant (spec §14.7: grantor, subject, issuer, scope, window; domain and type `grant`) letting the keys an issuer certifies for a subject such as agent:claude@andymccall act for them, as a delegate does but with no key of their own. The issuer puts the signed grant in each certificate it issues under it (certificate field 8, only when present), so it travels with the certificate. The author-or-delegate rule now accepts a signer whose certificate verifies with its issuer and carries a grant that verifies with the change's author, matches the certificate's subject and issuer, gives the scope and covers the change's time. The policy still checks the certificate at a time the signer did not choose. A push now seals the configured author's unsigned changes when a separate signer (author.signer: a delegate, or a key under a grant) signs for them, so a ten-minute key need not sign at commit; the receiver still checks it may. fsck decodes grants. Refs oxygenhub#241
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-17 18:43 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
a2387d547373968f42202e7e5f2897e504b01b03777c470089a4c74cdca13f2d- Depends on
- 02e691881a4e 05e71ed6cdf1 098227d48b1a 1983686fb7a7 387335ff7f4d 5b9c6ad45f6e 7ba155f20aa0 83e0677b07fe 982864068a59 b0e64e81e4fb c63fed2e0465 cb8b8082fa73 f0a517d28237 f53cbe7a204e fc1a466a1e66 ff0af1ee299d
- Depended on by
- 6c3dbe2018e0 45a6f5ec85cf 1ec6fdbc6bcb 028545dfa7a4 e5f8eb2a2006 68dcf21fe055 c895bc52e4e0
- 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
- 38 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified crates/oxy-core/src/cert.rs +189 −2
| … | 33 unchanged lines |
||
| 34 | 34 | /// Valid from and until, in wall milliseconds. |
|
| 35 | 35 | pub not_before: u64, |
|
| 36 | 36 | pub expires: u64, |
|
| 37 | + | /// The grant that lets the subject act for an author, when an agent |
|
| 38 | + | /// asked with one (field 8, only when present). |
|
| 39 | + | pub grant: Option<SignedGrant>, |
|
| 37 | 40 | } |
|
| 38 | 41 | ||
| 39 | 42 | impl Certificate { |
|
| 40 | 43 | pub fn to_value(&self) -> Value { |
|
| 41 | 44 | let mut claims = self.claims.clone(); |
|
| 42 | 45 | claims.sort(); |
|
| 43 | - | Value::map([ |
|
| 46 | + | let mut v = Value::map([ |
|
| 44 | 47 | (0, Value::Uint(1)), |
|
| 45 | 48 | (1, Value::Bytes(self.key.encode())), |
|
| 46 | 49 | (2, Value::Bytes(self.issuer.0.to_vec())), |
|
| … | 19 unchanged lines |
||
| 66 | 69 | ), |
|
| 67 | 70 | (6, Value::Uint(self.not_before)), |
|
| 68 | 71 | (7, Value::Uint(self.expires)), |
|
| 69 | - | ]) |
|
| 72 | + | ]); |
|
| 73 | + | if let (Some(g), Value::Map(m)) = (&self.grant, &mut v) { |
|
| 74 | + | m.insert(8, Value::Bytes(g.encode())); |
|
| 75 | + | } |
|
| 76 | + | v |
|
| 70 | 77 | } |
|
| 71 | 78 | ||
| 72 | 79 | pub fn encode(&self) -> Vec<u8> { |
|
| … | 41 unchanged lines |
||
| 114 | 121 | scope, |
|
| 115 | 122 | not_before: uint(field(m, 6, "not_before")?, "not_before")?, |
|
| 116 | 123 | expires: uint(field(m, 7, "expires")?, "expires")?, |
|
| 124 | + | grant: match m.get(&8) { |
|
| 125 | + | Some(g) => Some( |
|
| 126 | + | SignedGrant::decode(bytes(g, "grant")?) |
|
| 127 | + | .map_err(|e| ObjectError::Schema(format!("grant: {e}")))?, |
|
| 128 | + | ), |
|
| 129 | + | None => None, |
|
| 130 | + | }, |
|
| 117 | 131 | }) |
|
| 118 | 132 | } |
|
| 119 | 133 | ||
| … | 13 unchanged lines |
||
| 133 | 147 | .find(|(k, _)| k == name) |
|
| 134 | 148 | .map(|(_, v)| v.as_str()) |
|
| 135 | 149 | } |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | /// An author's grant (spec §14.7): it lets the keys an issuer certifies |
|
| 153 | + | /// for `subject` act for the author, as a delegate does, without a key of |
|
| 154 | + | /// their own. An agent's fresh keys then sign and seal changes in the |
|
| 155 | + | /// author's name while the grant lasts; the grant travels inside their |
|
| 156 | + | /// certificates, so a verifier needs nothing else. Signed by the author. |
|
| 157 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 158 | + | pub struct Grant { |
|
| 159 | + | /// The author's KeyId, which signs the grant. |
|
| 160 | + | pub grantor: KeyId, |
|
| 161 | + | /// The subject whose certified keys act for it: `agent:NAME@ACCOUNT`. |
|
| 162 | + | pub subject: String, |
|
| 163 | + | /// The issuer whose certificates count. |
|
| 164 | + | pub issuer: KeyId, |
|
| 165 | + | /// What they may do for the author: changes, seal. |
|
| 166 | + | pub scope: Scope, |
|
| 167 | + | /// Valid from and until, in wall milliseconds. |
|
| 168 | + | pub not_before: u64, |
|
| 169 | + | pub expires: u64, |
|
| 136 | 170 | } |
|
| 171 | + | ||
| 172 | + | impl Grant { |
|
| 173 | + | pub fn to_value(&self) -> Value { |
|
| 174 | + | Value::map([ |
|
| 175 | + | (0, Value::Uint(1)), |
|
| 176 | + | (1, Value::Bytes(self.grantor.0.to_vec())), |
|
| 177 | + | (2, Value::Text(self.subject.clone())), |
|
| 178 | + | (3, Value::Bytes(self.issuer.0.to_vec())), |
|
| 179 | + | ( |
|
| 180 | + | 4, |
|
| 181 | + | Value::Array( |
|
| 182 | + | self.scope |
|
| 183 | + | .names() |
|
| 184 | + | .into_iter() |
|
| 185 | + | .map(|n| Value::Text(n.to_string())) |
|
| 186 | + | .collect(), |
|
| 187 | + | ), |
|
| 188 | + | ), |
|
| 189 | + | (5, Value::Uint(self.not_before)), |
|
| 190 | + | (6, Value::Uint(self.expires)), |
|
| 191 | + | ]) |
|
| 192 | + | } |
|
| 193 | + | ||
| 194 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 195 | + | cbor::encode(&self.to_value()) |
|
| 196 | + | } |
|
| 197 | + | ||
| 198 | + | pub fn hash(&self) -> ObjectRef { |
|
| 199 | + | hash_object(ObjectType::Grant, &self.encode()) |
|
| 200 | + | } |
|
| 137 | 201 | ||
| 202 | + | pub fn decode(b: &[u8]) -> Result<Grant, ObjectError> { |
|
| 203 | + | let v = cbor::decode(b)?; |
|
| 204 | + | let m = map(&v, "grant")?; |
|
| 205 | + | check_version(m)?; |
|
| 206 | + | let mut scope = Scope::NONE; |
|
| 207 | + | for n in array(field(m, 4, "scope")?, "scope")? { |
|
| 208 | + | let n = text(n, "scope")?; |
|
| 209 | + | scope = scope |
|
| 210 | + | | Scope::named(n) |
|
| 211 | + | .ok_or_else(|| ObjectError::Schema(format!("grant: unknown scope {n:?}")))?; |
|
| 212 | + | } |
|
| 213 | + | Ok(Grant { |
|
| 214 | + | grantor: oref(field(m, 1, "grantor")?, "grantor")?, |
|
| 215 | + | subject: text(field(m, 2, "subject")?, "subject")?.to_owned(), |
|
| 216 | + | issuer: oref(field(m, 3, "issuer")?, "issuer")?, |
|
| 217 | + | scope, |
|
| 218 | + | not_before: uint(field(m, 5, "not_before")?, "not_before")?, |
|
| 219 | + | expires: uint(field(m, 6, "expires")?, "expires")?, |
|
| 220 | + | }) |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | /// Whether it lets its subject do `scope` for the author at `at_ms` |
|
| 224 | + | /// (the change's own time, as for a delegation). |
|
| 225 | + | pub fn covers(&self, scope: Scope, at_ms: u64) -> bool { |
|
| 226 | + | !scope.is_empty() |
|
| 227 | + | && self.scope.has(scope) |
|
| 228 | + | && self.not_before <= at_ms |
|
| 229 | + | && at_ms <= self.expires |
|
| 230 | + | } |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | /// A grant with its author's signature over its ObjectRef. |
|
| 234 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 235 | + | pub struct SignedGrant { |
|
| 236 | + | pub grant: Grant, |
|
| 237 | + | pub sig: Signature, |
|
| 238 | + | } |
|
| 239 | + | ||
| 240 | + | impl SignedGrant { |
|
| 241 | + | /// Grant `grant`, signed by the author `grantor` holds the secret of. |
|
| 242 | + | pub fn sign(grant: Grant, grantor: &Identity, sk: &SecretKey) -> SignedGrant { |
|
| 243 | + | let sig = sk.sign(Domain::Grant, &grantor.hash(), &grant.hash()); |
|
| 244 | + | SignedGrant { grant, sig } |
|
| 245 | + | } |
|
| 246 | + | ||
| 247 | + | /// Signed by `grantor`, which it names, and well formed. |
|
| 248 | + | pub fn verify(&self, grantor: &Identity) -> Result<(), CertError> { |
|
| 249 | + | let g = &self.grant; |
|
| 250 | + | if grantor.hash() != g.grantor || self.sig.key != g.grantor { |
|
| 251 | + | return Err(CertError::WrongIssuer(g.grantor.short(12))); |
|
| 252 | + | } |
|
| 253 | + | verify(grantor, &self.sig, Domain::Grant, &g.hash()) |
|
| 254 | + | .map_err(|e| CertError::BadSignature(e.to_string()))?; |
|
| 255 | + | if g.expires <= g.not_before { |
|
| 256 | + | return Err(CertError::BadLifetime); |
|
| 257 | + | } |
|
| 258 | + | if g.scope.is_empty() { |
|
| 259 | + | return Err(CertError::NoScope); |
|
| 260 | + | } |
|
| 261 | + | Ok(()) |
|
| 262 | + | } |
|
| 263 | + | ||
| 264 | + | /// `[grant bytes, signature]`. |
|
| 265 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 266 | + | cbor::encode(&Value::Array(vec![ |
|
| 267 | + | Value::Bytes(self.grant.encode()), |
|
| 268 | + | self.sig.to_value(), |
|
| 269 | + | ])) |
|
| 270 | + | } |
|
| 271 | + | ||
| 272 | + | pub fn decode(b: &[u8]) -> Result<SignedGrant, CertError> { |
|
| 273 | + | let v = cbor::decode(b).map_err(ObjectError::from)?; |
|
| 274 | + | let a = array(&v, "signed grant")?; |
|
| 275 | + | if a.len() != 2 { |
|
| 276 | + | return Err(CertError::Schema( |
|
| 277 | + | "signed grant: expected [grant, signature]".into(), |
|
| 278 | + | )); |
|
| 279 | + | } |
|
| 280 | + | Ok(SignedGrant { |
|
| 281 | + | grant: Grant::decode(bytes(&a[0], "grant")?)?, |
|
| 282 | + | sig: Signature::from_value(&a[1])?, |
|
| 283 | + | }) |
|
| 284 | + | } |
|
| 285 | + | } |
|
| 286 | + | ||
| 138 | 287 | /// What a fresh key signs (a challenge, §14.3) to show its holder asks for |
|
| 139 | 288 | /// the certificate: bound to the credential it is asked with, so the |
|
| 140 | 289 | /// proof cannot be replayed with another. |
|
| … | 101 unchanged lines |
||
| 242 | 391 | scope: Scope::CHANGES | Scope::REFS, |
|
| 243 | 392 | not_before: 1_000, |
|
| 244 | 393 | expires: 1_000 + minutes * 60_000, |
|
| 394 | + | grant: None, |
|
| 245 | 395 | }; |
|
| 246 | 396 | (job_sk, SignedCertificate::sign(cert, issuer, issuer_sk)) |
|
| 247 | 397 | } |
|
| … | 28 unchanged lines |
||
| 276 | 426 | stolen.verify(&hub), |
|
| 277 | 427 | Err(CertError::BadSignature(_)) |
|
| 278 | 428 | )); |
|
| 429 | + | } |
|
| 430 | + | ||
| 431 | + | #[test] |
|
| 432 | + | fn a_grant_rides_in_its_certificate_and_verifies_with_its_author_only() { |
|
| 433 | + | let hub_sk = SecretKey::generate(); |
|
| 434 | + | let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
| 435 | + | let ada_sk = SecretKey::generate(); |
|
| 436 | + | let ada = ada_sk.identity("Ada", "[email protected]", 1); |
|
| 437 | + | let g = SignedGrant::sign( |
|
| 438 | + | Grant { |
|
| 439 | + | grantor: ada.hash(), |
|
| 440 | + | subject: "agent:claude@ada".into(), |
|
| 441 | + | issuer: hub.hash(), |
|
| 442 | + | scope: Scope::CHANGES | Scope::SEAL, |
|
| 443 | + | not_before: 1_000, |
|
| 444 | + | expires: 2_000, |
|
| 445 | + | }, |
|
| 446 | + | &ada, |
|
| 447 | + | &ada_sk, |
|
| 448 | + | ); |
|
| 449 | + | assert!(g.verify(&ada).is_ok()); |
|
| 450 | + | assert!(g.verify(&hub).is_err(), "only its author's"); |
|
| 451 | + | assert!(g.grant.covers(Scope::SEAL, 1_500)); |
|
| 452 | + | assert!(!g.grant.covers(Scope::REFS, 1_500)); |
|
| 453 | + | assert!(!g.grant.covers(Scope::SEAL, 2_001)); |
|
| 454 | + | let (_, plain) = issue(&hub_sk, &hub, 10); |
|
| 455 | + | assert!( |
|
| 456 | + | !matches!(cbor::decode(&plain.cert.encode()).unwrap(), Value::Map(m) if m.contains_key(&8)), |
|
| 457 | + | "no grant, no field 8" |
|
| 458 | + | ); |
|
| 459 | + | let mut with = plain.cert.clone(); |
|
| 460 | + | with.grant = Some(g.clone()); |
|
| 461 | + | let sc = SignedCertificate::sign(with, &hub, &hub_sk); |
|
| 462 | + | let back = SignedCertificate::decode(&sc.encode()).unwrap(); |
|
| 463 | + | assert_eq!(back.cert.grant, Some(g)); |
|
| 464 | + | assert!(back.verify(&hub).is_ok()); |
|
| 465 | + | assert_ne!(back.cert.hash(), plain.cert.hash()); |
|
| 279 | 466 | } |
|
| 280 | 467 | ||
| 281 | 468 | #[test] |
|
| … | 45 unchanged lines |
modified crates/oxy-core/src/hash.rs +5 −1
| … | 65 unchanged lines |
||
| 66 | 66 | Receipt, |
|
| 67 | 67 | /// A short-lived key's certificate (oxygenhub#241). |
|
| 68 | 68 | Certificate, |
|
| 69 | + | /// An author's grant to a certified subject (§14.7). |
|
| 70 | + | Grant, |
|
| 69 | 71 | } |
|
| 70 | 72 | ||
| 71 | 73 | impl ObjectType { |
|
| 72 | - | pub const ALL: [ObjectType; 11] = [ |
|
| 74 | + | pub const ALL: [ObjectType; 12] = [ |
|
| 73 | 75 | ObjectType::Chunk, |
|
| 74 | 76 | ObjectType::Blob, |
|
| 75 | 77 | ObjectType::Change, |
|
| … | 5 unchanged lines |
||
| 81 | 83 | ObjectType::State, |
|
| 82 | 84 | ObjectType::Receipt, |
|
| 83 | 85 | ObjectType::Certificate, |
|
| 86 | + | ObjectType::Grant, |
|
| 84 | 87 | ]; |
|
| 85 | 88 | ||
| 86 | 89 | pub fn name(self) -> &'static str { |
|
| … | 9 unchanged lines |
||
| 96 | 99 | ObjectType::State => "state", |
|
| 97 | 100 | ObjectType::Receipt => "receipt", |
|
| 98 | 101 | ObjectType::Certificate => "certificate", |
|
| 102 | + | ObjectType::Grant => "grant", |
|
| 99 | 103 | } |
|
| 100 | 104 | } |
|
| 101 | 105 | ||
| … | 59 unchanged lines |
modified crates/oxy-core/src/identity.rs +3 −0
| … | 320 unchanged lines |
||
| 321 | 321 | Challenge, |
|
| 322 | 322 | /// A certificate an issuer gives a short-lived key (§14.7). |
|
| 323 | 323 | Certificate, |
|
| 324 | + | /// An author's grant to a certified subject (§14.7). |
|
| 325 | + | Grant, |
|
| 324 | 326 | } |
|
| 325 | 327 | ||
| 326 | 328 | impl Domain { |
|
| … | 4 unchanged lines |
||
| 331 | 333 | Domain::Policy => "policy", |
|
| 332 | 334 | Domain::Challenge => "challenge", |
|
| 333 | 335 | Domain::Certificate => "certificate", |
|
| 336 | + | Domain::Grant => "grant", |
|
| 334 | 337 | } |
|
| 335 | 338 | } |
|
| 336 | 339 | } |
|
| … | 433 unchanged lines |
modified crates/oxy-core/src/protocol.rs +1 −0
| … | 1224 unchanged lines |
||
| 1225 | 1225 | scope: crate::identity::Scope::CHANGES, |
|
| 1226 | 1226 | not_before: 1, |
|
| 1227 | 1227 | expires: 2, |
|
| 1228 | + | grant: None, |
|
| 1228 | 1229 | }, |
|
| 1229 | 1230 | &issuer, |
|
| 1230 | 1231 | &issuer_sk, |
|
| … | 139 unchanged lines |
modified crates/oxy-core/src/receipt.rs +1 −0
| … | 369 unchanged lines |
||
| 370 | 370 | scope, |
|
| 371 | 371 | not_before: from, |
|
| 372 | 372 | expires: to, |
|
| 373 | + | grant: None, |
|
| 373 | 374 | }, |
|
| 374 | 375 | &hub, |
|
| 375 | 376 | &hub_sk, |
|
| … | 56 unchanged lines |
modified crates/oxy-core/src/repo/fsck.rs +1 −0
| … | 101 unchanged lines |
||
| 102 | 102 | ObjectType::Policy => crate::policy::Policy::decode(body).map(|_| ()), |
|
| 103 | 103 | ObjectType::Receipt => crate::receipt::Receipt::decode(body).map(|_| ()), |
|
| 104 | 104 | ObjectType::Certificate => crate::cert::Certificate::decode(body).map(|_| ()), |
|
| 105 | + | ObjectType::Grant => crate::cert::Grant::decode(body).map(|_| ()), |
|
| 105 | 106 | ObjectType::State => crate::objects::StateObject::decode(body).map(|_| ()), |
|
| 106 | 107 | }; |
|
| 107 | 108 | r.map_err(|e| e.to_string()) |
|
| … | 406 unchanged lines |
modified crates/oxy-core/src/repo/identity.rs +82 −6
| … | 459 unchanged lines |
||
| 460 | 460 | Ok(SignatureStatus::Invalid(last_err)) |
|
| 461 | 461 | } |
|
| 462 | 462 | ||
| 463 | + | /// Whether `signer` is a short-lived key acting for `author` under a |
|
| 464 | + | /// grant (spec §14.7): its certificate verifies with its issuer and |
|
| 465 | + | /// carries a grant `author` signed, for the certificate's subject and |
|
| 466 | + | /// issuer, giving `needed` at `at_ms` (the change's time, as for a |
|
| 467 | + | /// delegation). Whether the certificate covered the time it signed is |
|
| 468 | + | /// the policy's check, at a time the signer did not choose. `None` |
|
| 469 | + | /// when it does; otherwise why not. |
|
| 470 | + | fn granted( |
|
| 471 | + | &self, |
|
| 472 | + | author: &KeyId, |
|
| 473 | + | signer: &KeyId, |
|
| 474 | + | needed: Scope, |
|
| 475 | + | at_ms: u64, |
|
| 476 | + | ) -> std::result::Result<Option<String>, String> { |
|
| 477 | + | let neither = || { |
|
| 478 | + | Some(format!( |
|
| 479 | + | "key {} is neither the author nor a delegate", |
|
| 480 | + | signer.short(12) |
|
| 481 | + | )) |
|
| 482 | + | }; |
|
| 483 | + | let Ok(Some(c)) = self.store.certificate_of(signer) else { |
|
| 484 | + | return Ok(neither()); |
|
| 485 | + | }; |
|
| 486 | + | let Ok(sc) = self.store.get_certificate(&c) else { |
|
| 487 | + | return Ok(neither()); |
|
| 488 | + | }; |
|
| 489 | + | let Some(g) = &sc.cert.grant else { |
|
| 490 | + | return Ok(neither()); |
|
| 491 | + | }; |
|
| 492 | + | let who = signer.short(12); |
|
| 493 | + | let issuer = self |
|
| 494 | + | .store |
|
| 495 | + | .get_identity(&sc.cert.issuer) |
|
| 496 | + | .map_err(|_| format!("key {who}'s certificate names an unknown issuer"))?; |
|
| 497 | + | sc.verify(&issuer) |
|
| 498 | + | .map_err(|e| format!("key {who}'s certificate: {e}"))?; |
|
| 499 | + | if g.grant.grantor != *author { |
|
| 500 | + | return Ok(neither()); |
|
| 501 | + | } |
|
| 502 | + | let grantor = self |
|
| 503 | + | .store |
|
| 504 | + | .get_identity(author) |
|
| 505 | + | .map_err(|_| format!("key {who}'s grant is by an unknown author"))?; |
|
| 506 | + | g.verify(&grantor) |
|
| 507 | + | .map_err(|e| format!("key {who}'s grant: {e}"))?; |
|
| 508 | + | if g.grant.subject != sc.cert.subject || g.grant.issuer != sc.cert.issuer { |
|
| 509 | + | return Err(format!( |
|
| 510 | + | "key {who}'s grant is for {} from issuer {}, not its certificate's", |
|
| 511 | + | g.grant.subject, |
|
| 512 | + | g.grant.issuer.short(12) |
|
| 513 | + | )); |
|
| 514 | + | } |
|
| 515 | + | if !sc.cert.scope.has(needed) || !g.grant.scope.has(needed) { |
|
| 516 | + | return Err(format!("key {who} is not granted {needed}")); |
|
| 517 | + | } |
|
| 518 | + | if !g.grant.covers(needed, at_ms) { |
|
| 519 | + | return Err(format!( |
|
| 520 | + | "key {who}'s grant did not cover the time the change was made" |
|
| 521 | + | )); |
|
| 522 | + | } |
|
| 523 | + | Ok(None) |
|
| 524 | + | } |
|
| 525 | + | ||
| 463 | 526 | /// Verify `sig` over `target` and check the author-or-delegate rule for `change`. |
|
| 464 | 527 | fn check_signer( |
|
| 465 | 528 | &self, |
|
| … | 23 unchanged lines |
||
| 489 | 552 | .ok() |
|
| 490 | 553 | .and_then(|a| a.delegate(&sig.key).copied()); |
|
| 491 | 554 | match delegation { |
|
| 555 | + | // A certified key acting under the author's grant (§14.7). |
|
| 492 | 556 | None => { |
|
| 493 | - | return Err(format!( |
|
| 494 | - | "key {} is neither the author nor a delegate", |
|
| 495 | - | sig.key.short(12) |
|
| 496 | - | )) |
|
| 557 | + | if let Some(why) = |
|
| 558 | + | self.granted(&change.author, &sig.key, needed, change.hlc.wall_ms)? |
|
| 559 | + | { |
|
| 560 | + | return Err(why); |
|
| 561 | + | } |
|
| 497 | 562 | } |
|
| 498 | 563 | Some(d) if !d.scope.has(needed) => { |
|
| 499 | 564 | return Err(format!( |
|
| … | 123 unchanged lines |
||
| 623 | 688 | self.store.index_seal(h, &r)?; |
|
| 624 | 689 | } |
|
| 625 | 690 | Ok(r) |
|
| 691 | + | } |
|
| 692 | + | ||
| 693 | + | /// Whether `signer` seals changes by `author`: its own, anonymous ones |
|
| 694 | + | /// (§14.4), and, when it signs for the configured author (a delegate, |
|
| 695 | + | /// or a short-lived key under the author's grant, §14.7), the |
|
| 696 | + | /// author's. A receiver still checks it may. |
|
| 697 | + | pub(crate) fn seals_for_author(&self, author: &KeyId, signer: &KeyId) -> bool { |
|
| 698 | + | author == signer |
|
| 699 | + | || author.is_zero() |
|
| 700 | + | || (self.config.signer_key.is_some() && self.config.author_key == Some(*author)) |
|
| 626 | 701 | } |
|
| 627 | 702 | ||
| 628 | 703 | /// The changes of the current bookmark this identity can authenticate |
|
| 629 | - | /// but has not: its own unsigned ones and anonymous ones. |
|
| 704 | + | /// but has not: its own unsigned ones, anonymous ones, and the |
|
| 705 | + | /// author's when it signs for them. |
|
| 630 | 706 | pub fn sealable(&self) -> Result<Vec<ChangeHash>> { |
|
| 631 | 707 | let Some((key, _)) = self.secret_key()? else { |
|
| 632 | 708 | return fail("no identity: run oxy identity create"); |
|
| … | 2 unchanged lines |
||
| 635 | 711 | let state = self.load_state_history(&heads)?; |
|
| 636 | 712 | let mut out = Vec::new(); |
|
| 637 | 713 | for (h, meta) in state.changes() { |
|
| 638 | - | if (meta.author == key || meta.author.is_zero()) |
|
| 714 | + | if self.seals_for_author(&meta.author, &key) |
|
| 639 | 715 | && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) |
|
| 640 | 716 | { |
|
| 641 | 717 | out.push(*h); |
|
| … | 24 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +1 −1
| … | 1999 unchanged lines |
||
| 2000 | 2000 | let mut unsigned = Vec::new(); |
|
| 2001 | 2001 | for h in &to_send { |
|
| 2002 | 2002 | let c = self.store.get_change_meta(h)?; |
|
| 2003 | - | if (c.author == key || c.author.is_zero()) |
|
| 2003 | + | if self.seals_for_author(&c.author, &key) |
|
| 2004 | 2004 | && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) |
|
| 2005 | 2005 | { |
|
| 2006 | 2006 | unsigned.push(*h); |
|
| … | 327 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +126 −0
| … | 1041 unchanged lines |
||
| 1042 | 1042 | scope: Scope::CHANGES | Scope::REFS, |
|
| 1043 | 1043 | not_before: from, |
|
| 1044 | 1044 | expires: to, |
|
| 1045 | + | grant: None, |
|
| 1045 | 1046 | }, |
|
| 1046 | 1047 | &hub, |
|
| 1047 | 1048 | &hub_sk, |
|
| … | 78 unchanged lines |
||
| 1126 | 1127 | ); |
|
| 1127 | 1128 | } |
|
| 1128 | 1129 | ||
| 1130 | + | /// Keyless step 5 (oxygenhub#241): an agent's fresh key, certified for a |
|
| 1131 | + | /// subject the author granted, seals changes in the author's name; with |
|
| 1132 | + | /// no grant, a stranger's, or one that had ended, it is neither the |
|
| 1133 | + | /// author nor a delegate. |
|
| 1129 | 1134 | #[test] |
|
| 1135 | + | fn a_certified_key_acts_for_its_author_under_a_grant() { |
|
| 1136 | + | use crate::cert::{Certificate, Grant, SignedCertificate, SignedGrant}; |
|
| 1137 | + | use crate::identity::{Identity, Scope}; |
|
| 1138 | + | hermetic(); |
|
| 1139 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1140 | + | let root = dir.path(); |
|
| 1141 | + | let _origin = Repo::init(&root.join("origin")).unwrap(); |
|
| 1142 | + | let url = root.join("origin").to_string_lossy().to_string(); |
|
| 1143 | + | let mut a = Repo::clone(&url, &root.join("a")) |
|
| 1144 | + | .unwrap() |
|
| 1145 | + | .with_home(root.join("home-a")); |
|
| 1146 | + | let (ada, _) = a |
|
| 1147 | + | .create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 1148 | + | .unwrap(); |
|
| 1149 | + | write(&root.join("a"), "a.txt", b"a\n"); |
|
| 1150 | + | a.commit("a", &[]).unwrap(); |
|
| 1151 | + | a.policy_init().unwrap(); |
|
| 1152 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1153 | + | let hub_sk = SecretKey::generate(); |
|
| 1154 | + | let hub = hub_sk.identity("hub", "[email protected]", 1); |
|
| 1155 | + | a.policy_grant_subject( |
|
| 1156 | + | "agent:claude@ada", |
|
| 1157 | + | hub.clone(), |
|
| 1158 | + | Scope::CHANGES | Scope::REFS, |
|
| 1159 | + | None, |
|
| 1160 | + | ) |
|
| 1161 | + | .unwrap(); |
|
| 1162 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1163 | + | let (_, ada_sk) = a.secret_key().unwrap().unwrap(); |
|
| 1164 | + | let ada_id = a.identity(&ada).unwrap(); |
|
| 1165 | + | ||
| 1166 | + | let now = Hlc::now().wall_ms; |
|
| 1167 | + | let grant = |by: &Identity, sk: &SecretKey, from: u64, to: u64| { |
|
| 1168 | + | SignedGrant::sign( |
|
| 1169 | + | Grant { |
|
| 1170 | + | grantor: by.hash(), |
|
| 1171 | + | subject: "agent:claude@ada".into(), |
|
| 1172 | + | issuer: hub.hash(), |
|
| 1173 | + | scope: Scope::CHANGES | Scope::SEAL, |
|
| 1174 | + | not_before: from, |
|
| 1175 | + | expires: to, |
|
| 1176 | + | }, |
|
| 1177 | + | by, |
|
| 1178 | + | sk, |
|
| 1179 | + | ) |
|
| 1180 | + | }; |
|
| 1181 | + | // Ada's work as an agent runs it: Ada the author, a fresh key the hub |
|
| 1182 | + | // certified (with `g` inside) the signer. |
|
| 1183 | + | let agent = |name: &str, g: Option<SignedGrant>| { |
|
| 1184 | + | let mut r = Repo::clone(&url, &root.join(name)) |
|
| 1185 | + | .unwrap() |
|
| 1186 | + | .with_home(root.join(format!("home-{name}"))); |
|
| 1187 | + | let (key, id) = r |
|
| 1188 | + | .create_identity(name, "[email protected]", IdentityScope::User) |
|
| 1189 | + | .unwrap(); |
|
| 1190 | + | r.set_user_config("author.key", &ada.to_hex()).unwrap(); |
|
| 1191 | + | r.set_user_config("author.signer", &key.to_hex()).unwrap(); |
|
| 1192 | + | let cert = SignedCertificate::sign( |
|
| 1193 | + | Certificate { |
|
| 1194 | + | key: id, |
|
| 1195 | + | issuer: hub.hash(), |
|
| 1196 | + | subject: "agent:claude@ada".into(), |
|
| 1197 | + | claims: vec![], |
|
| 1198 | + | scope: Scope::CHANGES | Scope::REFS | Scope::SEAL, |
|
| 1199 | + | not_before: now - 60_000, |
|
| 1200 | + | expires: now + 600_000, |
|
| 1201 | + | grant: g, |
|
| 1202 | + | }, |
|
| 1203 | + | &hub, |
|
| 1204 | + | &hub_sk, |
|
| 1205 | + | ); |
|
| 1206 | + | r.store().put_identity(&hub).unwrap(); |
|
| 1207 | + | r.store().put_certificate(&cert).unwrap(); |
|
| 1208 | + | write(&root.join(name), &format!("{name}.txt"), b"agent work\n"); |
|
| 1209 | + | let h = r.commit("work", &[]).unwrap(); |
|
| 1210 | + | assert_eq!(r.store().get_change_meta(&h).unwrap().author, ada); |
|
| 1211 | + | (r, key, h) |
|
| 1212 | + | }; |
|
| 1213 | + | ||
| 1214 | + | let (claude, key, h) = agent( |
|
| 1215 | + | "claude", |
|
| 1216 | + | Some(grant(&ada_id, &ada_sk, now - 60_000, now + 86_400_000)), |
|
| 1217 | + | ); |
|
| 1218 | + | let r = claude.push("origin", false).unwrap(); |
|
| 1219 | + | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 1220 | + | let host = Repo::open(&root.join("origin")).unwrap(); |
|
| 1221 | + | assert!(matches!( |
|
| 1222 | + | host.authenticate_change(&h).unwrap(), |
|
| 1223 | + | SignatureStatus::Valid { key: k, sealed: true, .. } if k == key |
|
| 1224 | + | )); |
|
| 1225 | + | a.pull("origin").unwrap(); |
|
| 1226 | + | assert!(matches!( |
|
| 1227 | + | a.authenticate_change(&h).unwrap(), |
|
| 1228 | + | SignatureStatus::Valid { key: k, .. } if k == key |
|
| 1229 | + | )); |
|
| 1230 | + | ||
| 1231 | + | let (none, _, _) = agent("none", None); |
|
| 1232 | + | let r = none.push("origin", false).unwrap(); |
|
| 1233 | + | assert!(r.iter().all(|x| !x.ok), "no grant: {r:?}"); |
|
| 1234 | + | let mallory_sk = SecretKey::generate(); |
|
| 1235 | + | let mallory = mallory_sk.identity("Mallory", "[email protected]", 1); |
|
| 1236 | + | let (stranger, _, _) = agent( |
|
| 1237 | + | "stranger", |
|
| 1238 | + | Some(grant(&mallory, &mallory_sk, now - 60_000, now + 600_000)), |
|
| 1239 | + | ); |
|
| 1240 | + | let r = stranger.push("origin", false).unwrap(); |
|
| 1241 | + | assert!(r.iter().all(|x| !x.ok), "a stranger's grant: {r:?}"); |
|
| 1242 | + | let (ended, _, eh) = agent( |
|
| 1243 | + | "ended", |
|
| 1244 | + | Some(grant(&ada_id, &ada_sk, now - 7_200_000, now - 3_600_000)), |
|
| 1245 | + | ); |
|
| 1246 | + | let r = ended.push("origin", false).unwrap(); |
|
| 1247 | + | assert!(r.iter().all(|x| !x.ok), "an ended grant: {r:?}"); |
|
| 1248 | + | assert!(matches!( |
|
| 1249 | + | ended.authenticate_change(&eh).unwrap(), |
|
| 1250 | + | SignatureStatus::Invalid(e) if e.contains("grant did not cover") |
|
| 1251 | + | )); |
|
| 1252 | + | } |
|
| 1253 | + | ||
| 1254 | + | #[test] |
|
| 1130 | 1255 | fn trust_policy_governs_pushes_and_fetches() { |
|
| 1131 | 1256 | hermetic(); |
|
| 1132 | 1257 | let dir = tempfile::tempdir().unwrap(); |
|
| … | 4613 unchanged lines |
||
| 5746 | 5871 | scope: crate::identity::Scope::SEAL, |
|
| 5747 | 5872 | not_before: now - 1_000, |
|
| 5748 | 5873 | expires: now + 600_000, |
|
| 5874 | + | grant: None, |
|
| 5749 | 5875 | }, |
|
| 5750 | 5876 | &hub, |
|
| 5751 | 5877 | &hub_sk, |
|
| … | 2631 unchanged lines |
modified docs/spec.md +19 −2
| … | 617 unchanged lines |
||
| 618 | 618 | ml [3]: bytes(3309) |
|
| 619 | 619 | } |
|
| 620 | 620 | message = "oxy1 sig " || domain || 0x00 || KeyId || ObjectRef-being-signed |
|
| 621 | - | domain = "change" | "attest" | "policy" |
|
| 621 | + | domain = "change" | "attest" | "policy" | "certificate" | "grant" |
|
| 622 | 622 | ``` |
|
| 623 | 623 | ||
| 624 | 624 | Both signatures are computed over the same `message`, which includes the `KeyId` of the composite pair. Verification MUST require both signatures to verify with the keys of the identity named by `key`. A signature with either half missing, empty, or failing is invalid. This is the same construction as the IETF LAMPS composite signature drafts and prevents stripping the post-quantum half. |
|
| … | 30 unchanged lines |
||
| 655 | 655 | ||
| 656 | 656 | **Cadence.** With the default `core.sign = "seal"`, changes are left unsigned locally and `oxy push` creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With `core.sign = "change"`, every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts. |
|
| 657 | 657 | ||
| 658 | - | **Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate with the `seal` scope, not expired at `C.hlc.wall_ms` (§14.2). The same author-or-delegate rule applies to individual change signatures, with the `changes` scope. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal. A seal's signer must additionally be authorized by policy for the reference being pushed. |
|
| 658 | + | **Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate with the `seal` scope, not expired at `C.hlc.wall_ms` (§14.2). The same author-or-delegate rule applies to individual change signatures, with the `changes` scope. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal; a pusher that signs for its configured author (`author.signer`: a delegate, or a short-lived key under the author's grant, §14.7) seals the author's as well, and the receiver checks it may. A seal's signer must additionally be authorized by policy for the reference being pushed. |
|
| 659 | 659 | ||
| 660 | 660 | **Handling.** A seal is stored and transported with the changes it covers: `fetch` (§16.1) streams the seals covering every change it returns, `push` (§16.4) MUST include a seal or signature for every change, and a seal covering any reachable change is itself reachable (§21). Seals from different keys MAY overlap; a change needs at least one valid authenticator, and policy thresholds count distinct authenticating keys. |
|
| 661 | 661 | ||
| … | 67 unchanged lines |
||
| 729 | 729 | scope [5]: [text] // changes, refs, seal |
|
| 730 | 730 | not_before [6]: u64 // wall milliseconds |
|
| 731 | 731 | expires [7]: u64 |
|
| 732 | + | grant [8]: bytes // optional: a signed grant (below), only when present |
|
| 732 | 733 | } |
|
| 733 | 734 | ``` |
|
| 734 | 735 | ||
| 735 | 736 | 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). |
|
| 736 | 737 | ||
| 738 | + | **Grants.** An author lets the keys an issuer certifies for a subject act for it, as a delegate does but without a key of their own (an agent's fresh keys, oxygenhub docs/keyless.md): |
|
| 739 | + | ||
| 740 | + | ``` |
|
| 741 | + | Grant { |
|
| 742 | + | version [0]: 1 |
|
| 743 | + | grantor [1]: KeyId // the author, whose key signs the grant |
|
| 744 | + | subject [2]: text // "agent:NAME@ACCOUNT" |
|
| 745 | + | issuer [3]: KeyId // whose certificates for the subject count |
|
| 746 | + | scope [4]: [text] // changes, seal |
|
| 747 | + | not_before [5]: u64 // wall milliseconds |
|
| 748 | + | expires [6]: u64 |
|
| 749 | + | } |
|
| 750 | + | ``` |
|
| 751 | + | ||
| 752 | + | A signed grant is `[grant bytes, Signature]`: the grantor's composite signature, in domain `grant`, over the grant's ObjectRef (type `grant`). It needs no maximum lifetime: it gives nothing until an issuer certifies a key under it, and the grantor revokes it by asking the issuer to stop. The issuer puts it in each certificate it issues under it (field 8), so it travels with the certificate and a verifier needs nothing else. A signature by a key that is neither a change's author nor its delegate (§14.4) still satisfies the author-or-delegate rule when the key's certificate verifies with its issuer and carries a grant that verifies with the change's author, names the certificate's subject and issuer, gives the scope needed (`changes` for a change signature, `seal` for a seal) in both the grant and the certificate, and covers the change's `hlc.wall_ms`, as a delegation does. Whether the certificate covered the time the key signed is the policy's check (§14.5), at a time the signer did not choose. |
|
| 753 | + | ||
| 737 | 754 | **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. |
|
| 738 | 755 | ||
| 739 | 756 | ## 15. Transport |
|
| … | 345 unchanged lines |