OXYGENHUB
◆ v0.4.4 oxygen / crates / oxy-core / src / attest.rs
10.7 KiB · Rust · last changed Sealed approvals: the approval attestation and oxy seal --flow 19 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
    },
28fc23d7 Andy McCall ↶ 29
    /// The signing key's identity was replaced by `new` (spec §14.2).
30
    Rotate { new: KeyId },
31
    /// `key` must no longer be trusted (spec §14.2).
32
    Revoke { key: KeyId, reason: String },
fc1a466a Andy McCall ↶ 33
    /// A runner ran the job of `receipt` on `state` (spec §14.6); `run`
34
    /// names the run, usually by URL.
35
    Receipt {
36
        receipt: ObjectRef,
37
        state: ObjectRef,
38
        run: String,
39
    },
b582948f Andy McCall ↶ 40
    /// A person approved (or rejected) one job of one run (OxygenFlow
41
    /// approval gates; oxygenhub#235): the job as hashed with its sandbox,
42
    /// on that state, taking exactly those input blobs (a reviewed plan),
43
    /// for that environment. It approves nothing else.
44
    Approval {
45
        run: String,
46
        job: String,
47
        job_hash: ObjectRef,
48
        state: ObjectRef,
49
        inputs: Vec<ObjectRef>,
50
        environment: String,
51
        approve: bool,
52
    },
5b9c6ad4 Andy McCall ↶ 53
}
54
55
#[derive(Clone, Debug, PartialEq, Eq)]
56
pub struct Attest {
57
    /// Repository ID chosen at init.
58
    pub repo: [u8; 16],
59
    pub hlc: Hlc,
60
    pub body: AttestBody,
61
}
62
63
impl Attest {
64
    pub fn kind(&self) -> &'static str {
65
        match self.body {
66
            AttestBody::Ref { .. } => "ref",
67
            AttestBody::Seal { .. } => "seal",
132b884b Andy McCall ↶ 68
            AttestBody::Tag { .. } => "tag",
28fc23d7 Andy McCall ↶ 69
            AttestBody::Rotate { .. } => "rotate",
70
            AttestBody::Revoke { .. } => "revoke",
fc1a466a Andy McCall ↶ 71
            AttestBody::Receipt { .. } => "receipt",
b582948f Andy McCall ↶ 72
            AttestBody::Approval { .. } => "approval",
5b9c6ad4 Andy McCall ↶ 73
        }
74
    }
75
76
    pub fn seal(repo: [u8; 16], mut hashes: Vec<ChangeHash>) -> Attest {
77
        hashes.sort();
78
        hashes.dedup();
75167672 Andy McCall 79
        Attest {
80
            repo,
81
            hlc: Hlc::now(),
82
            body: AttestBody::Seal { hashes },
83
        }
5b9c6ad4 Andy McCall ↶ 84
    }
85
86
    pub fn to_value(&self) -> Value {
87
        let body = match &self.body {
75167672 Andy McCall 88
            AttestBody::Ref {
89
                name,
90
                old_state,
91
                new_state,
92
                serial,
93
            } => Value::map([
5b9c6ad4 Andy McCall ↶ 94
                (0, Value::Text(name.clone())),
95
                (1, Value::Bytes(old_state.0.to_vec())),
96
                (2, Value::Bytes(new_state.0.to_vec())),
97
                (3, Value::Uint(*serial)),
98
            ]),
75167672 Andy McCall 99
            AttestBody::Seal { hashes } => Value::map([(
100
                0,
101
                Value::Array(hashes.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()),
102
            )]),
103
            AttestBody::Tag {
104
                name,
105
                state,
106
                message,
107
            } => Value::map([
108
                (0, Value::Text(name.clone())),
109
                (1, Value::Bytes(state.0.to_vec())),
110
                (2, Value::Text(message.clone())),
111
            ]),
28fc23d7 Andy McCall ↶ 112
            AttestBody::Rotate { new } => Value::map([(0, Value::Bytes(new.0.to_vec()))]),
75167672 Andy McCall 113
            AttestBody::Revoke { key, reason } => Value::map([
114
                (0, Value::Bytes(key.0.to_vec())),
115
                (1, Value::Text(reason.clone())),
fc1a466a Andy McCall ↶ 116
            ]),
117
            AttestBody::Receipt {
118
                receipt,
119
                state,
120
                run,
121
            } => Value::map([
122
                (0, Value::Bytes(receipt.0.to_vec())),
123
                (1, Value::Bytes(state.0.to_vec())),
124
                (2, Value::Text(run.clone())),
b582948f Andy McCall ↶ 125
            ]),
126
            AttestBody::Approval {
127
                run,
128
                job,
129
                job_hash,
130
                state,
131
                inputs,
132
                environment,
133
                approve,
134
            } => Value::map([
135
                (0, Value::Text(run.clone())),
136
                (1, Value::Text(job.clone())),
137
                (2, Value::Bytes(job_hash.0.to_vec())),
138
                (3, Value::Bytes(state.0.to_vec())),
139
                (
140
                    4,
141
                    Value::Array(inputs.iter().map(|i| Value::Bytes(i.0.to_vec())).collect()),
142
                ),
143
                (5, Value::Text(environment.clone())),
144
                (6, Value::Uint(u64::from(*approve))),
75167672 Andy McCall 145
            ]),
5b9c6ad4 Andy McCall ↶ 146
        };
147
        Value::map([
148
            (0, Value::Uint(FORMAT_VERSION)),
149
            (1, Value::Text(self.kind().into())),
150
            (2, Value::Bytes(self.repo.to_vec())),
151
            (3, self.hlc.to_value()),
152
            (4, body),
153
        ])
154
    }
