OXYGENHUB
# 4275ecb110f5 oxygen / crates / oxy-core / src / receipt.rs
15.2 KiB · Rust · last changed Grants let certified keys act for an author (keyless step 5a) 18 days ago source blame history raw
1
//! Execution receipts (spec §14.6): what a runner ran, in what, on which
2
//! inputs and with what result. A receipt names no state, so the same
3
//! receipt serves every state whose scope is unchanged; an attestation of
4
//! kind `receipt`, signed by the runner, binds it to the state it ran on.
5
6
use crate::attest::{Attest, AttestBody};
7
use crate::cbor::{self, Value};
8
use crate::hash::{hash_object, ObjectRef, ObjectType};
9
use crate::identity::{verify, Domain, Identity, SecretKey, Signature};
10
use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, Hlc, ObjectError};
11
12
#[derive(Clone, Debug, PartialEq, Eq)]
13
pub struct Receipt {
14
    pub job_name: String,
15
    /// The pipeline definition the job came from.
16
    pub pipeline_hash: ObjectRef,
17
    /// BLAKE3 over the sorted (path, content) of the files the job's scope
18
    /// matches; the whole tree when it has no scope.
19
    pub scope_tree_hash: ObjectRef,
20
    /// Artifacts and other inputs consumed beyond the tree.
21
    pub inputs: Vec<ObjectRef>,
22
    /// What the job ran in: isolation tier, rootfs, argv and environment.
23
    pub environment: ObjectRef,
24
    /// Blobs the job produced.
25
    pub outputs: Vec<ObjectRef>,
26
    pub exit_code: u32,
27
    pub duration_ms: u64,
28
}
29
30
fn refs(v: &[ObjectRef]) -> Value {
31
    Value::Array(v.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())
32
}
33
34
fn refs_from(v: &Value, what: &str) -> Result<Vec<ObjectRef>, ObjectError> {
35
    array(v, what)?.iter().map(|h| oref(h, what)).collect()
36
}
37
38
impl Receipt {
39
    pub fn passed(&self) -> bool {
40
        self.exit_code == 0
41
    }
42
43
    pub fn to_value(&self) -> Value {
44
        Value::map([
45
            (0, Value::Uint(1)),
46
            (1, Value::Text(self.job_name.clone())),
47
            (2, Value::Bytes(self.pipeline_hash.0.to_vec())),
48
            (3, Value::Bytes(self.scope_tree_hash.0.to_vec())),
49
            (4, refs(&self.inputs)),
50
            (5, Value::Bytes(self.environment.0.to_vec())),
51
            (6, refs(&self.outputs)),
52
            (7, Value::Uint(self.exit_code as u64)),
53
            (8, Value::Uint(self.duration_ms)),
54
        ])
55
    }
56
57
    pub fn encode(&self) -> Vec<u8> {
58
        cbor::encode(&self.to_value())
59
    }
60
61
    pub fn hash(&self) -> ObjectRef {
62
        hash_object(ObjectType::Receipt, &self.encode())
63
    }
64
65
    pub fn decode(b: &[u8]) -> Result<Receipt, ObjectError> {
66
        let v = cbor::decode(b)?;
67
        let m = map(&v, "receipt")?;
68
        check_version(m)?;
69
        let exit_code = uint(field(m, 7, "exit_code")?, "exit_code")?;
70
        Ok(Receipt {
71
            job_name: text(field(m, 1, "job_name")?, "job_name")?.to_owned(),
72
            pipeline_hash: oref(field(m, 2, "pipeline_hash")?, "pipeline_hash")?,
73
            scope_tree_hash: oref(field(m, 3, "scope_tree_hash")?, "scope_tree_hash")?,
74
            inputs: refs_from(field(m, 4, "inputs")?, "inputs")?,
75
            environment: oref(field(m, 5, "environment")?, "environment")?,
76
            outputs: refs_from(field(m, 6, "outputs")?, "outputs")?,
77
            exit_code: u32::try_from(exit_code)
78
                .map_err(|_| ObjectError::Schema("exit_code: expected u32".into()))?,
79
            duration_ms: uint(field(m, 8, "duration_ms")?, "duration_ms")?,
80
        })
81
    }
82
}
83
84
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
85
pub enum ReceiptError {
86
    #[error("the attestation is not of kind receipt")]
87
    NotReceipt,
88
    #[error("the attestation names a different receipt")]
89
    WrongReceipt,
90
    #[error("no signature by the runner {0}")]
91
    Unsigned(String),
92
    #[error("the runner's signature does not verify: {0}")]
93
    BadSignature(String),
94
    #[error("its certificate does not hold: {0}")]
95
    Certificate(String),
96
    #[error("{0}")]
97
    Schema(String),
98
}
99
100
impl From<ObjectError> for ReceiptError {
101
    fn from(e: ObjectError) -> Self {
102
        ReceiptError::Schema(e.to_string())
103
    }
104
}
105
106
/// A receipt as a runner hands it over: the receipt, the attestation
107
/// binding it to a state, the runner's signature over that attestation, and
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.
111
#[derive(Clone, Debug, PartialEq, Eq)]
112
pub struct SignedReceipt {
113
    pub receipt: Receipt,
114
    pub attest: Attest,
115
    pub sigs: Vec<Signature>,
116
    pub runner: Identity,
117
    pub certificate: Option<CertifiedBy>,
118
}
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
128
impl SignedReceipt {
129
    /// Bind `receipt` to `state` of repository `repo` for run `run` (a URL
130
    /// or other name of the run), signed by `runner` with `sk`.
131
    pub fn sign(
132
        receipt: Receipt,
133
        repo: [u8; 16],
134
        state: ObjectRef,
135
        run: &str,
136
        runner: &Identity,
137
        sk: &SecretKey,
138
    ) -> SignedReceipt {
139
        let attest = Attest {
140
            repo,
141
            hlc: Hlc::now(),
142
            body: AttestBody::Receipt {
143
                receipt: receipt.hash(),
144
                state,
145
                run: run.into(),
146
            },
147
        };
148
        let sig = sk.sign(Domain::Attest, &runner.hash(), &attest.hash());
149
        SignedReceipt {
150
            receipt,
151
            attest,
152
            sigs: vec![sig],
153
            runner: runner.clone(),
154
            certificate: None,
155
        }
156
    }
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
169
    /// The state the receipt was made on.
170
    pub fn state(&self) -> ObjectRef {
171
        match &self.attest.body {
172
            AttestBody::Receipt { state, .. } => *state,
173
            _ => ObjectRef([0; 32]),
174
        }
175
    }
176
177
    pub fn run(&self) -> &str {
178
        match &self.attest.body {
179
            AttestBody::Receipt { run, .. } => run,
180
            _ => "",
181
        }
182
    }
183
184
    /// The attestation is a receipt attestation for this receipt, and the
185
    /// runner signed it. Whether to trust the runner is the reader's call.
186
    pub fn verify(&self) -> Result<(), ReceiptError> {
187
        let AttestBody::Receipt { receipt, .. } = &self.attest.body else {
188
            return Err(ReceiptError::NotReceipt);
189
        };
190
        if *receipt != self.receipt.hash() {
191
            return Err(ReceiptError::WrongReceipt);
192
        }
193
        let key = self.runner.hash();
194
        let target = self.attest.hash();
195
        let sig = self
196
            .sigs
197
            .iter()
198
            .find(|s| s.key == key)
199
            .ok_or_else(|| ReceiptError::Unsigned(key.short(12)))?;
200
        verify(&self.runner, sig, Domain::Attest, &target)
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(())
224
    }
225
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.
230
    pub fn encode(&self) -> Vec<u8> {
231
        let mut parts = vec![
232
            Value::Bytes(self.receipt.encode()),
233
            Value::Bytes(self.attest.encode()),
234
            Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()),
235
            Value::Bytes(self.runner.encode()),
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))
244
    }
