OXYGENHUB

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