OXYGENHUB
# 835ceb9b3ea7 oxygen / crates / oxy-core / src / attest.rs
12.4 KiB · Rust · last changed A tag can be withdrawn, by a signed withdrawal (oxygenhub#301) 11 days ago source blame history raw
5b9c6ad4 Andy McCall ↶ 1
//! Attestations (spec §14.4): signed statements about references and batches
fc1a466a Andy McCall ↶ 2
//! of changes, and runs (receipts, §14.6).
5b9c6ad4 Andy McCall ↶ 3
4
use crate::cbor::{self, Value};
75167672 Andy McCall 5
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
6
use crate::objects::{
7
    array, bytes, check_version, field, map, oref, text, uint, Hlc, ObjectError, FORMAT_VERSION,
8
};
5b9c6ad4 Andy McCall ↶ 9
10
pub const MAX_SEAL_HASHES: usize = 4096;
11
12
#[derive(Clone, Debug, PartialEq, Eq)]
13
pub enum AttestBody {
14
    /// A bookmark moved from `old_state` to `new_state` (spec §10.2).
75167672 Andy McCall 15
    Ref {
16
        name: String,
17
        old_state: ObjectRef,
18
        new_state: ObjectRef,
19
        serial: u64,
20
    },
5b9c6ad4 Andy McCall ↶ 21
    /// One signature covering a batch of changes (spec §14.4).
22
    Seal { hashes: Vec<ChangeHash> },
132b884b Andy McCall ↶ 23
    /// An immutable named state (spec §10.1).
75167672 Andy McCall 24
    Tag {
25
        name: String,
26
        state: ObjectRef,
27
        message: String,
28
    },
b63fc7a7 Andy McCall ↶ 29
    /// Tag `name`, naming `state`, is withdrawn (spec §10.1): signed by a
30
    /// key the policy lets create the tag. The name is never used again.
31
    Untag {
32
        name: String,
33
        state: ObjectRef,
34
        reason: String,
35
    },
28fc23d7 Andy McCall ↶ 36
    /// The signing key's identity was replaced by `new` (spec §14.2).
37
    Rotate { new: KeyId },
38
    /// `key` must no longer be trusted (spec §14.2).
39
    Revoke { key: KeyId, reason: String },
fc1a466a Andy McCall ↶ 40
    /// A runner ran the job of `receipt` on `state` (spec §14.6); `run`
41
    /// names the run, usually by URL.
42
    Receipt {
43
        receipt: ObjectRef,
44
        state: ObjectRef,
45
        run: String,
46
    },
b582948f Andy McCall ↶ 47
    /// A person approved (or rejected) one job of one run (OxygenFlow
48
    /// approval gates; oxygenhub#235): the job as hashed with its sandbox,
49
    /// on that state, taking exactly those input blobs (a reviewed plan),
50
    /// for that environment. It approves nothing else.
51
    Approval {
52
        run: String,
53
        job: String,
54
        job_hash: ObjectRef,
55
        state: ObjectRef,
56
        inputs: Vec<ObjectRef>,
57
        environment: String,
58
        approve: bool,
59
    },
f53cbe7a Andy McCall ↶ 60
    /// A host accepted the reference attestation `attest`, at this
61
    /// attestation's own time, the host's clock (§14.7): the time a
62
    /// certificate of a short-lived key is checked at on a fetch.
63
    Accepted { attest: ObjectRef },
5b9c6ad4 Andy McCall ↶ 64
}
65
66
#[derive(Clone, Debug, PartialEq, Eq)]
67
pub struct Attest {
68
    /// Repository ID chosen at init.
69
    pub repo: [u8; 16],
70
    pub hlc: Hlc,
71
    pub body: AttestBody,
72
}
73
74
impl Attest {
75
    pub fn kind(&self) -> &'static str {
76
        match self.body {
77
            AttestBody::Ref { .. } => "ref",
78
            AttestBody::Seal { .. } => "seal",
132b884b Andy McCall ↶ 79
            AttestBody::Tag { .. } => "tag",
b63fc7a7 Andy McCall ↶ 80
            AttestBody::Untag { .. } => "untag",
28fc23d7 Andy McCall ↶ 81
            AttestBody::Rotate { .. } => "rotate",
82
            AttestBody::Revoke { .. } => "revoke",
fc1a466a Andy McCall ↶ 83
            AttestBody::Receipt { .. } => "receipt",
b582948f Andy McCall ↶ 84
            AttestBody::Approval { .. } => "approval",
f53cbe7a Andy McCall ↶ 85
            AttestBody::Accepted { .. } => "accepted",
5b9c6ad4 Andy McCall ↶ 86
        }
87
    }
88
89
    pub fn seal(repo: [u8; 16], mut hashes: Vec<ChangeHash>) -> Attest {
90
        hashes.sort();
91
        hashes.dedup();
75167672 Andy McCall 92
        Attest {
93
            repo,
94
            hlc: Hlc::now(),
95
            body: AttestBody::Seal { hashes },
96
        }
5b9c6ad4 Andy McCall ↶ 97
    }
98
99
    pub fn to_value(&self) -> Value {
100
        let body = match &self.body {
75167672 Andy McCall 101
            AttestBody::Ref {
102
                name,
103
                old_state,
104
                new_state,
105
                serial,
106
            } => Value::map([
5b9c6ad4 Andy McCall ↶ 107
                (0, Value::Text(name.clone())),
108
                (1, Value::Bytes(old_state.0.to_vec())),
109
                (2, Value::Bytes(new_state.0.to_vec())),
110
                (3, Value::Uint(*serial)),
111
            ]),
75167672 Andy McCall 112
            AttestBody::Seal { hashes } => Value::map([(
113
                0,
114
                Value::Array(hashes.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()),
115
            )]),
116
            AttestBody::Tag {
117
                name,
118
                state,
119
                message,
120
            } => Value::map([
121
                (0, Value::Text(name.clone())),
122
                (1, Value::Bytes(state.0.to_vec())),
123
                (2, Value::Text(message.clone())),
b63fc7a7 Andy McCall ↶ 124
            ]),
125
            AttestBody::Untag {
126
                name,
127
                state,
128
                reason,
129
            } => Value::map([
130
                (0, Value::Text(name.clone())),
131
                (1, Value::Bytes(state.0.to_vec())),
132
                (2, Value::Text(reason.clone())),
75167672 Andy McCall 133
            ]),
28fc23d7 Andy McCall ↶ 134
            AttestBody::Rotate { new } => Value::map([(0, Value::Bytes(new.0.to_vec()))]),
75167672 Andy McCall 135
            AttestBody::Revoke { key, reason } => Value::map([
136
                (0, Value::Bytes(key.0.to_vec())),
137
                (1, Value::Text(reason.clone())),
fc1a466a Andy McCall ↶ 138
            ]),
139
            AttestBody::Receipt {
140
                receipt,
141
                state,
142
                run,
143
            } => Value::map([
144
                (0, Value::Bytes(receipt.0.to_vec())),
145
                (1, Value::Bytes(state.0.to_vec())),
146
                (2, Value::Text(run.clone())),
b582948f Andy McCall ↶ 147
            ]),
f53cbe7a Andy McCall ↶ 148
            AttestBody::Accepted { attest } => Value::map([(0, Value::Bytes(attest.0.to_vec()))]),
b582948f Andy McCall ↶ 149
            AttestBody::Approval {
150
                run,
151
                job,
152
                job_hash,
153
                state,
154
                inputs,
155
                environment,
156
                approve,
157
            } => Value::map([
158
                (0, Value::Text(run.clone())),
159
                (1, Value::Text(job.clone())),
160
                (2, Value::Bytes(job_hash.0.to_vec())),
161
                (3, Value::Bytes(state.0.to_vec())),
162
                (
163
                    4,
164
                    Value::Array(inputs.iter().map(|i| Value::Bytes(i.0.to_vec())).collect()),
165
                ),
166
                (5, Value::Text(environment.clone())),
167
                (6, Value::Uint(u64::from(*approve))),
75167672 Andy McCall 168
            ]),
5b9c6ad4 Andy McCall ↶ 169
        };
170
        Value::map([
171
            (0, Value::Uint(FORMAT_VERSION)),
172
            (1, Value::Text(self.kind().into())),
173
            (2, Value::Bytes(self.repo.to_vec())),
174
            (3, self.hlc.to_value()),
175
            (4, body),
176
        ])
177
    }
178
179
    pub fn encode(&self) -> Vec<u8> {
180
        cbor::encode(&self.to_value())
181
    }
182
183
    pub fn hash(&self) -> ObjectRef {
184
        hash_object(ObjectType::Attest, &self.encode())
185
    }
186
187
    pub fn decode(b: &[u8]) -> Result<Attest, ObjectError> {
188
        let v = cbor::decode(b)?;
189
        let m = map(&v, "attest")?;
190
        check_version(m)?;
191
        let kind = text(field(m, 1, "kind")?, "kind")?;
192
        let repo: [u8; 16] = bytes(field(m, 2, "repo")?, "repo")?
193
            .try_into()
194
            .map_err(|_| ObjectError::Schema("repo: expected 16 bytes".into()))?;
195
        let hlc = Hlc::from_value(field(m, 3, "hlc")?)?;
196
        let b = map(field(m, 4, "body")?, "body")?;
197
        let body = match kind {
198
            "ref" => AttestBody::Ref {
199
                name: text(field(b, 0, "name")?, "name")?.to_owned(),
200
                old_state: oref(field(b, 1, "old_state")?, "old_state")?,
201
                new_state: oref(field(b, 2, "new_state")?, "new_state")?,
202
                serial: uint(field(b, 3, "serial")?, "serial")?,
203
            },
204
            "seal" => {
75167672 Andy McCall 205
                let hashes = array(field(b, 0, "hashes")?, "hashes")?
206
                    .iter()
207
                    .map(|h| oref(h, "hash"))
208
                    .collect::<Result<Vec<_>, _>>()?;
209
                if hashes.is_empty()
210
                    || hashes.len() > MAX_SEAL_HASHES
211
                    || !hashes.windows(2).all(|w| w[0] < w[1])
212
                {
213
                    return Err(ObjectError::Schema(
214
                        "seal: hashes must be 1..4096, sorted and unique".into(),
215
                    ));
5b9c6ad4 Andy McCall ↶ 216
                }
217
                AttestBody::Seal { hashes }
218
            }
132b884b Andy McCall ↶ 219
            "tag" => AttestBody::Tag {
220
                name: text(field(b, 0, "name")?, "name")?.to_owned(),
221
                state: oref(field(b, 1, "state")?, "state")?,
222
                message: text(field(b, 2, "message")?, "message")?.to_owned(),
223
            },
b63fc7a7 Andy McCall ↶ 224
            "untag" => AttestBody::Untag {
225
                name: text(field(b, 0, "name")?, "name")?.to_owned(),
226
                state: oref(field(b, 1, "state")?, "state")?,
227
                reason: text(field(b, 2, "reason")?, "reason")?.to_owned(),
228
            },
75167672 Andy McCall 229
            "rotate" => AttestBody::Rotate {
230
                new: oref(field(b, 0, "new")?, "new")?,
231
            },
232
            "revoke" => AttestBody::Revoke {
233
                key: oref(field(b, 0, "key")?, "key")?,
234
                reason: text(field(b, 1, "reason")?, "reason")?.to_owned(),
fc1a466a Andy McCall ↶ 235
            },
236
            "receipt" => AttestBody::Receipt {
237
                receipt: oref(field(b, 0, "receipt")?, "receipt")?,
238
                state: oref(field(b, 1, "state")?, "state")?,
239
                run: text(field(b, 2, "run")?, "run")?.to_owned(),
75167672 Andy McCall 240
            },
b582948f Andy McCall ↶ 241
            "approval" => {
242
                let inputs = array(field(b, 4, "inputs")?, "inputs")?
243
                    .iter()
244
                    .map(|h| oref(h, "input"))
245
                    .collect::<Result<Vec<_>, _>>()?;
246
                if !inputs.windows(2).all(|w| w[0] < w[1]) {
247
                    return Err(ObjectError::Schema(
248
                        "approval: inputs must be sorted and unique".into(),
249
                    ));
250
                }
251
                AttestBody::Approval {
252
                    run: text(field(b, 0, "run")?, "run")?.to_owned(),
253
                    job: text(field(b, 1, "job")?, "job")?.to_owned(),
254
                    job_hash: oref(field(b, 2, "job_hash")?, "job_hash")?,
255
                    state: oref(field(b, 3, "state")?, "state")?,
256
                    inputs,
257
                    environment: text(field(b, 5, "environment")?, "environment")?.to_owned(),
258
                    approve: match uint(field(b, 6, "approve")?, "approve")? {
259
                        0 => false,
260
                        1 => true,
261
                        _ => return Err(ObjectError::Schema("approval: approve is 0 or 1".into())),
262
                    },
263
                }
264
            }
f53cbe7a Andy McCall ↶ 265
            "accepted" => AttestBody::Accepted {
266
                attest: oref(field(b, 0, "attest")?, "attest")?,
267
            },
75167672 Andy McCall 268
            other => {
269
                return Err(ObjectError::Schema(format!(
270
                    "unknown attestation kind {other:?}"
271
                )))
272
            }
5b9c6ad4 Andy McCall ↶ 273
        };
274
        Ok(Attest { repo, hlc, body })
275
    }
276
}
277
278
#[cfg(test)]
279
mod tests {
280
    use super::*;
281
282
    #[test]
283
    fn round_trips() {
284
        let r = Attest {
285
            repo: [1; 16],
75167672 Andy McCall 286
            hlc: Hlc {
287
                wall_ms: 3,
288
                logical: 0,
289
            },
290
            body: AttestBody::Ref {
291
                name: "main".into(),
292
                old_state: ObjectRef([1; 32]),
293
                new_state: ObjectRef([2; 32]),
294
                serial: 7,
295
            },
5b9c6ad4 Andy McCall ↶ 296
        };
297
        assert_eq!(Attest::decode(&r.encode()).unwrap(), r);
298
        assert_eq!(r.kind(), "ref");
75167672 Andy McCall 299
        let s = Attest::seal(
300
            [1; 16],
301
            vec![ObjectRef([9; 32]), ObjectRef([3; 32]), ObjectRef([9; 32])],
302
        );
5b9c6ad4 Andy McCall ↶ 303
        let d = Attest::decode(&s.encode()).unwrap();
75167672 Andy McCall 304
        assert!(
305
            matches!(&d.body, AttestBody::Seal { hashes } if hashes == &vec![ObjectRef([3; 32]), ObjectRef([9; 32])])
306
        );
5b9c6ad4 Andy McCall ↶ 307
        assert_ne!(s.hash(), r.hash());
75167672 Andy McCall 308
        let t = Attest {
309
            repo: [1; 16],
310
            hlc: Hlc::default(),
311
            body: AttestBody::Tag {
312
                name: "v1".into(),
313
                state: ObjectRef([5; 32]),
314
                message: "release".into(),
315
            },
316
        };
132b884b Andy McCall ↶ 317
        assert_eq!(Attest::decode(&t.encode()).unwrap(), t);
318
        assert_eq!(t.kind(), "tag");
b63fc7a7 Andy McCall ↶ 319
        let u = Attest {
320
            repo: [1; 16],
321
            hlc: Hlc::default(),
322
            body: AttestBody::Untag {
323
                name: "v1".into(),
324
                state: ObjectRef([5; 32]),
325
                reason: "tagged the wrong change".into(),
326
            },
327
        };
328
        assert_eq!(Attest::decode(&u.encode()).unwrap(), u);
329
        assert_eq!(u.kind(), "untag");
330
        assert_ne!(u.hash(), t.hash());
5b9c6ad4 Andy McCall ↶ 331
    }
332
333
    #[test]
334
    fn rejects_bad_seals() {
75167672 Andy McCall 335
        let empty = Attest {
336
            repo: [0; 16],
337
            hlc: Hlc::default(),
338
            body: AttestBody::Seal { hashes: vec![] },
339
        };
5b9c6ad4 Andy McCall ↶ 340
        assert!(Attest::decode(&empty.encode()).is_err());
75167672 Andy McCall 341
        let unsorted = Attest {
342
            repo: [0; 16],
343
            hlc: Hlc::default(),
344
            body: AttestBody::Seal {
345
                hashes: vec![ObjectRef([2; 32]), ObjectRef([1; 32])],
346
            },
347
        };
5b9c6ad4 Andy McCall ↶ 348
        assert!(Attest::decode(&unsorted.encode()).is_err());
349
    }
350
}