245
246
    pub fn decode(b: &[u8]) -> Result<SignedReceipt, ReceiptError> {
247
        let v = cbor::decode(b).map_err(ObjectError::from)?;
248
        let a = array(&v, "signed receipt")?;
249
        if a.len() != 4 && a.len() != 5 {
250
            return Err(ReceiptError::Schema(
251
                "signed receipt: expected [receipt, attest, sigs, identity, (certificate, issuer)]"
252
                    .into(),
253
            ));
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
        };
271
        Ok(SignedReceipt {
272
            receipt: Receipt::decode(bytes(&a[0], "receipt")?)?,
273
            attest: Attest::decode(bytes(&a[1], "attest")?)?,
274
            sigs: array(&a[2], "sigs")?
275
                .iter()
276
                .map(Signature::from_value)
277
                .collect::<Result<_, _>>()?,
278
            runner: Identity::decode(bytes(&a[3], "identity")?)?,
279
            certificate,
280
        })
281
    }
282
}
283
284
#[cfg(test)]
285
mod tests {
286
    use super::*;
287
288
    pub(crate) fn sample() -> Receipt {
289
        Receipt {
290
            job_name: "test".into(),
291
            pipeline_hash: ObjectRef([1; 32]),
292
            scope_tree_hash: ObjectRef([2; 32]),
293
            inputs: vec![],
294
            environment: ObjectRef([3; 32]),
295
            outputs: vec![ObjectRef([4; 32])],
296
            exit_code: 0,
297
            duration_ms: 1500,
298
        }
299
    }
300
301
    #[test]
302
    fn receipts_round_trip_and_hash_as_receipt_objects() {
303
        let r = sample();
304
        assert_eq!(Receipt::decode(&r.encode()).unwrap(), r);
305
        let mut h = blake3::Hasher::new();
306
        h.update(format!("oxy1 receipt {}\0", r.encode().len()).as_bytes());
307
        h.update(&r.encode());
308
        assert_eq!(
309
            r.hash().0,
310
            *h.finalize().as_bytes(),
311
            "OxygenFlow's receipt hash"
312
        );
313
        assert!(r.passed());
314
    }
315
316
    #[test]
317
    fn a_signed_receipt_verifies_and_tampering_does_not() {
318
        let sk = SecretKey::generate();
319
        let runner = sk.identity("earth-1", "runner@earth", 1);
320
        let sr = SignedReceipt::sign(
321
            sample(),
322
            [7; 16],
323
            ObjectRef([9; 32]),
324
            "andy/demo#3",
325
            &runner,
326
            &sk,
327
        );
328
        sr.verify().unwrap();
329
        let back = SignedReceipt::decode(&sr.encode()).unwrap();
330
        assert_eq!(back, sr);
331
        assert_eq!(back.state(), ObjectRef([9; 32]));
332
        assert_eq!(back.run(), "andy/demo#3");
333
334
        let mut forged = sr.clone();
335
        forged.receipt.exit_code = 0;
336
        forged.receipt.duration_ms = 1;
337
        assert_eq!(forged.verify(), Err(ReceiptError::WrongReceipt));
338
339
        let mut moved = sr.clone();
340
        if let AttestBody::Receipt { state, .. } = &mut moved.attest.body {
341
            *state = ObjectRef([8; 32]);
342
        }
343
        assert!(matches!(moved.verify(), Err(ReceiptError::BadSignature(_))));
344
345
        let other = SecretKey::generate().identity("mallory", "m@x", 1);
346
        let mut swapped = sr.clone();
347
        swapped.runner = other;
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
                    grant: None,
374
                },
375
                &hub,
376
                &hub_sk,
377
            )