155
156
    pub fn encode(&self) -> Vec<u8> {
157
        cbor::encode(&self.to_value())
158
    }
159
160
    pub fn hash(&self) -> ObjectRef {
161
        hash_object(ObjectType::Attest, &self.encode())
162
    }
163
164
    pub fn decode(b: &[u8]) -> Result<Attest, ObjectError> {
165
        let v = cbor::decode(b)?;
166
        let m = map(&v, "attest")?;
167
        check_version(m)?;
168
        let kind = text(field(m, 1, "kind")?, "kind")?;
169
        let repo: [u8; 16] = bytes(field(m, 2, "repo")?, "repo")?
170
            .try_into()
171
            .map_err(|_| ObjectError::Schema("repo: expected 16 bytes".into()))?;
172
        let hlc = Hlc::from_value(field(m, 3, "hlc")?)?;
173
        let b = map(field(m, 4, "body")?, "body")?;
174
        let body = match kind {
175
            "ref" => AttestBody::Ref {
176
                name: text(field(b, 0, "name")?, "name")?.to_owned(),
177
                old_state: oref(field(b, 1, "old_state")?, "old_state")?,
178
                new_state: oref(field(b, 2, "new_state")?, "new_state")?,
179
                serial: uint(field(b, 3, "serial")?, "serial")?,
180
            },
181
            "seal" => {
75167672 Andy McCall 182
                let hashes = array(field(b, 0, "hashes")?, "hashes")?
183
                    .iter()
184
                    .map(|h| oref(h, "hash"))
185
                    .collect::<Result<Vec<_>, _>>()?;
186
                if hashes.is_empty()
187
                    || hashes.len() > MAX_SEAL_HASHES
188
                    || !hashes.windows(2).all(|w| w[0] < w[1])
189
                {
190
                    return Err(ObjectError::Schema(
191
                        "seal: hashes must be 1..4096, sorted and unique".into(),
192
                    ));
5b9c6ad4 Andy McCall ↶ 193
                }
194
                AttestBody::Seal { hashes }
195
            }
132b884b Andy McCall ↶ 196
            "tag" => AttestBody::Tag {
197
                name: text(field(b, 0, "name")?, "name")?.to_owned(),
198
                state: oref(field(b, 1, "state")?, "state")?,
199
                message: text(field(b, 2, "message")?, "message")?.to_owned(),
200
            },
75167672 Andy McCall 201
            "rotate" => AttestBody::Rotate {
202
                new: oref(field(b, 0, "new")?, "new")?,
203
            },
204
            "revoke" => AttestBody::Revoke {
205
                key: oref(field(b, 0, "key")?, "key")?,
206
                reason: text(field(b, 1, "reason")?, "reason")?.to_owned(),
fc1a466a Andy McCall ↶ 207
            },
208
            "receipt" => AttestBody::Receipt {
209
                receipt: oref(field(b, 0, "receipt")?, "receipt")?,
210
                state: oref(field(b, 1, "state")?, "state")?,
211
                run: text(field(b, 2, "run")?, "run")?.to_owned(),
75167672 Andy McCall 212
            },
b582948f Andy McCall ↶ 213
            "approval" => {
214
                let inputs = array(field(b, 4, "inputs")?, "inputs")?
215
                    .iter()
216
                    .map(|h| oref(h, "input"))
217
                    .collect::<Result<Vec<_>, _>>()?;
218
                if !inputs.windows(2).all(|w| w[0] < w[1]) {
219
                    return Err(ObjectError::Schema(
220
                        "approval: inputs must be sorted and unique".into(),
221
                    ));
222
                }
223
                AttestBody::Approval {
224
                    run: text(field(b, 0, "run")?, "run")?.to_owned(),
225
                    job: text(field(b, 1, "job")?, "job")?.to_owned(),
226
                    job_hash: oref(field(b, 2, "job_hash")?, "job_hash")?,
227
                    state: oref(field(b, 3, "state")?, "state")?,
228
                    inputs,
229
                    environment: text(field(b, 5, "environment")?, "environment")?.to_owned(),
230
                    approve: match uint(field(b, 6, "approve")?, "approve")? {
231
                        0 => false,
232
                        1 => true,
233
                        _ => return Err(ObjectError::Schema("approval: approve is 0 or 1".into())),
234
                    },
235
                }
236
            }
75167672 Andy McCall 237
            other => {
238
                return Err(ObjectError::Schema(format!(
239
                    "unknown attestation kind {other:?}"
240
                )))
241
            }
5b9c6ad4 Andy McCall ↶ 242
        };
243
        Ok(Attest { repo, hlc, body })
244
    }
