OXYGENHUB

Coverage / crates/oxy-core/src/receipt.rs 94.8%

274 of 289 lines run, 41 of 53 functions · run #427 on main · run missed

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
30106×
fn refs(v: &[ObjectRef]) -> Value {
31106×
    Value::Array(v.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())
32106×
}
33
3444×
fn refs_from(v: &Value, what: &str) -> Result<Vec<ObjectRef>, ObjectError> {
3544×
    array(v, what)?.iter().map(|h| oref(h, what)).collect()
3644×
}
37
38
impl Receipt {
392×
    pub fn passed(&self) -> bool {
402×
        self.exit_code == 0
412×
    }
42
4353×
    pub fn to_value(&self) -> Value {
4453×
        Value::map([
4553×
            (0, Value::Uint(1)),
4653×
            (1, Value::Text(self.job_name.clone())),
4753×
            (2, Value::Bytes(self.pipeline_hash.0.to_vec())),
4853×
            (3, Value::Bytes(self.scope_tree_hash.0.to_vec())),
4953×
            (4, refs(&self.inputs)),
5053×
            (5, Value::Bytes(self.environment.0.to_vec())),
5153×
            (6, refs(&self.outputs)),
5253×
            (7, Value::Uint(self.exit_code as u64)),
5353×
            (8, Value::Uint(self.duration_ms)),
5453×
        ])
5553×
    }
56
5753×
    pub fn encode(&self) -> Vec<u8> {
5853×
        cbor::encode(&self.to_value())
5953×
    }
60
6138×
    pub fn hash(&self) -> ObjectRef {
6238×
        hash_object(ObjectType::Receipt, &self.encode())
6338×
    }
64
6522×
    pub fn decode(b: &[u8]) -> Result<Receipt, ObjectError> {
6622×
        let v = cbor::decode(b)?;
6722×
        let m = map(&v, "receipt")?;
6822×
        check_version(m)?;
6922×
        let exit_code = uint(field(m, 7, "exit_code")?, "exit_code")?;
70
        Ok(Receipt {
7122×
            job_name: text(field(m, 1, "job_name")?, "job_name")?.to_owned(),
7222×
            pipeline_hash: oref(field(m, 2, "pipeline_hash")?, "pipeline_hash")?,
7322×
            scope_tree_hash: oref(field(m, 3, "scope_tree_hash")?, "scope_tree_hash")?,
7422×
            inputs: refs_from(field(m, 4, "inputs")?, "inputs")?,
7522×
            environment: oref(field(m, 5, "environment")?, "environment")?,
7622×
            outputs: refs_from(field(m, 6, "outputs")?, "outputs")?,
7722×
            exit_code: u32::try_from(exit_code)
7822×
                .map_err(|_| ObjectError::Schema("exit_code: expected u32".into()))?,
7922×
            duration_ms: uint(field(m, 8, "duration_ms")?, "duration_ms")?,
80
        })
8122×
    }
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 {
1010×
    fn from(e: ObjectError) -> Self {
1020×
        ReceiptError::Schema(e.to_string())
1030×
    }
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`.
1316×
    pub fn sign(
1326×
        receipt: Receipt,
1336×
        repo: [u8; 16],
1346×
        state: ObjectRef,
1356×
        run: &str,
1366×
        runner: &Identity,
1376×
        sk: &SecretKey,
1386×
    ) -> SignedReceipt {
1396×
        let attest = Attest {
1406×
            repo,
1416×
            hlc: Hlc::now(),
1426×
            body: AttestBody::Receipt {
1436×
                receipt: receipt.hash(),
1446×
                state,
1456×
                run: run.into(),
1466×
            },
1476×
        };
1486×
        let sig = sk.sign(Domain::Attest, &runner.hash(), &attest.hash());
1496×
        SignedReceipt {
1506×
            receipt,
1516×
            attest,
1526×
            sigs: vec![sig],
1536×
            runner: runner.clone(),
1546×
            certificate: None,
1556×
        }
1566×
    }
157
158
    /// The same, signed by a short-lived key whose certificate `cert`
159
    /// `issuer` issued: the certificate rides along.
1606×
    pub fn with_certificate(
1616×
        mut self,
1626×
        cert: crate::cert::SignedCertificate,
1636×
        issuer: Identity,
1646×
    ) -> SignedReceipt {
1656×
        self.certificate = Some(CertifiedBy { cert, issuer });
1666×
        self
1676×
    }
168
169
    /// The state the receipt was made on.
17010×
    pub fn state(&self) -> ObjectRef {
17110×
        match &self.attest.body {
17210×
            AttestBody::Receipt { state, .. } => *state,
1730×
            _ => ObjectRef([0; 32]),
174
        }
17510×
    }
176
1777×
    pub fn run(&self) -> &str {
1787×
        match &self.attest.body {
1797×
            AttestBody::Receipt { run, .. } => run,
1800×
            _ => "",
181
        }
1827×
    }
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.
18627×
    pub fn verify(&self) -> Result<(), ReceiptError> {
18727×
        let AttestBody::Receipt { receipt, .. } = &self.attest.body else {
1880×
            return Err(ReceiptError::NotReceipt);
189
        };
19027×
        if *receipt != self.receipt.hash() {
1914×
            return Err(ReceiptError::WrongReceipt);
19223×
        }
19323×
        let key = self.runner.hash();
19423×
        let target = self.attest.hash();
19523×
        let sig = self
19623×
            .sigs
19723×
            .iter()
19823×
            .find(|s| s.key == key)
19923×
            .ok_or_else(|| ReceiptError::Unsigned(key.short(12)))?;
20022×
        verify(&self.runner, sig, Domain::Attest, &target)
20122×
            .map_err(|e| ReceiptError::BadSignature(e.to_string()))?;
20221×
        if let Some(c) = &self.certificate {
2038×
            c.cert
2048×
                .verify(&c.issuer)
2058×
                .map_err(|e| ReceiptError::Certificate(e.to_string()))?;
2067×
            if c.cert.cert.key_id() != key {
2071×
                return Err(ReceiptError::Certificate(
2081×
                    "it certifies another key than the one that signed".into(),
2091×
                ));
2106×
            }
211
            // The attestation's own time is the signer's claim: a host
212
            // checks the time it accepted the receipt as well.
2136×
            if !c
2146×
                .cert
2156×
                .cert
2166×
                .covers(crate::identity::Scope::SEAL, self.attest.hlc.wall_ms)
217
            {
2182×
                return Err(ReceiptError::Certificate(
2192×
                    "the receipt was signed outside the certificate's window, or it gives no seal scope".into(),
2202×
                ));
2214×
            }
22213×
        }
22317×
        Ok(())
22427×
    }
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.
2309×
    pub fn encode(&self) -> Vec<u8> {
2319×
        let mut parts = vec![
2329×
            Value::Bytes(self.receipt.encode()),
2339×
            Value::Bytes(self.attest.encode()),
2349×
            Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()),
2359×
            Value::Bytes(self.runner.encode()),
236
        ];
2379×
        if let Some(c) = &self.certificate {
2381×
            parts.push(Value::Array(vec![
2391×
                Value::Bytes(c.cert.encode()),
2401×
                Value::Bytes(c.issuer.encode()),
2411×
            ]));
2428×
        }
2439×
        cbor::encode(&Value::Array(parts))
2449×
    }
245
2467×
    pub fn decode(b: &[u8]) -> Result<SignedReceipt, ReceiptError> {
2477×
        let v = cbor::decode(b).map_err(ObjectError::from)?;
2487×
        let a = array(&v, "signed receipt")?;
2497×
        if a.len() != 4 && a.len() != 5 {
2500×
            return Err(ReceiptError::Schema(
2510×
                "signed receipt: expected [receipt, attest, sigs, identity, (certificate, issuer)]"
2520×
                    .into(),
2530×
            ));
2547×
        }
2557×
        let certificate = match a.get(4) {
2566×
            None => None,
2571×
            Some(c) => {
2581×
                let c = array(c, "certificate")?;
2591×
                if c.len() != 2 {
2600×
                    return Err(ReceiptError::Schema(
2610×
                        "signed receipt: expected [certificate, issuer identity]".into(),
2620×
                    ));
2631×
                }
264
                Some(CertifiedBy {
2651×
                    cert: crate::cert::SignedCertificate::decode(bytes(&c[0], "certificate")?)
2661×
                        .map_err(|e| ReceiptError::Certificate(e.to_string()))?,
2671×
                    issuer: Identity::decode(bytes(&c[1], "issuer")?)?,
268
                })
269
            }
270
        };
271
        Ok(SignedReceipt {
2727×
            receipt: Receipt::decode(bytes(&a[0], "receipt")?)?,
2737×
            attest: Attest::decode(bytes(&a[1], "attest")?)?,
2747×
            sigs: array(&a[2], "sigs")?
2757×
                .iter()
2767×
                .map(Signature::from_value)
2777×
                .collect::<Result<_, _>>()?,
2787×
            runner: Identity::decode(bytes(&a[3], "identity")?)?,
2797×
            certificate,
280
        })
2817×
    }
282
}
283
284
#[cfg(test)]
285
mod tests {
286
    use super::*;
287
2883×
    pub(crate) fn sample() -> Receipt {
2893×
        Receipt {
2903×
            job_name: "test".into(),
2913×
            pipeline_hash: ObjectRef([1; 32]),
2923×
            scope_tree_hash: ObjectRef([2; 32]),
2933×
            inputs: vec![],
2943×
            environment: ObjectRef([3; 32]),
2953×
            outputs: vec![ObjectRef([4; 32])],
2963×
            exit_code: 0,
2973×
            duration_ms: 1500,
2983×
        }
2993×
    }
300
301
    #[test]
3021×
    fn receipts_round_trip_and_hash_as_receipt_objects() {
3031×
        let r = sample();
3041×
        assert_eq!(Receipt::decode(&r.encode()).unwrap(), r);
3051×
        let mut h = blake3::Hasher::new();
3061×
        h.update(format!("oxy1 receipt {}\0", r.encode().len()).as_bytes());
3071×
        h.update(&r.encode());
3081×
        assert_eq!(
3091×
            r.hash().0,
3101×
            *h.finalize().as_bytes(),
311
            "OxygenFlow's receipt hash"
312
        );
3131×
        assert!(r.passed());
3141×
    }
315
316
    #[test]
3171×
    fn a_signed_receipt_verifies_and_tampering_does_not() {
3181×
        let sk = SecretKey::generate();
3191×
        let runner = sk.identity("earth-1", "runner@earth", 1);
3201×
        let sr = SignedReceipt::sign(
3211×
            sample(),
3221×
            [7; 16],
3231×
            ObjectRef([9; 32]),
3241×
            "andy/demo#3",
3251×
            &runner,
3261×
            &sk,
327
        );
3281×
        sr.verify().unwrap();
3291×
        let back = SignedReceipt::decode(&sr.encode()).unwrap();
3301×
        assert_eq!(back, sr);
3311×
        assert_eq!(back.state(), ObjectRef([9; 32]));
3321×
        assert_eq!(back.run(), "andy/demo#3");
333
3341×
        let mut forged = sr.clone();
3351×
        forged.receipt.exit_code = 0;
3361×
        forged.receipt.duration_ms = 1;
3371×
        assert_eq!(forged.verify(), Err(ReceiptError::WrongReceipt));
338
3391×
        let mut moved = sr.clone();
3401×
        if let AttestBody::Receipt { state, .. } = &mut moved.attest.body {
3411×
            *state = ObjectRef([8; 32]);
3421×
        }
3431×
        assert!(matches!(moved.verify(), Err(ReceiptError::BadSignature(_))));
344
3451×
        let other = SecretKey::generate().identity("mallory", "m@x", 1);
3461×
        let mut swapped = sr.clone();
3471×
        swapped.runner = other;
3481×
        assert!(matches!(swapped.verify(), Err(ReceiptError::Unsigned(_))));
3491×
    }
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]
3561×
    fn a_receipt_by_a_certified_key_carries_and_checks_its_certificate() {
357
        use crate::cert::{Certificate, SignedCertificate};
358
        use crate::identity::Scope;
3591×
        let hub_sk = SecretKey::generate();
3601×
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
3611×
        let job_sk = SecretKey::generate();
3621×
        let job = job_sk.identity("job 7 test", "[email protected]", 1);
3635×
        let certify = |key: &Identity, scope: Scope, from: u64, to: u64| {
3645×
            SignedCertificate::sign(
3655×
                Certificate {
3665×
                    key: key.clone(),
3675×
                    issuer: hub.hash(),
3685×
                    subject: "repo:andy/demo:bookmark:main".into(),
3695×
                    claims: vec![("job".into(), "test".into())],
3705×
                    scope,
3715×
                    not_before: from,
3725×
                    expires: to,
3735×
                    grant: None,
3745×
                },
3755×
                &hub,
3765×
                &hub_sk,
377
            )
3785×
        };
3791×
        let sr = SignedReceipt::sign(
3801×
            sample(),
3811×
            [7; 16],
3821×
            ObjectRef([9; 32]),
3831×
            "andy/demo#7",
3841×
            &job,
3851×
            &job_sk,
386
        );
3871×
        let now = sr.attest.hlc.wall_ms;
3881×
        let good = sr.clone().with_certificate(
3891×
            certify(&job, Scope::SEAL, now - 1000, now + 600_000),
3901×
            hub.clone(),
391
        );
3921×
        good.verify().unwrap();
3931×
        let back = SignedReceipt::decode(&good.encode()).unwrap();
3941×
        assert_eq!(back, good);
3951×
        assert_eq!(
3961×
            SignedReceipt::decode(&sr.encode()).unwrap().certificate,
397
            None,
398
            "a plain receipt reads as before"
399
        );
400
4011×
        let other = SecretKey::generate().identity("other", "o@x", 1);
4021×
        let wrong_key = sr.clone().with_certificate(
4031×
            certify(&other, Scope::SEAL, now - 1000, now + 600_000),
4041×
            hub.clone(),
405
        );
4061×
        assert!(
4071×
            matches!(wrong_key.verify(), Err(ReceiptError::Certificate(m)) if m.contains("another key"))
408
        );
4091×
        let no_seal = sr.clone().with_certificate(
4101×
            certify(&job, Scope::CHANGES, now - 1000, now + 600_000),
4111×
            hub.clone(),
412
        );
4131×
        assert!(matches!(
4141×
            no_seal.verify(),
415
            Err(ReceiptError::Certificate(_))
416
        ));
4171×
        let too_late = sr.clone().with_certificate(
4181×
            certify(&job, Scope::SEAL, now - 700_000, now - 100_000),
4191×
            hub.clone(),
420
        );
4211×
        assert!(
4221×
            matches!(too_late.verify(), Err(ReceiptError::Certificate(m)) if m.contains("window"))
423
        );
4241×
        let wrong_issuer = sr
4251×
            .clone()
4261×
            .with_certificate(certify(&job, Scope::SEAL, now - 1000, now + 600_000), other);
4271×
        assert!(matches!(
4281×
            wrong_issuer.verify(),
429
            Err(ReceiptError::Certificate(_))
430
        ));
4311×
    }
432
}