OXYGENHUB
# fddf64baa3d2 oxygen / crates / oxy-core / src / receipt.rs
9.3 KiB · Rust · last changed Execution receipts: the receipt object and attestation 1 month 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("{0}")]
95
    Schema(String),
96
}
97
98
impl From<ObjectError> for ReceiptError {
99
    fn from(e: ObjectError) -> Self {
100
        ReceiptError::Schema(e.to_string())
101
    }
102
}
103
104
/// A receipt as a runner hands it over: the receipt, the attestation
105
/// binding it to a state, the runner's signature over that attestation, and
106
/// the runner's identity, so it verifies with nothing else.
107
#[derive(Clone, Debug, PartialEq, Eq)]
108
pub struct SignedReceipt {
109
    pub receipt: Receipt,
110
    pub attest: Attest,
111
    pub sigs: Vec<Signature>,
112
    pub runner: Identity,
113
}
114
115
impl SignedReceipt {
116
    /// Bind `receipt` to `state` of repository `repo` for run `run` (a URL
117
    /// or other name of the run), signed by `runner` with `sk`.
118
    pub fn sign(
119
        receipt: Receipt,
120
        repo: [u8; 16],
121
        state: ObjectRef,
122
        run: &str,
123
        runner: &Identity,
124
        sk: &SecretKey,
125
    ) -> SignedReceipt {
126
        let attest = Attest {
127
            repo,
128
            hlc: Hlc::now(),
129
            body: AttestBody::Receipt {
130
                receipt: receipt.hash(),
131
                state,
132
                run: run.into(),
133
            },
134
        };
135
        let sig = sk.sign(Domain::Attest, &runner.hash(), &attest.hash());
136
        SignedReceipt {
137
            receipt,
138
            attest,
139
            sigs: vec![sig],
140
            runner: runner.clone(),
141
        }
142
    }
143
144
    /// The state the receipt was made on.
145
    pub fn state(&self) -> ObjectRef {
146
        match &self.attest.body {
147
            AttestBody::Receipt { state, .. } => *state,
148
            _ => ObjectRef([0; 32]),
149
        }
150
    }
151
152
    pub fn run(&self) -> &str {
153
        match &self.attest.body {
154
            AttestBody::Receipt { run, .. } => run,
155
            _ => "",
156
        }
157
    }
158
159
    /// The attestation is a receipt attestation for this receipt, and the
160
    /// runner signed it. Whether to trust the runner is the reader's call.
161
    pub fn verify(&self) -> Result<(), ReceiptError> {
162
        let AttestBody::Receipt { receipt, .. } = &self.attest.body else {
163
            return Err(ReceiptError::NotReceipt);
164
        };
165
        if *receipt != self.receipt.hash() {
166
            return Err(ReceiptError::WrongReceipt);
167
        }
168
        let key = self.runner.hash();
169
        let target = self.attest.hash();
170
        let sig = self
171
            .sigs
172
            .iter()
173
            .find(|s| s.key == key)
174
            .ok_or_else(|| ReceiptError::Unsigned(key.short(12)))?;
175
        verify(&self.runner, sig, Domain::Attest, &target)
176
            .map_err(|e| ReceiptError::BadSignature(e.to_string()))
177
    }
178
179
    /// `[receipt, attest, [Signature], identity]`, each object as its bytes.
180
    pub fn encode(&self) -> Vec<u8> {
181
        cbor::encode(&Value::Array(vec![
182
            Value::Bytes(self.receipt.encode()),
183
            Value::Bytes(self.attest.encode()),
184
            Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()),
185
            Value::Bytes(self.runner.encode()),
186
        ]))
187
    }
188
189
    pub fn decode(b: &[u8]) -> Result<SignedReceipt, ReceiptError> {
190
        let v = cbor::decode(b).map_err(ObjectError::from)?;
191
        let a = array(&v, "signed receipt")?;
192
        if a.len() != 4 {
193
            return Err(ReceiptError::Schema(
194
                "signed receipt: expected [receipt, attest, sigs, identity]".into(),
195
            ));
196
        }
197
        Ok(SignedReceipt {
198
            receipt: Receipt::decode(bytes(&a[0], "receipt")?)?,
199
            attest: Attest::decode(bytes(&a[1], "attest")?)?,
200
            sigs: array(&a[2], "sigs")?
201
                .iter()
202
                .map(Signature::from_value)
203
                .collect::<Result<_, _>>()?,
204
            runner: Identity::decode(bytes(&a[3], "identity")?)?,
205
        })
206
    }
207
}
208
209
#[cfg(test)]
210
mod tests {
211
    use super::*;
212
213
    pub(crate) fn sample() -> Receipt {
214
        Receipt {
215
            job_name: "test".into(),
216
            pipeline_hash: ObjectRef([1; 32]),
217
            scope_tree_hash: ObjectRef([2; 32]),
218
            inputs: vec![],
219
            environment: ObjectRef([3; 32]),
220
            outputs: vec![ObjectRef([4; 32])],
221
            exit_code: 0,
222
            duration_ms: 1500,
223
        }
224
    }
225
226
    #[test]
227
    fn receipts_round_trip_and_hash_as_receipt_objects() {
228
        let r = sample();
229
        assert_eq!(Receipt::decode(&r.encode()).unwrap(), r);
230
        let mut h = blake3::Hasher::new();
231
        h.update(format!("oxy1 receipt {}\0", r.encode().len()).as_bytes());
232
        h.update(&r.encode());
233
        assert_eq!(
234
            r.hash().0,
235
            *h.finalize().as_bytes(),
236
            "OxygenFlow's receipt hash"
237
        );
238
        assert!(r.passed());
239
    }
240
241
    #[test]
242
    fn a_signed_receipt_verifies_and_tampering_does_not() {
243
        let sk = SecretKey::generate();
244
        let runner = sk.identity("earth-1", "runner@earth", 1);
245
        let sr = SignedReceipt::sign(
246
            sample(),
247
            [7; 16],
248
            ObjectRef([9; 32]),
249
            "andy/demo#3",
250
            &runner,
251
            &sk,
252
        );
253
        sr.verify().unwrap();
254
        let back = SignedReceipt::decode(&sr.encode()).unwrap();
255
        assert_eq!(back, sr);
256
        assert_eq!(back.state(), ObjectRef([9; 32]));
257
        assert_eq!(back.run(), "andy/demo#3");
258
259
        let mut forged = sr.clone();
260
        forged.receipt.exit_code = 0;
261
        forged.receipt.duration_ms = 1;
262
        assert_eq!(forged.verify(), Err(ReceiptError::WrongReceipt));
263
264
        let mut moved = sr.clone();
265
        if let AttestBody::Receipt { state, .. } = &mut moved.attest.body {
266
            *state = ObjectRef([8; 32]);
267
        }
268
        assert!(matches!(moved.verify(), Err(ReceiptError::BadSignature(_))));
269
270
        let other = SecretKey::generate().identity("mallory", "m@x", 1);
271
        let mut swapped = sr.clone();
272
        swapped.runner = other;
273
        assert!(matches!(swapped.verify(), Err(ReceiptError::Unsigned(_))));
274
    }
275
}