245
}
246
247
#[cfg(test)]
248
mod tests {
249
    use super::*;
250
251
    #[test]
252
    fn round_trips() {
253
        let r = Attest {
254
            repo: [1; 16],
75167672 Andy McCall 255
            hlc: Hlc {
256
                wall_ms: 3,
257
                logical: 0,
258
            },
259
            body: AttestBody::Ref {
260
                name: "main".into(),
261
                old_state: ObjectRef([1; 32]),
262
                new_state: ObjectRef([2; 32]),
263
                serial: 7,
264
            },
5b9c6ad4 Andy McCall ↶ 265
        };
266
        assert_eq!(Attest::decode(&r.encode()).unwrap(), r);
267
        assert_eq!(r.kind(), "ref");
75167672 Andy McCall 268
        let s = Attest::seal(
269
            [1; 16],
270
            vec![ObjectRef([9; 32]), ObjectRef([3; 32]), ObjectRef([9; 32])],
271
        );
5b9c6ad4 Andy McCall ↶ 272
        let d = Attest::decode(&s.encode()).unwrap();
75167672 Andy McCall 273
        assert!(
274
            matches!(&d.body, AttestBody::Seal { hashes } if hashes == &vec![ObjectRef([3; 32]), ObjectRef([9; 32])])
275
        );
5b9c6ad4 Andy McCall ↶ 276
        assert_ne!(s.hash(), r.hash());
75167672 Andy McCall 277
        let t = Attest {
278
            repo: [1; 16],
279
            hlc: Hlc::default(),
280
            body: AttestBody::Tag {
281
                name: "v1".into(),
282
                state: ObjectRef([5; 32]),
283
                message: "release".into(),
284
            },
285
        };
132b884b Andy McCall ↶ 286
        assert_eq!(Attest::decode(&t.encode()).unwrap(), t);
287
        assert_eq!(t.kind(), "tag");
5b9c6ad4 Andy McCall ↶ 288
    }
289
290
    #[test]
291
    fn rejects_bad_seals() {
75167672 Andy McCall 292
        let empty = Attest {
293
            repo: [0; 16],
294
            hlc: Hlc::default(),
295
            body: AttestBody::Seal { hashes: vec![] },
296
        };
5b9c6ad4 Andy McCall ↶ 297
        assert!(Attest::decode(&empty.encode()).is_err());
75167672 Andy McCall 298
        let unsorted = Attest {
299
            repo: [0; 16],
300
            hlc: Hlc::default(),
301
            body: AttestBody::Seal {
302
                hashes: vec![ObjectRef([2; 32]), ObjectRef([1; 32])],
303
            },
304
        };
5b9c6ad4 Andy McCall ↶ 305
        assert!(Attest::decode(&unsorted.encode()).is_err());
306
    }
307
}