Receipts signed by a certified short-lived key carry the certificate c63fed2e0465
Keyless step 3a (oxygenhub#241): a signed receipt may add [certificate, issuer identity]; it then verifies only if the certificate is the issuer's, certifies the signing key, gives seal and covers the attestation's time. A plain receipt reads as before. Repositories keep the certificate (with its issuer's signature, indexed by key) and the issuer's identity beside the receipt, rebuild it with them, and gc keeps them. Spec 14.6. Tests: round trip, another key's certificate, no seal scope, outside the window, a wrong issuer; kept, rebuilt and surviving gc, fsck clean. 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 14:37 UTC
- Signature
- ✔ signed by Claude Code
- Hash
c63fed2e046589c891fd5fcf516906d762ef93b3d8bc7069ebb7201368163ac9- Depends on
- 982864068a59 fc1a466a1e66 ff0af1ee299d
- Depended on by
- 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/receipt.rs +162 −6
| … | 90 unchanged lines |
||
| 91 | 91 | Unsigned(String), |
|
| 92 | 92 | #[error("the runner's signature does not verify: {0}")] |
|
| 93 | 93 | BadSignature(String), |
|
| 94 | + | #[error("its certificate does not hold: {0}")] |
|
| 95 | + | Certificate(String), |
|
| 94 | 96 | #[error("{0}")] |
|
| 95 | 97 | Schema(String), |
|
| 96 | 98 | } |
|
| … | 6 unchanged lines |
||
| 103 | 105 | ||
| 104 | 106 | /// A receipt as a runner hands it over: the receipt, the attestation |
|
| 105 | 107 | /// binding it to a state, the runner's signature over that attestation, and |
|
| 106 | - | /// the runner's identity, so it verifies with nothing else. |
|
| 108 | + | /// the runner's identity, so it verifies with nothing else. A runner that |
|
| 109 | + | /// signs with a short-lived key (keyless, oxygenhub#241) adds that key's |
|
| 110 | + | /// certificate and its issuer's identity. |
|
| 107 | 111 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 108 | 112 | pub struct SignedReceipt { |
|
| 109 | 113 | pub receipt: Receipt, |
|
| 110 | 114 | pub attest: Attest, |
|
| 111 | 115 | pub sigs: Vec<Signature>, |
|
| 112 | 116 | pub runner: Identity, |
|
| 117 | + | pub certificate: Option<CertifiedBy>, |
|
| 113 | 118 | } |
|
| 114 | 119 | ||
| 120 | + | /// A short-lived signing key's certificate, with the identity of the key |
|
| 121 | + | /// that issued it (spec §14.7). |
|
| 122 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 123 | + | pub struct CertifiedBy { |
|
| 124 | + | pub cert: crate::cert::SignedCertificate, |
|
| 125 | + | pub issuer: Identity, |
|
| 126 | + | } |
|
| 127 | + | ||
| 115 | 128 | impl SignedReceipt { |
|
| 116 | 129 | /// Bind `receipt` to `state` of repository `repo` for run `run` (a URL |
|
| 117 | 130 | /// or other name of the run), signed by `runner` with `sk`. |
|
| … | 20 unchanged lines |
||
| 138 | 151 | attest, |
|
| 139 | 152 | sigs: vec![sig], |
|
| 140 | 153 | runner: runner.clone(), |
|
| 154 | + | certificate: None, |
|
| 141 | 155 | } |
|
| 142 | 156 | } |
|
| 143 | 157 | ||
| 158 | + | /// The same, signed by a short-lived key whose certificate `cert` |
|
| 159 | + | /// `issuer` issued: the certificate rides along. |
|
| 160 | + | pub fn with_certificate( |
|
| 161 | + | mut self, |
|
| 162 | + | cert: crate::cert::SignedCertificate, |
|
| 163 | + | issuer: Identity, |
|
| 164 | + | ) -> SignedReceipt { |
|
| 165 | + | self.certificate = Some(CertifiedBy { cert, issuer }); |
|
| 166 | + | self |
|
| 167 | + | } |
|
| 168 | + | ||
| 144 | 169 | /// The state the receipt was made on. |
|
| 145 | 170 | pub fn state(&self) -> ObjectRef { |
|
| 146 | 171 | match &self.attest.body { |
|
| … | 26 unchanged lines |
||
| 173 | 198 | .find(|s| s.key == key) |
|
| 174 | 199 | .ok_or_else(|| ReceiptError::Unsigned(key.short(12)))?; |
|
| 175 | 200 | verify(&self.runner, sig, Domain::Attest, &target) |
|
| 176 | - | .map_err(|e| ReceiptError::BadSignature(e.to_string())) |
|
| 201 | + | .map_err(|e| ReceiptError::BadSignature(e.to_string()))?; |
|
| 202 | + | if let Some(c) = &self.certificate { |
|
| 203 | + | c.cert |
|
| 204 | + | .verify(&c.issuer) |
|
| 205 | + | .map_err(|e| ReceiptError::Certificate(e.to_string()))?; |
|
| 206 | + | if c.cert.cert.key_id() != key { |
|
| 207 | + | return Err(ReceiptError::Certificate( |
|
| 208 | + | "it certifies another key than the one that signed".into(), |
|
| 209 | + | )); |
|
| 210 | + | } |
|
| 211 | + | // The attestation's own time is the signer's claim: a host |
|
| 212 | + | // checks the time it accepted the receipt as well. |
|
| 213 | + | if !c |
|
| 214 | + | .cert |
|
| 215 | + | .cert |
|
| 216 | + | .covers(crate::identity::Scope::SEAL, self.attest.hlc.wall_ms) |
|
| 217 | + | { |
|
| 218 | + | return Err(ReceiptError::Certificate( |
|
| 219 | + | "the receipt was signed outside the certificate's window, or it gives no seal scope".into(), |
|
| 220 | + | )); |
|
| 221 | + | } |
|
| 222 | + | } |
|
| 223 | + | Ok(()) |
|
| 177 | 224 | } |
|
| 178 | 225 | ||
| 179 | 226 | /// `[receipt, attest, [Signature], identity]`, each object as its bytes. |
|
| 227 | + | /// `[receipt, attest, [Signature], identity]`, and |
|
| 228 | + | /// `[certificate, issuer identity]` after them when a short-lived key |
|
| 229 | + | /// signed. |
|
| 180 | 230 | pub fn encode(&self) -> Vec<u8> { |
|
| 181 | - | cbor::encode(&Value::Array(vec![ |
|
| 231 | + | let mut parts = vec![ |
|
| 182 | 232 | Value::Bytes(self.receipt.encode()), |
|
| 183 | 233 | Value::Bytes(self.attest.encode()), |
|
| 184 | 234 | Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()), |
|
| 185 | 235 | Value::Bytes(self.runner.encode()), |
|
| 186 | - | ])) |
|
| 236 | + | ]; |
|
| 237 | + | if let Some(c) = &self.certificate { |
|
| 238 | + | parts.push(Value::Array(vec![ |
|
| 239 | + | Value::Bytes(c.cert.encode()), |
|
| 240 | + | Value::Bytes(c.issuer.encode()), |
|
| 241 | + | ])); |
|
| 242 | + | } |
|
| 243 | + | cbor::encode(&Value::Array(parts)) |
|
| 187 | 244 | } |
|
| 188 | 245 | ||
| 189 | 246 | pub fn decode(b: &[u8]) -> Result<SignedReceipt, ReceiptError> { |
|
| 190 | 247 | let v = cbor::decode(b).map_err(ObjectError::from)?; |
|
| 191 | 248 | let a = array(&v, "signed receipt")?; |
|
| 192 | - | if a.len() != 4 { |
|
| 249 | + | if a.len() != 4 && a.len() != 5 { |
|
| 193 | 250 | return Err(ReceiptError::Schema( |
|
| 194 | - | "signed receipt: expected [receipt, attest, sigs, identity]".into(), |
|
| 251 | + | "signed receipt: expected [receipt, attest, sigs, identity, (certificate, issuer)]" |
|
| 252 | + | .into(), |
|
| 195 | 253 | )); |
|
| 196 | 254 | } |
|
| 255 | + | let certificate = match a.get(4) { |
|
| 256 | + | None => None, |
|
| 257 | + | Some(c) => { |
|
| 258 | + | let c = array(c, "certificate")?; |
|
| 259 | + | if c.len() != 2 { |
|
| 260 | + | return Err(ReceiptError::Schema( |
|
| 261 | + | "signed receipt: expected [certificate, issuer identity]".into(), |
|
| 262 | + | )); |
|
| 263 | + | } |
|
| 264 | + | Some(CertifiedBy { |
|
| 265 | + | cert: crate::cert::SignedCertificate::decode(bytes(&c[0], "certificate")?) |
|
| 266 | + | .map_err(|e| ReceiptError::Certificate(e.to_string()))?, |
|
| 267 | + | issuer: Identity::decode(bytes(&c[1], "issuer")?)?, |
|
| 268 | + | }) |
|
| 269 | + | } |
|
| 270 | + | }; |
|
| 197 | 271 | Ok(SignedReceipt { |
|
| 198 | 272 | receipt: Receipt::decode(bytes(&a[0], "receipt")?)?, |
|
| 199 | 273 | attest: Attest::decode(bytes(&a[1], "attest")?)?, |
|
| … | 2 unchanged lines |
||
| 202 | 276 | .map(Signature::from_value) |
|
| 203 | 277 | .collect::<Result<_, _>>()?, |
|
| 204 | 278 | runner: Identity::decode(bytes(&a[3], "identity")?)?, |
|
| 279 | + | certificate, |
|
| 205 | 280 | }) |
|
| 206 | 281 | } |
|
| 207 | 282 | } |
|
| … | 63 unchanged lines |
||
| 271 | 346 | let mut swapped = sr.clone(); |
|
| 272 | 347 | swapped.runner = other; |
|
| 273 | 348 | assert!(matches!(swapped.verify(), Err(ReceiptError::Unsigned(_)))); |
|
| 349 | + | } |
|
| 350 | + | ||
| 351 | + | /// A receipt signed with a short-lived key carries its certificate |
|
| 352 | + | /// (oxygenhub#241): it verifies, round-trips, and is refused when the |
|
| 353 | + | /// certificate is another key's, gives no seal scope, or does not cover |
|
| 354 | + | /// when it was signed. |
|
| 355 | + | #[test] |
|
| 356 | + | fn a_receipt_by_a_certified_key_carries_and_checks_its_certificate() { |
|
| 357 | + | use crate::cert::{Certificate, SignedCertificate}; |
|
| 358 | + | use crate::identity::Scope; |
|
| 359 | + | let hub_sk = SecretKey::generate(); |
|
| 360 | + | let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
| 361 | + | let job_sk = SecretKey::generate(); |
|
| 362 | + | let job = job_sk.identity("job 7 test", "[email protected]", 1); |
|
| 363 | + | let certify = |key: &Identity, scope: Scope, from: u64, to: u64| { |
|
| 364 | + | SignedCertificate::sign( |
|
| 365 | + | Certificate { |
|
| 366 | + | key: key.clone(), |
|
| 367 | + | issuer: hub.hash(), |
|
| 368 | + | subject: "repo:andy/demo:bookmark:main".into(), |
|
| 369 | + | claims: vec![("job".into(), "test".into())], |
|
| 370 | + | scope, |
|
| 371 | + | not_before: from, |
|
| 372 | + | expires: to, |
|
| 373 | + | }, |
|
| 374 | + | &hub, |
|
| 375 | + | &hub_sk, |
|
| 376 | + | ) |
|
| 377 | + | }; |
|
| 378 | + | let sr = SignedReceipt::sign( |
|
| 379 | + | sample(), |
|
| 380 | + | [7; 16], |
|
| 381 | + | ObjectRef([9; 32]), |
|
| 382 | + | "andy/demo#7", |
|
| 383 | + | &job, |
|
| 384 | + | &job_sk, |
|
| 385 | + | ); |
|
| 386 | + | let now = sr.attest.hlc.wall_ms; |
|
| 387 | + | let good = sr.clone().with_certificate( |
|
| 388 | + | certify(&job, Scope::SEAL, now - 1000, now + 600_000), |
|
| 389 | + | hub.clone(), |
|
| 390 | + | ); |
|
| 391 | + | good.verify().unwrap(); |
|
| 392 | + | let back = SignedReceipt::decode(&good.encode()).unwrap(); |
|
| 393 | + | assert_eq!(back, good); |
|
| 394 | + | assert_eq!( |
|
| 395 | + | SignedReceipt::decode(&sr.encode()).unwrap().certificate, |
|
| 396 | + | None, |
|
| 397 | + | "a plain receipt reads as before" |
|
| 398 | + | ); |
|
| 399 | + | ||
| 400 | + | let other = SecretKey::generate().identity("other", "o@x", 1); |
|
| 401 | + | let wrong_key = sr.clone().with_certificate( |
|
| 402 | + | certify(&other, Scope::SEAL, now - 1000, now + 600_000), |
|
| 403 | + | hub.clone(), |
|
| 404 | + | ); |
|
| 405 | + | assert!( |
|
| 406 | + | matches!(wrong_key.verify(), Err(ReceiptError::Certificate(m)) if m.contains("another key")) |
|
| 407 | + | ); |
|
| 408 | + | let no_seal = sr.clone().with_certificate( |
|
| 409 | + | certify(&job, Scope::CHANGES, now - 1000, now + 600_000), |
|
| 410 | + | hub.clone(), |
|
| 411 | + | ); |
|
| 412 | + | assert!(matches!( |
|
| 413 | + | no_seal.verify(), |
|
| 414 | + | Err(ReceiptError::Certificate(_)) |
|
| 415 | + | )); |
|
| 416 | + | let too_late = sr.clone().with_certificate( |
|
| 417 | + | certify(&job, Scope::SEAL, now - 700_000, now - 100_000), |
|
| 418 | + | hub.clone(), |
|
| 419 | + | ); |
|
| 420 | + | assert!( |
|
| 421 | + | matches!(too_late.verify(), Err(ReceiptError::Certificate(m)) if m.contains("window")) |
|
| 422 | + | ); |
|
| 423 | + | let wrong_issuer = sr |
|
| 424 | + | .clone() |
|
| 425 | + | .with_certificate(certify(&job, Scope::SEAL, now - 1000, now + 600_000), other); |
|
| 426 | + | assert!(matches!( |
|
| 427 | + | wrong_issuer.verify(), |
|
| 428 | + | Err(ReceiptError::Certificate(_)) |
|
| 429 | + | )); |
|
| 274 | 430 | } |
|
| 275 | 431 | } |
modified crates/oxy-core/src/repo/receipts.rs +21 −0
| … | 26 unchanged lines |
||
| 27 | 27 | return fail("that receipt is for another repository"); |
|
| 28 | 28 | } |
|
| 29 | 29 | self.store.put_identity(&sr.runner)?; |
|
| 30 | + | if let Some(c) = &sr.certificate { |
|
| 31 | + | self.store.put_identity(&c.issuer)?; |
|
| 32 | + | self.store.put_certificate(&c.cert)?; |
|
| 33 | + | } |
|
| 30 | 34 | self.store.put_receipt(&sr.receipt)?; |
|
| 31 | 35 | let r = self.store.put_attest(&sr.attest)?; |
|
| 32 | 36 | for sig in &sr.sigs { |
|
| … | 15 unchanged lines |
||
| 48 | 52 | return fail(format!("receipt #{} has no signature", r.short(12))); |
|
| 49 | 53 | }; |
|
| 50 | 54 | let runner = self.store.get_identity(&key)?; |
|
| 55 | + | // Signed by a short-lived key: its certificate and issuer too. |
|
| 56 | + | let certificate = match self.store.certificate_of(&key)? { |
|
| 57 | + | Some(c) => { |
|
| 58 | + | let cert = self.store.get_certificate(&c)?; |
|
| 59 | + | let issuer = self.store.get_identity(&cert.cert.issuer)?; |
|
| 60 | + | Some(crate::receipt::CertifiedBy { cert, issuer }) |
|
| 61 | + | } |
|
| 62 | + | None => None, |
|
| 63 | + | }; |
|
| 51 | 64 | let signed = SignedReceipt { |
|
| 52 | 65 | receipt, |
|
| 53 | 66 | attest, |
|
| 54 | 67 | sigs, |
|
| 55 | 68 | runner, |
|
| 69 | + | certificate, |
|
| 56 | 70 | }; |
|
| 57 | 71 | let problem = signed.verify().err().map(|e| e.to_string()); |
|
| 58 | 72 | Ok(ReceiptInfo { |
|
| … | 39 unchanged lines |
||
| 98 | 112 | keep.insert(a); |
|
| 99 | 113 | for sig in self.store.signatures(&a)? { |
|
| 100 | 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 | + | } |
|
| 101 | 122 | } |
|
| 102 | 123 | let Ok(attest) = self.store.get_attest(&a) else { |
|
| 103 | 124 | continue; |
|
| … | 20 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +50 −2
| … | 5446 unchanged lines |
||
| 5447 | 5447 | .to_string() |
|
| 5448 | 5448 | .contains("does not verify")); |
|
| 5449 | 5449 | ||
| 5450 | - | // gc keeps the receipt, its runner and its output while the state lives. |
|
| 5450 | + | // A receipt signed by a short-lived key keeps its certificate and the |
|
| 5451 | + | // issuer's identity, and reads back with them (oxygenhub#241). |
|
| 5452 | + | let hub_sk = SecretKey::generate(); |
|
| 5453 | + | let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
| 5454 | + | let job_sk = SecretKey::generate(); |
|
| 5455 | + | let job = job_sk.identity("job 2 test", "[email protected]", 1); |
|
| 5456 | + | let mut test_receipt = receipt.clone(); |
|
| 5457 | + | test_receipt.job_name = "test".into(); |
|
| 5458 | + | let plain = SignedReceipt::sign( |
|
| 5459 | + | test_receipt, |
|
| 5460 | + | repo.id().unwrap(), |
|
| 5461 | + | state, |
|
| 5462 | + | "demo#2", |
|
| 5463 | + | &job, |
|
| 5464 | + | &job_sk, |
|
| 5465 | + | ); |
|
| 5466 | + | let now = plain.attest.hlc.wall_ms; |
|
| 5467 | + | let cert = crate::cert::SignedCertificate::sign( |
|
| 5468 | + | crate::cert::Certificate { |
|
| 5469 | + | key: job.clone(), |
|
| 5470 | + | issuer: hub.hash(), |
|
| 5471 | + | subject: "repo:andy/demo:bookmark:main".into(), |
|
| 5472 | + | claims: vec![], |
|
| 5473 | + | scope: crate::identity::Scope::SEAL, |
|
| 5474 | + | not_before: now - 1_000, |
|
| 5475 | + | expires: now + 600_000, |
|
| 5476 | + | }, |
|
| 5477 | + | &hub, |
|
| 5478 | + | &hub_sk, |
|
| 5479 | + | ); |
|
| 5480 | + | let certified = plain.with_certificate(cert, hub.clone()); |
|
| 5481 | + | repo.add_receipt(&certified).unwrap(); |
|
| 5482 | + | let found = repo.receipts(&heads).unwrap(); |
|
| 5483 | + | let got = found |
|
| 5484 | + | .iter() |
|
| 5485 | + | .find(|i| i.signed.receipt.job_name == "test") |
|
| 5486 | + | .unwrap(); |
|
| 5487 | + | assert_eq!(got.signed, certified); |
|
| 5488 | + | assert!(got.problem.is_none(), "{:?}", got.problem); |
|
| 5489 | + | ||
| 5490 | + | // gc keeps the receipts, their runners, the certificate and its issuer, |
|
| 5491 | + | // and the output while the state lives. |
|
| 5451 | 5492 | repo.gc(true).unwrap(); |
|
| 5452 | - | assert_eq!(repo.receipts(&heads).unwrap().len(), 1); |
|
| 5493 | + | let found = repo.receipts(&heads).unwrap(); |
|
| 5494 | + | assert_eq!(found.len(), 2); |
|
| 5495 | + | let got = found |
|
| 5496 | + | .iter() |
|
| 5497 | + | .find(|i| i.signed.receipt.job_name == "test") |
|
| 5498 | + | .unwrap(); |
|
| 5499 | + | assert_eq!(got.signed, certified, "the certificate survives gc"); |
|
| 5500 | + | assert!(got.problem.is_none()); |
|
| 5453 | 5501 | assert_eq!(repo.read_blob(&out).unwrap(), b"built\n"); |
|
| 5454 | 5502 | assert!(repo.fsck().unwrap().problems.is_empty()); |
|
| 5455 | 5503 | } |
modified crates/oxy-core/src/store.rs +28 −0
| … | 936 unchanged lines |
||
| 937 | 937 | } |
|
| 938 | 938 | ||
| 939 | 939 | /// Record receipt attestation `attest` for `state` (spec §14.6). |
|
| 940 | + | /// Keep a short-lived key's certificate (spec §14.7), with its issuer's |
|
| 941 | + | /// signature, indexed by the key it certifies. |
|
| 942 | + | pub fn put_certificate(&self, c: &crate::cert::SignedCertificate) -> Result<ObjectRef> { |
|
| 943 | + | let r = self.put(ObjectType::Certificate, &c.cert.encode())?; |
|
| 944 | + | self.put_signature(&r, &c.sig)?; |
|
| 945 | + | self.index_add("cert", &c.cert.key_id(), &r)?; |
|
| 946 | + | Ok(r) |
|
| 947 | + | } |
|
| 948 | + | ||
| 949 | + | /// A kept certificate with its issuer's signature. |
|
| 950 | + | pub fn get_certificate(&self, r: &ObjectRef) -> Result<crate::cert::SignedCertificate> { |
|
| 951 | + | let cert = crate::cert::Certificate::decode(&self.get_typed(r, ObjectType::Certificate)?)?; |
|
| 952 | + | let sig = self |
|
| 953 | + | .signatures(r)? |
|
| 954 | + | .into_iter() |
|
| 955 | + | .find(|s| s.key == cert.issuer) |
|
| 956 | + | .ok_or(StoreError::NotFound(*r))?; |
|
| 957 | + | Ok(crate::cert::SignedCertificate { cert, sig }) |
|
| 958 | + | } |
|
| 959 | + | ||
| 960 | + | /// The certificate kept for short-lived key `key`, if any. |
|
| 961 | + | pub fn certificate_of(&self, key: &ObjectRef) -> Result<Option<ObjectRef>> { |
|
| 962 | + | Ok(self |
|
| 963 | + | .index_get("cert", "certs", key)? |
|
| 964 | + | .into_iter() |
|
| 965 | + | .find(|r| self.has(r))) |
|
| 966 | + | } |
|
| 967 | + | ||
| 940 | 968 | pub fn index_receipt(&self, state: &ObjectRef, attest: &ObjectRef) -> Result<()> { |
|
| 941 | 969 | self.index_add("receipts", state, attest) |
|
| 942 | 970 | } |
|
| … | 549 unchanged lines |
modified docs/spec.md +1 −1
| … | 690 unchanged lines |
||
| 691 | 691 | ||
| 692 | 692 | A receipt names no state: the same job on the same scope in the same environment gives the same receipt, which is what lets a runner reuse it for a later state whose scope is unchanged. An attestation of kind `receipt` (§14.4) binds a receipt to the `state` it ran on and names the `run` (usually its URL). The runner signs that attestation with its own identity (§14.2), an ordinary composite key, in domain `attest`. |
|
| 693 | 693 | ||
| 694 | - | **Signed receipt.** A runner hands a receipt over as `[receipt bytes, attest bytes, [Signature], identity bytes]`, so it verifies with nothing else: the attestation's `receipt` is the receipt's ObjectRef, and a signature by the included identity verifies over the attestation. Whether a runner is trusted is the reader's decision (a host lists its runners' keys); a revoked runner key is reported. |
|
| 694 | + | **Signed receipt.** A runner hands a receipt over as `[receipt bytes, attest bytes, [Signature], identity bytes]`, so it verifies with nothing else: the attestation's `receipt` is the receipt's ObjectRef, and a signature by the included identity verifies over the attestation. Whether a runner is trusted is the reader's decision (a host lists its runners' keys); a revoked runner key is reported. A runner that signs with a short-lived key (§14.7) adds a fifth element, `[certificate bytes, issuer identity bytes]`: the receipt then verifies only if the certificate does (signed by the identity given, which its `issuer` names), certifies the key that signed, gives the `seal` scope and covers the attestation's time; a host checks the time it accepted the receipt as well, since the attestation's is the signer's claim. A repository keeps the certificate (type `certificate`, with its issuer's signature, indexed by the key it certifies) and the issuer's identity beside the receipt, and gc keeps them with it. |
|
| 695 | 695 | ||
| 696 | 696 | **Storage.** A repository keeps receipts beside the state they name: the receipt, the attestation with its signatures, and the runner's identity, indexed by state ObjectRef. Receipts live as long as the changes of their state, and keep their outputs and inputs alive (§21). `oxy receipt list|show|export|verify|import` reads and exchanges them; a receipt for another repository is refused. |
|
| 697 | 697 | ||
| … | 342 unchanged lines |