OXYGENHUB
# 771661aa50f4 oxygen / crates / oxy-core / src / cert.rs
18.5 KiB · Rust · last changed Grants let certified keys act for an author (keyless step 5a) 19 days ago source blame history raw
02e69188 Andy McCall ↶ 1
//! Certificates for short-lived keys (spec §14.7, oxygenhub#241,
2
//! oxygenhub docs/keyless.md). An issuer, a hub's key, binds a fresh key
3
//! to the identity that proved itself (an OIDC subject) for minutes: a Flow
4
//! job or an agent signs with that key and throws it away. The certificate
5
//! is signed with the issuer's composite key, so it is post-quantum like
6
//! every other signature in Oxygen.
7
//!
8
//! Whether to trust an issuer, and for which subjects, is the trust
9
//! policy's (§14.5); whether a signature was made inside the certificate's
10
//! window needs time the signer does not choose, which the caller passes
11
//! in (a host's acceptance now, the transparency log's later).
12
13
use crate::cbor::{self, Value};
14
use crate::hash::{hash_object, KeyId, ObjectRef, ObjectType};
15
use crate::identity::{verify, Domain, Identity, Scope, SecretKey, Signature};
16
use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, ObjectError};
17
18
/// The longest a certificate may be valid. Jobs get ten minutes; an hour
19
/// leaves room for an issuer that rounds up, and no more.
20
pub const MAX_LIFETIME_MS: u64 = 60 * 60 * 1000;
21
22
#[derive(Clone, Debug, PartialEq, Eq)]
23
pub struct Certificate {
24
    /// The short-lived key's public identity.
25
    pub key: Identity,
26
    /// The issuer's KeyId (a hub's key).
27
    pub issuer: KeyId,
28
    /// Who proved it: `repo:OWNER/NAME:bookmark:B`, `agent:NAME@ACCOUNT`.
29
    pub subject: String,
30
    /// What else the proof said (environment, run, job), sorted by name.
31
    pub claims: Vec<(String, String)>,
32
    /// What the key may sign: changes, refs, seals.
33
    pub scope: Scope,
34
    /// Valid from and until, in wall milliseconds.
35
    pub not_before: u64,
36
    pub expires: u64,
a2387d54 Andy McCall ↶ 37
    /// The grant that lets the subject act for an author, when an agent
38
    /// asked with one (field 8, only when present).
39
    pub grant: Option<SignedGrant>,
02e69188 Andy McCall ↶ 40
}
41
42
impl Certificate {
43
    pub fn to_value(&self) -> Value {
44
        let mut claims = self.claims.clone();
45
        claims.sort();
a2387d54 Andy McCall ↶ 46
        let mut v = Value::map([
02e69188 Andy McCall ↶ 47
            (0, Value::Uint(1)),
48
            (1, Value::Bytes(self.key.encode())),
49
            (2, Value::Bytes(self.issuer.0.to_vec())),
50
            (3, Value::Text(self.subject.clone())),
51
            (
52
                4,
53
                Value::Array(
54
                    claims
55
                        .into_iter()
56
                        .map(|(k, v)| Value::Array(vec![Value::Text(k), Value::Text(v)]))
57
                        .collect(),
58
                ),
59
            ),
60
            (
61
                5,
62
                Value::Array(
63
                    self.scope
64
                        .names()
65
                        .into_iter()
66
                        .map(|n| Value::Text(n.to_string()))
67
                        .collect(),
68
                ),
69
            ),
70
            (6, Value::Uint(self.not_before)),
71
            (7, Value::Uint(self.expires)),
a2387d54 Andy McCall ↶ 72
        ]);
73
        if let (Some(g), Value::Map(m)) = (&self.grant, &mut v) {
74
            m.insert(8, Value::Bytes(g.encode()));
75
        }
76
        v
02e69188 Andy McCall ↶ 77
    }
78
79
    pub fn encode(&self) -> Vec<u8> {
80
        cbor::encode(&self.to_value())
81
    }
82
83
    pub fn hash(&self) -> ObjectRef {
84
        hash_object(ObjectType::Certificate, &self.encode())
85
    }
86
87
    /// The short-lived key's KeyId: what its signatures name.
88
    pub fn key_id(&self) -> KeyId {
89
        self.key.hash()
90
    }
91
92
    pub fn decode(b: &[u8]) -> Result<Certificate, ObjectError> {
93
        let v = cbor::decode(b)?;
94
        let m = map(&v, "certificate")?;
95
        check_version(m)?;
96
        let key = Identity::decode(bytes(field(m, 1, "key")?, "key")?)?;
97
        let mut claims = Vec::new();
98
        for c in array(field(m, 4, "claims")?, "claims")? {
99
            let pair = array(c, "claim")?;
100
            if pair.len() != 2 {
101
                return Err(ObjectError::Schema("claim: expected [name, value]".into()));
102
            }
103
            claims.push((
104
                text(&pair[0], "claim name")?.to_owned(),
105
                text(&pair[1], "claim value")?.to_owned(),
106
            ));
107
        }
108
        let mut scope = Scope::NONE;
109
        for n in array(field(m, 5, "scope")?, "scope")? {
110
            let n = text(n, "scope")?;
111
            scope = scope
112
                | Scope::named(n).ok_or_else(|| {
113
                    ObjectError::Schema(format!("certificate: unknown scope {n:?}"))
114
                })?;
115
        }
116
        Ok(Certificate {
117
            key,
118
            issuer: oref(field(m, 2, "issuer")?, "issuer")?,
119
            subject: text(field(m, 3, "subject")?, "subject")?.to_owned(),
120
            claims,
121
            scope,
122
            not_before: uint(field(m, 6, "not_before")?, "not_before")?,
123
            expires: uint(field(m, 7, "expires")?, "expires")?,
a2387d54 Andy McCall ↶ 124
            grant: match m.get(&8) {
125
                Some(g) => Some(
126
                    SignedGrant::decode(bytes(g, "grant")?)
127
                        .map_err(|e| ObjectError::Schema(format!("grant: {e}")))?,
128
                ),
129
                None => None,
130
            },
02e69188 Andy McCall ↶ 131
        })
132
    }
133
134
    /// Whether the key may sign within `scope` at `at_ms`, a time the
135
    /// signer did not choose.
136
    pub fn covers(&self, scope: Scope, at_ms: u64) -> bool {
137
        !scope.is_empty()
138
            && self.scope.has(scope)
139
            && self.not_before <= at_ms
140
            && at_ms <= self.expires
141
    }
142
143
    /// The claim called `name`, if the certificate has one.
144
    pub fn claim(&self, name: &str) -> Option<&str> {
145
        self.claims
146
            .iter()
147
            .find(|(k, _)| k == name)
148
            .map(|(_, v)| v.as_str())
149
    }
a2387d54 Andy McCall ↶ 150
}
151
152
/// An author's grant (spec §14.7): it lets the keys an issuer certifies
153
/// for `subject` act for the author, as a delegate does, without a key of
154
/// their own. An agent's fresh keys then sign and seal changes in the
155
/// author's name while the grant lasts; the grant travels inside their
156
/// certificates, so a verifier needs nothing else. Signed by the author.
157
#[derive(Clone, Debug, PartialEq, Eq)]
158
pub struct Grant {
159
    /// The author's KeyId, which signs the grant.
160
    pub grantor: KeyId,
161
    /// The subject whose certified keys act for it: `agent:NAME@ACCOUNT`.
162
    pub subject: String,
163
    /// The issuer whose certificates count.
164
    pub issuer: KeyId,
165
    /// What they may do for the author: changes, seal.
166
    pub scope: Scope,
167
    /// Valid from and until, in wall milliseconds.
168
    pub not_before: u64,
169
    pub expires: u64,
02e69188 Andy McCall ↶ 170
}
a2387d54 Andy McCall ↶ 171
172
impl Grant {
173
    pub fn to_value(&self) -> Value {
174
        Value::map([
175
            (0, Value::Uint(1)),
176
            (1, Value::Bytes(self.grantor.0.to_vec())),
177
            (2, Value::Text(self.subject.clone())),
178
            (3, Value::Bytes(self.issuer.0.to_vec())),
179
            (
180
                4,
181
                Value::Array(
182
                    self.scope
183
                        .names()
184
                        .into_iter()
185
                        .map(|n| Value::Text(n.to_string()))
186
                        .collect(),
187
                ),
188
            ),
189
            (5, Value::Uint(self.not_before)),
190
            (6, Value::Uint(self.expires)),
191
        ])
192
    }
193
194
    pub fn encode(&self) -> Vec<u8> {
195
        cbor::encode(&self.to_value())
196
    }
197
198
    pub fn hash(&self) -> ObjectRef {
199
        hash_object(ObjectType::Grant, &self.encode())
200
    }
02e69188 Andy McCall ↶ 201
a2387d54 Andy McCall ↶ 202
    pub fn decode(b: &[u8]) -> Result<Grant, ObjectError> {
203
        let v = cbor::decode(b)?;
204
        let m = map(&v, "grant")?;
205
        check_version(m)?;
206
        let mut scope = Scope::NONE;
207
        for n in array(field(m, 4, "scope")?, "scope")? {
208
            let n = text(n, "scope")?;
209
            scope = scope
210
                | Scope::named(n)
211
                    .ok_or_else(|| ObjectError::Schema(format!("grant: unknown scope {n:?}")))?;
212
        }
213
        Ok(Grant {
214
            grantor: oref(field(m, 1, "grantor")?, "grantor")?,
215
            subject: text(field(m, 2, "subject")?, "subject")?.to_owned(),
216
            issuer: oref(field(m, 3, "issuer")?, "issuer")?,
217
            scope,
218
            not_before: uint(field(m, 5, "not_before")?, "not_before")?,
219
            expires: uint(field(m, 6, "expires")?, "expires")?,
220
        })
221
    }
222
223
    /// Whether it lets its subject do `scope` for the author at `at_ms`
224
    /// (the change's own time, as for a delegation).
225
    pub fn covers(&self, scope: Scope, at_ms: u64) -> bool {
226
        !scope.is_empty()
227
            && self.scope.has(scope)
228
            && self.not_before <= at_ms
229
            && at_ms <= self.expires
230
    }
231
}
232
233
/// A grant with its author's signature over its ObjectRef.
234
#[derive(Clone, Debug, PartialEq, Eq)]
235
pub struct SignedGrant {
236
    pub grant: Grant,
237
    pub sig: Signature,
238
}
239
240
impl SignedGrant {
241
    /// Grant `grant`, signed by the author `grantor` holds the secret of.
242
    pub fn sign(grant: Grant, grantor: &Identity, sk: &SecretKey) -> SignedGrant {
243
        let sig = sk.sign(Domain::Grant, &grantor.hash(), &grant.hash());
244
        SignedGrant { grant, sig }
245
    }
246
247
    /// Signed by `grantor`, which it names, and well formed.
248
    pub fn verify(&self, grantor: &Identity) -> Result<(), CertError> {
249
        let g = &self.grant;
250
        if grantor.hash() != g.grantor || self.sig.key != g.grantor {
251
            return Err(CertError::WrongIssuer(g.grantor.short(12)));
252
        }
253
        verify(grantor, &self.sig, Domain::Grant, &g.hash())
254
            .map_err(|e| CertError::BadSignature(e.to_string()))?;
255
        if g.expires <= g.not_before {
256
            return Err(CertError::BadLifetime);
257
        }
258
        if g.scope.is_empty() {
259
            return Err(CertError::NoScope);
260
        }
261
        Ok(())
262
    }
263
264
    /// `[grant bytes, signature]`.
265
    pub fn encode(&self) -> Vec<u8> {
266
        cbor::encode(&Value::Array(vec![
267
            Value::Bytes(self.grant.encode()),
268
            self.sig.to_value(),
269
        ]))
270
    }
271
272
    pub fn decode(b: &[u8]) -> Result<SignedGrant, CertError> {
273
        let v = cbor::decode(b).map_err(ObjectError::from)?;
274
        let a = array(&v, "signed grant")?;
275
        if a.len() != 2 {
276
            return Err(CertError::Schema(
277
                "signed grant: expected [grant, signature]".into(),
278
            ));
279
        }
280
        Ok(SignedGrant {
281
            grant: Grant::decode(bytes(&a[0], "grant")?)?,
282
            sig: Signature::from_value(&a[1])?,
283
        })
284
    }
285
}
286
098227d4 Andy McCall ↶ 287
/// What a fresh key signs (a challenge, §14.3) to show its holder asks for
288
/// the certificate: bound to the credential it is asked with, so the
289
/// proof cannot be replayed with another.
290
pub fn certify_challenge(credential: &str) -> Vec<u8> {
291
    let mut c = b"oxy1 certify ".to_vec();
292
    c.extend_from_slice(blake3::hash(credential.as_bytes()).as_bytes());
293
    c
294
}
295
02e69188 Andy McCall ↶ 296
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
297
pub enum CertError {
298
    #[error("the certificate names issuer {0}, not this one")]
299
    WrongIssuer(String),
300
    #[error("the issuer's signature does not verify: {0}")]
301
    BadSignature(String),
302
    #[error("the certificate is valid for no time, or for longer than an hour")]
303
    BadLifetime,
304
    #[error("the certificate gives no scope")]
305
    NoScope,
306
    #[error("{0}")]
307
    Schema(String),
308
}
309
310
impl From<ObjectError> for CertError {
311
    fn from(e: ObjectError) -> Self {
312
        CertError::Schema(e.to_string())
313
    }
314
}
315
316
/// A certificate with its issuer's signature over its ObjectRef.
317
#[derive(Clone, Debug, PartialEq, Eq)]
318
pub struct SignedCertificate {
319
    pub cert: Certificate,
320
    pub sig: Signature,
321
}
322
323
impl SignedCertificate {
324
    /// Issue `cert`, signed by the issuer `issuer` holds the secret of.
325
    pub fn sign(mut cert: Certificate, issuer: &Identity, sk: &SecretKey) -> SignedCertificate {
326
        // Claims in their canonical order, as encoded.
327
        cert.claims.sort();
328
        let sig = sk.sign(Domain::Certificate, &issuer.hash(), &cert.hash());
329
        SignedCertificate { cert, sig }
330
    }
331
332
    /// Signed by `issuer`, which it names, and well formed. Whether to
333
    /// trust the issuer for its subject is the policy's question.
334
    pub fn verify(&self, issuer: &Identity) -> Result<(), CertError> {
335
        let c = &self.cert;
336
        if issuer.hash() != c.issuer || self.sig.key != c.issuer {
337
            return Err(CertError::WrongIssuer(c.issuer.short(12)));
338
        }
339
        verify(issuer, &self.sig, Domain::Certificate, &c.hash())
340
            .map_err(|e| CertError::BadSignature(e.to_string()))?;
341
        if c.expires <= c.not_before || c.expires - c.not_before > MAX_LIFETIME_MS {
342
            return Err(CertError::BadLifetime);
343
        }
344
        if c.scope.is_empty() {
345
            return Err(CertError::NoScope);
346
        }
347
        Ok(())
348
    }
349
350
    /// `[certificate bytes, signature]`.
351
    pub fn encode(&self) -> Vec<u8> {
352
        cbor::encode(&Value::Array(vec![
353
            Value::Bytes(self.cert.encode()),
354
            self.sig.to_value(),
355
        ]))
356
    }
357
358
    pub fn decode(b: &[u8]) -> Result<SignedCertificate, CertError> {
359
        let v = cbor::decode(b).map_err(ObjectError::from)?;
360
        let a = array(&v, "signed certificate")?;
361
        if a.len() != 2 {
362
            return Err(CertError::Schema(
363
                "signed certificate: expected [certificate, signature]".into(),
364
            ));
365
        }
366
        Ok(SignedCertificate {
367
            cert: Certificate::decode(bytes(&a[0], "certificate")?)?,
368
            sig: Signature::from_value(&a[1])?,
369
        })
370
    }
371
}
372
373
#[cfg(test)]
374
mod tests {
375
    use super::*;
376
377
    fn issue(
378
        issuer_sk: &SecretKey,
379
        issuer: &Identity,
380
        minutes: u64,
381
    ) -> (SecretKey, SignedCertificate) {
382
        let job_sk = SecretKey::generate();
383
        let cert = Certificate {
384
            key: job_sk.identity("job 12 build", "[email protected]", 1_000),
385
            issuer: issuer.hash(),
386
            subject: "repo:andy/demo:bookmark:main".into(),
387
            claims: vec![
388
                ("run".into(), "12".into()),
389
                ("environment".into(), "prod".into()),
390
            ],
391
            scope: Scope::CHANGES | Scope::REFS,
392
            not_before: 1_000,
393
            expires: 1_000 + minutes * 60_000,
a2387d54 Andy McCall ↶ 394
            grant: None,
02e69188 Andy McCall ↶ 395
        };
396
        (job_sk, SignedCertificate::sign(cert, issuer, issuer_sk))
397
    }
398
399
    #[test]
400
    fn a_certificate_round_trips_and_verifies_with_its_issuer_only() {
401
        let hub_sk = SecretKey::generate();
402
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
403
        let (_, sc) = issue(&hub_sk, &hub, 10);
404
        let back = SignedCertificate::decode(&sc.encode()).unwrap();
405
        assert_eq!(back, sc);
406
        assert_eq!(back.cert.claims[0].0, "environment", "claims kept sorted");
407
        assert_eq!(back.cert.claim("run"), Some("12"));
408
        assert!(back.verify(&hub).is_ok());
409
        let other_sk = SecretKey::generate();
410
        let other = other_sk.identity("elsewhere", "[email protected]", 1);
411
        assert!(matches!(
412
            back.verify(&other),
413
            Err(CertError::WrongIssuer(_))
414
        ));
415
        // Changed after signing: the signature no longer covers it.
416
        let mut forged = back.clone();
417
        forged.cert.subject = "repo:andy/demo:bookmark:release".into();
418
        assert!(matches!(
419
            forged.verify(&hub),
420
            Err(CertError::BadSignature(_))
421
        ));
422
        // Signed by another key in the issuer's name.
423
        let mut stolen = back.clone();
424
        stolen.sig = other_sk.sign(Domain::Certificate, &hub.hash(), &back.cert.hash());
425
        assert!(matches!(
426
            stolen.verify(&hub),
427
            Err(CertError::BadSignature(_))
428
        ));
a2387d54 Andy McCall ↶ 429
    }
430
431
    #[test]
432
    fn a_grant_rides_in_its_certificate_and_verifies_with_its_author_only() {
433
        let hub_sk = SecretKey::generate();
434
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
435
        let ada_sk = SecretKey::generate();
436
        let ada = ada_sk.identity("Ada", "[email protected]", 1);
437
        let g = SignedGrant::sign(
438
            Grant {
439
                grantor: ada.hash(),
440
                subject: "agent:claude@ada".into(),
441
                issuer: hub.hash(),
442
                scope: Scope::CHANGES | Scope::SEAL,
443
                not_before: 1_000,
444
                expires: 2_000,
445
            },
446
            &ada,
447
            &ada_sk,
448
        );
449
        assert!(g.verify(&ada).is_ok());
450
        assert!(g.verify(&hub).is_err(), "only its author's");
451
        assert!(g.grant.covers(Scope::SEAL, 1_500));
452
        assert!(!g.grant.covers(Scope::REFS, 1_500));
453
        assert!(!g.grant.covers(Scope::SEAL, 2_001));
454
        let (_, plain) = issue(&hub_sk, &hub, 10);
455
        assert!(
456
            !matches!(cbor::decode(&plain.cert.encode()).unwrap(), Value::Map(m) if m.contains_key(&8)),
457
            "no grant, no field 8"
458
        );
459
        let mut with = plain.cert.clone();
460
        with.grant = Some(g.clone());
461
        let sc = SignedCertificate::sign(with, &hub, &hub_sk);
462
        let back = SignedCertificate::decode(&sc.encode()).unwrap();
463
        assert_eq!(back.cert.grant, Some(g));
464
        assert!(back.verify(&hub).is_ok());
465
        assert_ne!(back.cert.hash(), plain.cert.hash());
02e69188 Andy McCall ↶ 466
    }
467
468
    #[test]
469
    fn it_covers_its_scope_and_window_only() {
470
        let hub_sk = SecretKey::generate();
471
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
472
        let (_, sc) = issue(&hub_sk, &hub, 10);
473
        let c = &sc.cert;
474
        assert!(c.covers(Scope::CHANGES, 1_000) && c.covers(Scope::REFS, 1_000 + 10 * 60_000));
475
        assert!(!c.covers(Scope::SEAL, 2_000), "not a scope it gives");
476
        assert!(!c.covers(Scope::CHANGES, 999), "before it");
477
        assert!(
478
            !c.covers(Scope::CHANGES, 1_000 + 10 * 60_000 + 1),
479
            "after it"
480
        );
481
        assert!(!c.covers(Scope::NONE, 2_000));
482
        let (_, long) = issue(&hub_sk, &hub, 61);
483
        assert_eq!(long.verify(&hub), Err(CertError::BadLifetime));
098227d4 Andy McCall ↶ 484
    }
485
486
    #[test]
487
    fn the_proof_of_possession_is_bound_to_its_credential() {
488
        let sk = SecretKey::generate();
489
        let id = sk.identity("job", "[email protected]", 1);
490
        let proof = sk
491
            .sign_challenge(&id.hash(), &certify_challenge("flowjob_aaa"))
492
            .unwrap();
493
        assert!(
494
            crate::identity::verify_challenge(&id, &proof, &certify_challenge("flowjob_aaa"))
495
                .is_ok()
496
        );
497
        assert!(
498
            crate::identity::verify_challenge(&id, &proof, &certify_challenge("flowjob_bbb"))
499
                .is_err()
500
        );
02e69188 Andy McCall ↶ 501
    }
502
503
    #[test]
504
    fn a_signature_by_the_short_lived_key_names_it() {
505
        let hub_sk = SecretKey::generate();
506
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
507
        let (job_sk, sc) = issue(&hub_sk, &hub, 10);
508
        let target = ObjectRef([5; 32]);
509
        let sig = job_sk.sign(Domain::Change, &sc.cert.key_id(), &target);
510
        assert_eq!(sig.key, sc.cert.key_id());
511
        assert!(verify(&sc.cert.key, &sig, Domain::Change, &target).is_ok());
512
    }
513
}