378
        };
379
        let sr = SignedReceipt::sign(
380
            sample(),
381
            [7; 16],
382
            ObjectRef([9; 32]),
383
            "andy/demo#7",
384
            &job,
385
            &job_sk,
386
        );
387
        let now = sr.attest.hlc.wall_ms;
388
        let good = sr.clone().with_certificate(
389
            certify(&job, Scope::SEAL, now - 1000, now + 600_000),
390
            hub.clone(),
391
        );
392
        good.verify().unwrap();
393
        let back = SignedReceipt::decode(&good.encode()).unwrap();
394
        assert_eq!(back, good);
395
        assert_eq!(
396
            SignedReceipt::decode(&sr.encode()).unwrap().certificate,
397
            None,
398
            "a plain receipt reads as before"
399
        );
400
401
        let other = SecretKey::generate().identity("other", "o@x", 1);
402
        let wrong_key = sr.clone().with_certificate(
403
            certify(&other, Scope::SEAL, now - 1000, now + 600_000),
404
            hub.clone(),
405
        );
406
        assert!(
407
            matches!(wrong_key.verify(), Err(ReceiptError::Certificate(m)) if m.contains("another key"))
408
        );
409
        let no_seal = sr.clone().with_certificate(
410
            certify(&job, Scope::CHANGES, now - 1000, now + 600_000),
411
            hub.clone(),
412
        );
413
        assert!(matches!(
414
            no_seal.verify(),
415
            Err(ReceiptError::Certificate(_))
416
        ));
417
        let too_late = sr.clone().with_certificate(
418
            certify(&job, Scope::SEAL, now - 700_000, now - 100_000),
419
            hub.clone(),
420
        );
421
        assert!(
422
            matches!(too_late.verify(), Err(ReceiptError::Certificate(m)) if m.contains("window"))
423
        );
424
        let wrong_issuer = sr
425
            .clone()
426
            .with_certificate(certify(&job, Scope::SEAL, now - 1000, now + 600_000), other);
427
        assert!(matches!(
428
            wrong_issuer.verify(),
429
            Err(ReceiptError::Certificate(_))
430
        ));
431
    }
432
}