OXYGENHUB

Coverage / crates/oxy-core/src/cert.rs 93.8%

305 of 325 lines run, 44 of 70 functions · run #381 on main · run missed

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 (the transparency log's integrated time, §14.8, else a host's
12
//! acceptance).
13
14
use crate::cbor::{self, Value};
15
use crate::hash::{hash_object, KeyId, ObjectRef, ObjectType};
16
use crate::identity::{verify, Domain, Identity, Scope, SecretKey, Signature};
17
use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, ObjectError};
18
19
/// The longest a certificate may be valid. Jobs get ten minutes; an hour
20
/// leaves room for an issuer that rounds up, and no more.
21
pub const MAX_LIFETIME_MS: u64 = 60 * 60 * 1000;
22
23
#[derive(Clone, Debug, PartialEq, Eq)]
24
pub struct Certificate {
25
    /// The short-lived key's public identity.
26
    pub key: Identity,
27
    /// The issuer's KeyId (a hub's key).
28
    pub issuer: KeyId,
29
    /// Who proved it: `repo:OWNER/NAME:bookmark:B`, `agent:NAME@ACCOUNT`.
30
    pub subject: String,
31
    /// What else the proof said (environment, run, job), sorted by name.
32
    pub claims: Vec<(String, String)>,
33
    /// What the key may sign: changes, refs, seals.
34
    pub scope: Scope,
35
    /// Valid from and until, in wall milliseconds.
36
    pub not_before: u64,
37
    pub expires: u64,
38
    /// The grant that lets the subject act for an author, when an agent
39
    /// asked with one (field 8, only when present).
40
    pub grant: Option<SignedGrant>,
41
}
42
43
impl Certificate {
44233×
    pub fn to_value(&self) -> Value {
45233×
        let mut claims = self.claims.clone();
46233×
        claims.sort();
47233×
        let mut v = Value::map([
48233×
            (0, Value::Uint(1)),
49233×
            (1, Value::Bytes(self.key.encode())),
50233×
            (2, Value::Bytes(self.issuer.0.to_vec())),
51233×
            (3, Value::Text(self.subject.clone())),
52
            (
53
                4,
54
                Value::Array(
55233×
                    claims
56233×
                        .into_iter()
57233×
                        .map(|(k, v)| Value::Array(vec![Value::Text(k), Value::Text(v)]))
58233×
                        .collect(),
59
                ),
60
            ),
61
            (
62
                5,
63
                Value::Array(
64233×
                    self.scope
65233×
                        .names()
66233×
                        .into_iter()
67499×
                        .map(|n| Value::Text(n.to_string()))
68233×
                        .collect(),
69
                ),
70
            ),
71233×
            (6, Value::Uint(self.not_before)),
72233×
            (7, Value::Uint(self.expires)),
73
        ]);
74233×
        if let (Some(g), Value::Map(m)) = (&self.grant, &mut v) {
7548×
            m.insert(8, Value::Bytes(g.encode()));
76185×
        }
77233×
        v
78233×
    }
79
80233×
    pub fn encode(&self) -> Vec<u8> {
81233×
        cbor::encode(&self.to_value())
82233×
    }
83
84169×
    pub fn hash(&self) -> ObjectRef {
85169×
        hash_object(ObjectType::Certificate, &self.encode())
86169×
    }
87
88
    /// The short-lived key's KeyId: what its signatures name.
89114×
    pub fn key_id(&self) -> KeyId {
90114×
        self.key.hash()
91114×
    }
92
93184×
    pub fn decode(b: &[u8]) -> Result<Certificate, ObjectError> {
94184×
        let v = cbor::decode(b)?;
95184×
        let m = map(&v, "certificate")?;
96184×
        check_version(m)?;
97184×
        let key = Identity::decode(bytes(field(m, 1, "key")?, "key")?)?;
98184×
        let mut claims = Vec::new();
99184×
        for c in array(field(m, 4, "claims")?, "claims")? {
1005×
            let pair = array(c, "claim")?;
1015×
            if pair.len() != 2 {
1020×
                return Err(ObjectError::Schema("claim: expected [name, value]".into()));
1035×
            }
1045×
            claims.push((
1055×
                text(&pair[0], "claim name")?.to_owned(),
1065×
                text(&pair[1], "claim value")?.to_owned(),
107
            ));
108
        }
109184×
        let mut scope = Scope::NONE;
110394×
        for n in array(field(m, 5, "scope")?, "scope")? {
111394×
            let n = text(n, "scope")?;
112394×
            scope = scope
113394×
                | Scope::named(n).ok_or_else(|| {
1140×
                    ObjectError::Schema(format!("certificate: unknown scope {n:?}"))
1150×
                })?;
116
        }
117
        Ok(Certificate {
118184×
            key,
119184×
            issuer: oref(field(m, 2, "issuer")?, "issuer")?,
120184×
            subject: text(field(m, 3, "subject")?, "subject")?.to_owned(),
121184×
            claims,
122184×
            scope,
123184×
            not_before: uint(field(m, 6, "not_before")?, "not_before")?,
124184×
            expires: uint(field(m, 7, "expires")?, "expires")?,
125184×
            grant: match m.get(&8) {
12630×
                Some(g) => Some(
12730×
                    SignedGrant::decode(bytes(g, "grant")?)
12830×
                        .map_err(|e| ObjectError::Schema(format!("grant: {e}")))?,
129
                ),
130154×
                None => None,
131
            },
132
        })
133184×
    }
134
135
    /// Whether the key may sign within `scope` at `at_ms`, a time the
136
    /// signer did not choose.
13764×
    pub fn covers(&self, scope: Scope, at_ms: u64) -> bool {
13864×
        !scope.is_empty()
13963×
            && self.scope.has(scope)
14061×
            && self.not_before <= at_ms
14160×
            && at_ms <= self.expires
14264×
    }
143
144
    /// The claim called `name`, if the certificate has one.
1451×
    pub fn claim(&self, name: &str) -> Option<&str> {
1461×
        self.claims
1471×
            .iter()
1482×
            .find(|(k, _)| k == name)
1491×
            .map(|(_, v)| v.as_str())
1501×
    }
151
}
152
153
/// An author's grant (spec §14.7): it lets the keys an issuer certifies
154
/// for `subject` act for the author, as a delegate does, without a key of
155
/// their own. An agent's fresh keys then sign and seal changes in the
156
/// author's name while the grant lasts; the grant travels inside their
157
/// certificates, so a verifier needs nothing else. Signed by the author.
158
#[derive(Clone, Debug, PartialEq, Eq)]
159
pub struct Grant {
160
    /// The author's KeyId, which signs the grant.
161
    pub grantor: KeyId,
162
    /// The subject whose certified keys act for it: `agent:NAME@ACCOUNT`.
163
    pub subject: String,
164
    /// The issuer whose certificates count.
165
    pub issuer: KeyId,
166
    /// What they may do for the author: changes, seal.
167
    pub scope: Scope,
168
    /// Valid from and until, in wall milliseconds.
169
    pub not_before: u64,
170
    pub expires: u64,
171
}
172
173
impl Grant {
17462×
    pub fn to_value(&self) -> Value {
17562×
        Value::map([
17662×
            (0, Value::Uint(1)),
17762×
            (1, Value::Bytes(self.grantor.0.to_vec())),
17862×
            (2, Value::Text(self.subject.clone())),
17962×
            (3, Value::Bytes(self.issuer.0.to_vec())),
180
            (
181
                4,
182
                Value::Array(
18362×
                    self.scope
18462×
                        .names()
18562×
                        .into_iter()
186124×
                        .map(|n| Value::Text(n.to_string()))
18762×
                        .collect(),
188
                ),
189
            ),
19062×
            (5, Value::Uint(self.not_before)),
19162×
            (6, Value::Uint(self.expires)),
192
        ])
19362×
    }
194
19562×
    pub fn encode(&self) -> Vec<u8> {
19662×
        cbor::encode(&self.to_value())
19762×
    }
198
19914×
    pub fn hash(&self) -> ObjectRef {
20014×
        hash_object(ObjectType::Grant, &self.encode())
20114×
    }
202
20330×
    pub fn decode(b: &[u8]) -> Result<Grant, ObjectError> {
20430×
        let v = cbor::decode(b)?;
20530×
        let m = map(&v, "grant")?;
20630×
        check_version(m)?;
20730×
        let mut scope = Scope::NONE;
20860×
        for n in array(field(m, 4, "scope")?, "scope")? {
20960×
            let n = text(n, "scope")?;
21060×
            scope = scope
21160×
                | Scope::named(n)
21260×
                    .ok_or_else(|| ObjectError::Schema(format!("grant: unknown scope {n:?}")))?;
213
        }
214
        Ok(Grant {
21530×
            grantor: oref(field(m, 1, "grantor")?, "grantor")?,
21630×
            subject: text(field(m, 2, "subject")?, "subject")?.to_owned(),
21730×
            issuer: oref(field(m, 3, "issuer")?, "issuer")?,
21830×
            scope,
21930×
            not_before: uint(field(m, 5, "not_before")?, "not_before")?,
22030×
            expires: uint(field(m, 6, "expires")?, "expires")?,
221
        })
22230×
    }
223
224
    /// Whether it lets its subject do `scope` for the author at `at_ms`
225
    /// (the change's own time, as for a delegation).
22610×
    pub fn covers(&self, scope: Scope, at_ms: u64) -> bool {
22710×
        !scope.is_empty()
22810×
            && self.scope.has(scope)
2299×
            && self.not_before <= at_ms
2309×
            && at_ms <= self.expires
23110×
    }
232
}
233
234
/// A grant with its author's signature over its ObjectRef.
235
#[derive(Clone, Debug, PartialEq, Eq)]
236
pub struct SignedGrant {
237
    pub grant: Grant,
238
    pub sig: Signature,
239
}
240
241
impl SignedGrant {
242
    /// Grant `grant`, signed by the author `grantor` holds the secret of.
2436×
    pub fn sign(grant: Grant, grantor: &Identity, sk: &SecretKey) -> SignedGrant {
2446×
        let sig = sk.sign(Domain::Grant, &grantor.hash(), &grant.hash());
2456×
        SignedGrant { grant, sig }
2466×
    }
247
248
    /// Signed by `grantor`, which it names, and well formed.
2499×
    pub fn verify(&self, grantor: &Identity) -> Result<(), CertError> {
2509×
        let g = &self.grant;
2519×
        if grantor.hash() != g.grantor || self.sig.key != g.grantor {
2521×
            return Err(CertError::WrongIssuer(g.grantor.short(12)));
2538×
        }
2548×
        verify(grantor, &self.sig, Domain::Grant, &g.hash())
2558×
            .map_err(|e| CertError::BadSignature(e.to_string()))?;
2568×
        if g.expires <= g.not_before {
2570×
            return Err(CertError::BadLifetime);
2588×
        }
2598×
        if g.scope.is_empty() {
2600×
            return Err(CertError::NoScope);
2618×
        }
2628×
        Ok(())
2639×
    }
264
265
    /// `[grant bytes, signature]`.
26648×
    pub fn encode(&self) -> Vec<u8> {
26748×
        cbor::encode(&Value::Array(vec![
26848×
            Value::Bytes(self.grant.encode()),
26948×
            self.sig.to_value(),
27048×
        ]))
27148×
    }
272
27330×
    pub fn decode(b: &[u8]) -> Result<SignedGrant, CertError> {
27430×
        let v = cbor::decode(b).map_err(ObjectError::from)?;
27530×
        let a = array(&v, "signed grant")?;
27630×
        if a.len() != 2 {
2770×
            return Err(CertError::Schema(
2780×
                "signed grant: expected [grant, signature]".into(),
2790×
            ));
28030×
        }
281
        Ok(SignedGrant {
28230×
            grant: Grant::decode(bytes(&a[0], "grant")?)?,
28330×
            sig: Signature::from_value(&a[1])?,
284
        })
28530×
    }
286
}
287
288
/// What a fresh key signs (a challenge, §14.3) to show its holder asks for
289
/// the certificate: bound to the credential it is asked with, so the
290
/// proof cannot be replayed with another.
2913×
pub fn certify_challenge(credential: &str) -> Vec<u8> {
2923×
    let mut c = b"oxy1 certify ".to_vec();
2933×
    c.extend_from_slice(blake3::hash(credential.as_bytes()).as_bytes());
2943×
    c
2953×
}
296
297
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
298
pub enum CertError {
299
    #[error("the certificate names issuer {0}, not this one")]
300
    WrongIssuer(String),
301
    #[error("the issuer's signature does not verify: {0}")]
302
    BadSignature(String),
303
    #[error("the certificate is valid for no time, or for longer than an hour")]
304
    BadLifetime,
305
    #[error("the certificate gives no scope")]
306
    NoScope,
307
    #[error("{0}")]
308
    Schema(String),
309
}
310
311
impl From<ObjectError> for CertError {
3120×
    fn from(e: ObjectError) -> Self {
3130×
        CertError::Schema(e.to_string())
3140×
    }
315
}
316
317
/// A certificate with its issuer's signature over its ObjectRef.
318
#[derive(Clone, Debug, PartialEq, Eq)]
319
pub struct SignedCertificate {
320
    pub cert: Certificate,
321
    pub sig: Signature,
322
}
323
324
impl SignedCertificate {
325
    /// Issue `cert`, signed by the issuer `issuer` holds the secret of.
32629×
    pub fn sign(mut cert: Certificate, issuer: &Identity, sk: &SecretKey) -> SignedCertificate {
327
        // Claims in their canonical order, as encoded.
32829×
        cert.claims.sort();
32929×
        let sig = sk.sign(Domain::Certificate, &issuer.hash(), &cert.hash());
33029×
        SignedCertificate { cert, sig }
33129×
    }
332
333
    /// Signed by `issuer`, which it names, and well formed. Whether to
334
    /// trust the issuer for its subject is the policy's question.
335111×
    pub fn verify(&self, issuer: &Identity) -> Result<(), CertError> {
336111×
        let c = &self.cert;
337111×
        if issuer.hash() != c.issuer || self.sig.key != c.issuer {
3382×
            return Err(CertError::WrongIssuer(c.issuer.short(12)));
339109×
        }
340109×
        verify(issuer, &self.sig, Domain::Certificate, &c.hash())
341109×
            .map_err(|e| CertError::BadSignature(e.to_string()))?;
342107×
        if c.expires <= c.not_before || c.expires - c.not_before > MAX_LIFETIME_MS {
3431×
            return Err(CertError::BadLifetime);
344106×
        }
345106×
        if c.scope.is_empty() {
3460×
            return Err(CertError::NoScope);
347106×
        }
348106×
        Ok(())
349111×
    }
350
351
    /// `[certificate bytes, signature]`.
35210×
    pub fn encode(&self) -> Vec<u8> {
35310×
        cbor::encode(&Value::Array(vec![
35410×
            Value::Bytes(self.cert.encode()),
35510×
            self.sig.to_value(),
35610×
        ]))
35710×
    }
358
35910×
    pub fn decode(b: &[u8]) -> Result<SignedCertificate, CertError> {
36010×
        let v = cbor::decode(b).map_err(ObjectError::from)?;
36110×
        let a = array(&v, "signed certificate")?;
36210×
        if a.len() != 2 {
3630×
            return Err(CertError::Schema(
3640×
                "signed certificate: expected [certificate, signature]".into(),
3650×
            ));
36610×
        }
367
        Ok(SignedCertificate {
36810×
            cert: Certificate::decode(bytes(&a[0], "certificate")?)?,
36910×
            sig: Signature::from_value(&a[1])?,
370
        })
37110×
    }
372
}
373
374
#[cfg(test)]
375
mod tests {
376
    use super::*;
377
3785×
    fn issue(
3795×
        issuer_sk: &SecretKey,
3805×
        issuer: &Identity,
3815×
        minutes: u64,
3825×
    ) -> (SecretKey, SignedCertificate) {
3835×
        let job_sk = SecretKey::generate();
3845×
        let cert = Certificate {
3855×
            key: job_sk.identity("job 12 build", "[email protected]", 1_000),
3865×
            issuer: issuer.hash(),
3875×
            subject: "repo:andy/demo:bookmark:main".into(),
3885×
            claims: vec![
3895×
                ("run".into(), "12".into()),
3905×
                ("environment".into(), "prod".into()),
3915×
            ],
3925×
            scope: Scope::CHANGES | Scope::REFS,
3935×
            not_before: 1_000,
3945×
            expires: 1_000 + minutes * 60_000,
3955×
            grant: None,
3965×
        };
3975×
        (job_sk, SignedCertificate::sign(cert, issuer, issuer_sk))
3985×
    }
399
400
    #[test]
4011×
    fn a_certificate_round_trips_and_verifies_with_its_issuer_only() {
4021×
        let hub_sk = SecretKey::generate();
4031×
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
4041×
        let (_, sc) = issue(&hub_sk, &hub, 10);
4051×
        let back = SignedCertificate::decode(&sc.encode()).unwrap();
4061×
        assert_eq!(back, sc);
4071×
        assert_eq!(back.cert.claims[0].0, "environment", "claims kept sorted");
4081×
        assert_eq!(back.cert.claim("run"), Some("12"));
4091×
        assert!(back.verify(&hub).is_ok());
4101×
        let other_sk = SecretKey::generate();
4111×
        let other = other_sk.identity("elsewhere", "[email protected]", 1);
4121×
        assert!(matches!(
4131×
            back.verify(&other),
414
            Err(CertError::WrongIssuer(_))
415
        ));
416
        // Changed after signing: the signature no longer covers it.
4171×
        let mut forged = back.clone();
4181×
        forged.cert.subject = "repo:andy/demo:bookmark:release".into();
4191×
        assert!(matches!(
4201×
            forged.verify(&hub),
421
            Err(CertError::BadSignature(_))
422
        ));
423
        // Signed by another key in the issuer's name.
4241×
        let mut stolen = back.clone();
4251×
        stolen.sig = other_sk.sign(Domain::Certificate, &hub.hash(), &back.cert.hash());
4261×
        assert!(matches!(
4271×
            stolen.verify(&hub),
428
            Err(CertError::BadSignature(_))
429
        ));
4301×
    }
431
432
    #[test]
4331×
    fn a_grant_rides_in_its_certificate_and_verifies_with_its_author_only() {
4341×
        let hub_sk = SecretKey::generate();
4351×
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
4361×
        let ada_sk = SecretKey::generate();
4371×
        let ada = ada_sk.identity("Ada", "[email protected]", 1);
4381×
        let g = SignedGrant::sign(
4391×
            Grant {
4401×
                grantor: ada.hash(),
4411×
                subject: "agent:claude@ada".into(),
4421×
                issuer: hub.hash(),
4431×
                scope: Scope::CHANGES | Scope::SEAL,
4441×
                not_before: 1_000,
4451×
                expires: 2_000,
4461×
            },
4471×
            &ada,
4481×
            &ada_sk,
449
        );
4501×
        assert!(g.verify(&ada).is_ok());
4511×
        assert!(g.verify(&hub).is_err(), "only its author's");
4521×
        assert!(g.grant.covers(Scope::SEAL, 1_500));
4531×
        assert!(!g.grant.covers(Scope::REFS, 1_500));
4541×
        assert!(!g.grant.covers(Scope::SEAL, 2_001));
4551×
        let (_, plain) = issue(&hub_sk, &hub, 10);
4561×
        assert!(
4571×
            !matches!(cbor::decode(&plain.cert.encode()).unwrap(), Value::Map(m) if m.contains_key(&8)),
458
            "no grant, no field 8"
459
        );
4601×
        let mut with = plain.cert.clone();
4611×
        with.grant = Some(g.clone());
4621×
        let sc = SignedCertificate::sign(with, &hub, &hub_sk);
4631×
        let back = SignedCertificate::decode(&sc.encode()).unwrap();
4641×
        assert_eq!(back.cert.grant, Some(g));
4651×
        assert!(back.verify(&hub).is_ok());
4661×
        assert_ne!(back.cert.hash(), plain.cert.hash());
4671×
    }
468
469
    #[test]
4701×
    fn it_covers_its_scope_and_window_only() {
4711×
        let hub_sk = SecretKey::generate();
4721×
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
4731×
        let (_, sc) = issue(&hub_sk, &hub, 10);
4741×
        let c = &sc.cert;
4751×
        assert!(c.covers(Scope::CHANGES, 1_000) && c.covers(Scope::REFS, 1_000 + 10 * 60_000));
4761×
        assert!(!c.covers(Scope::SEAL, 2_000), "not a scope it gives");
4771×
        assert!(!c.covers(Scope::CHANGES, 999), "before it");
4781×
        assert!(
4791×
            !c.covers(Scope::CHANGES, 1_000 + 10 * 60_000 + 1),
480
            "after it"
481
        );
4821×
        assert!(!c.covers(Scope::NONE, 2_000));
4831×
        let (_, long) = issue(&hub_sk, &hub, 61);
4841×
        assert_eq!(long.verify(&hub), Err(CertError::BadLifetime));
4851×
    }
486
487
    #[test]
4881×
    fn the_proof_of_possession_is_bound_to_its_credential() {
4891×
        let sk = SecretKey::generate();
4901×
        let id = sk.identity("job", "[email protected]", 1);
4911×
        let proof = sk
4921×
            .sign_challenge(&id.hash(), &certify_challenge("flowjob_aaa"))
4931×
            .unwrap();
4941×
        assert!(
4951×
            crate::identity::verify_challenge(&id, &proof, &certify_challenge("flowjob_aaa"))
4961×
                .is_ok()
497
        );
4981×
        assert!(
4991×
            crate::identity::verify_challenge(&id, &proof, &certify_challenge("flowjob_bbb"))
5001×
                .is_err()
501
        );
5021×
    }
503
504
    #[test]
5051×
    fn a_signature_by_the_short_lived_key_names_it() {
5061×
        let hub_sk = SecretKey::generate();
5071×
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
5081×
        let (job_sk, sc) = issue(&hub_sk, &hub, 10);
5091×
        let target = ObjectRef([5; 32]);
5101×
        let sig = job_sk.sign(Domain::Change, &sc.cert.key_id(), &target);
5111×
        assert_eq!(sig.key, sc.cert.key_id());
5121×
        assert!(verify(&sc.cert.key, &sig, Domain::Change, &target).is_ok());
5131×
    }
514
}