OXYGENHUB

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

188 of 200 lines run, 22 of 43 functions · run #312 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 (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,
37
}
38
39
impl Certificate {
4058×
    pub fn to_value(&self) -> Value {
4158×
        let mut claims = self.claims.clone();
4258×
        claims.sort();
4358×
        Value::map([
4458×
            (0, Value::Uint(1)),
4558×
            (1, Value::Bytes(self.key.encode())),
4658×
            (2, Value::Bytes(self.issuer.0.to_vec())),
4758×
            (3, Value::Text(self.subject.clone())),
48
            (
49
                4,
50
                Value::Array(
5158×
                    claims
5258×
                        .into_iter()
5358×
                        .map(|(k, v)| Value::Array(vec![Value::Text(k), Value::Text(v)]))
5458×
                        .collect(),
55
                ),
56
            ),
57
            (
58
                5,
59
                Value::Array(
6058×
                    self.scope
6158×
                        .names()
6258×
                        .into_iter()
6399×
                        .map(|n| Value::Text(n.to_string()))
6458×
                        .collect(),
65
                ),
66
            ),
6758×
            (6, Value::Uint(self.not_before)),
6858×
            (7, Value::Uint(self.expires)),
69
        ])
7058×
    }
71
7258×
    pub fn encode(&self) -> Vec<u8> {
7358×
        cbor::encode(&self.to_value())
7458×
    }
75
7645×
    pub fn hash(&self) -> ObjectRef {
7745×
        hash_object(ObjectType::Certificate, &self.encode())
7845×
    }
79
80
    /// The short-lived key's KeyId: what its signatures name.
8126×
    pub fn key_id(&self) -> KeyId {
8226×
        self.key.hash()
8326×
    }
84
8523×
    pub fn decode(b: &[u8]) -> Result<Certificate, ObjectError> {
8623×
        let v = cbor::decode(b)?;
8723×
        let m = map(&v, "certificate")?;
8823×
        check_version(m)?;
8923×
        let key = Identity::decode(bytes(field(m, 1, "key")?, "key")?)?;
9023×
        let mut claims = Vec::new();
9123×
        for c in array(field(m, 4, "claims")?, "claims")? {
923×
            let pair = array(c, "claim")?;
933×
            if pair.len() != 2 {
940×
                return Err(ObjectError::Schema("claim: expected [name, value]".into()));
953×
            }
963×
            claims.push((
973×
                text(&pair[0], "claim name")?.to_owned(),
983×
                text(&pair[1], "claim value")?.to_owned(),
99
            ));
100
        }
10123×
        let mut scope = Scope::NONE;
10240×
        for n in array(field(m, 5, "scope")?, "scope")? {
10340×
            let n = text(n, "scope")?;
10440×
            scope = scope
10540×
                | Scope::named(n).ok_or_else(|| {
1060×
                    ObjectError::Schema(format!("certificate: unknown scope {n:?}"))
1070×
                })?;
108
        }
109
        Ok(Certificate {
11023×
            key,
11123×
            issuer: oref(field(m, 2, "issuer")?, "issuer")?,
11223×
            subject: text(field(m, 3, "subject")?, "subject")?.to_owned(),
11323×
            claims,
11423×
            scope,
11523×
            not_before: uint(field(m, 6, "not_before")?, "not_before")?,
11623×
            expires: uint(field(m, 7, "expires")?, "expires")?,
117
        })
11823×
    }
119
120
    /// Whether the key may sign within `scope` at `at_ms`, a time the
121
    /// signer did not choose.
12219×
    pub fn covers(&self, scope: Scope, at_ms: u64) -> bool {
12319×
        !scope.is_empty()
12418×
            && self.scope.has(scope)
12516×
            && self.not_before <= at_ms
12615×
            && at_ms <= self.expires
12719×
    }
128
129
    /// The claim called `name`, if the certificate has one.
1301×
    pub fn claim(&self, name: &str) -> Option<&str> {
1311×
        self.claims
1321×
            .iter()
1332×
            .find(|(k, _)| k == name)
1341×
            .map(|(_, v)| v.as_str())
1351×
    }
136
}
137
138
/// What a fresh key signs (a challenge, §14.3) to show its holder asks for
139
/// the certificate: bound to the credential it is asked with, so the
140
/// proof cannot be replayed with another.
1413×
pub fn certify_challenge(credential: &str) -> Vec<u8> {
1423×
    let mut c = b"oxy1 certify ".to_vec();
1433×
    c.extend_from_slice(blake3::hash(credential.as_bytes()).as_bytes());
1443×
    c
1453×
}
146
147
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
148
pub enum CertError {
149
    #[error("the certificate names issuer {0}, not this one")]
150
    WrongIssuer(String),
151
    #[error("the issuer's signature does not verify: {0}")]
152
    BadSignature(String),
153
    #[error("the certificate is valid for no time, or for longer than an hour")]
154
    BadLifetime,
155
    #[error("the certificate gives no scope")]
156
    NoScope,
157
    #[error("{0}")]
158
    Schema(String),
159
}
160
161
impl From<ObjectError> for CertError {
1620×
    fn from(e: ObjectError) -> Self {
1630×
        CertError::Schema(e.to_string())
1640×
    }
165
}
166
167
/// A certificate with its issuer's signature over its ObjectRef.
168
#[derive(Clone, Debug, PartialEq, Eq)]
169
pub struct SignedCertificate {
170
    pub cert: Certificate,
171
    pub sig: Signature,
172
}
173
174
impl SignedCertificate {
175
    /// Issue `cert`, signed by the issuer `issuer` holds the secret of.
17614×
    pub fn sign(mut cert: Certificate, issuer: &Identity, sk: &SecretKey) -> SignedCertificate {
177
        // Claims in their canonical order, as encoded.
17814×
        cert.claims.sort();
17914×
        let sig = sk.sign(Domain::Certificate, &issuer.hash(), &cert.hash());
18014×
        SignedCertificate { cert, sig }
18114×
    }
182
183
    /// Signed by `issuer`, which it names, and well formed. Whether to
184
    /// trust the issuer for its subject is the policy's question.
18526×
    pub fn verify(&self, issuer: &Identity) -> Result<(), CertError> {
18626×
        let c = &self.cert;
18726×
        if issuer.hash() != c.issuer || self.sig.key != c.issuer {
1882×
            return Err(CertError::WrongIssuer(c.issuer.short(12)));
18924×
        }
19024×
        verify(issuer, &self.sig, Domain::Certificate, &c.hash())
19124×
            .map_err(|e| CertError::BadSignature(e.to_string()))?;
19222×
        if c.expires <= c.not_before || c.expires - c.not_before > MAX_LIFETIME_MS {
1931×
            return Err(CertError::BadLifetime);
19421×
        }
19521×
        if c.scope.is_empty() {
1960×
            return Err(CertError::NoScope);
19721×
        }
19821×
        Ok(())
19926×
    }
200
201
    /// `[certificate bytes, signature]`.
2023×
    pub fn encode(&self) -> Vec<u8> {
2033×
        cbor::encode(&Value::Array(vec![
2043×
            Value::Bytes(self.cert.encode()),
2053×
            self.sig.to_value(),
2063×
        ]))
2073×
    }
208
2093×
    pub fn decode(b: &[u8]) -> Result<SignedCertificate, CertError> {
2103×
        let v = cbor::decode(b).map_err(ObjectError::from)?;
2113×
        let a = array(&v, "signed certificate")?;
2123×
        if a.len() != 2 {
2130×
            return Err(CertError::Schema(
2140×
                "signed certificate: expected [certificate, signature]".into(),
2150×
            ));
2163×
        }
217
        Ok(SignedCertificate {
2183×
            cert: Certificate::decode(bytes(&a[0], "certificate")?)?,
2193×
            sig: Signature::from_value(&a[1])?,
220
        })
2213×
    }
222
}
223
224
#[cfg(test)]
225
mod tests {
226
    use super::*;
227
2284×
    fn issue(
2294×
        issuer_sk: &SecretKey,
2304×
        issuer: &Identity,
2314×
        minutes: u64,
2324×
    ) -> (SecretKey, SignedCertificate) {
2334×
        let job_sk = SecretKey::generate();
2344×
        let cert = Certificate {
2354×
            key: job_sk.identity("job 12 build", "[email protected]", 1_000),
2364×
            issuer: issuer.hash(),
2374×
            subject: "repo:andy/demo:bookmark:main".into(),
2384×
            claims: vec![
2394×
                ("run".into(), "12".into()),
2404×
                ("environment".into(), "prod".into()),
2414×
            ],
2424×
            scope: Scope::CHANGES | Scope::REFS,
2434×
            not_before: 1_000,
2444×
            expires: 1_000 + minutes * 60_000,
2454×
        };
2464×
        (job_sk, SignedCertificate::sign(cert, issuer, issuer_sk))
2474×
    }
248
249
    #[test]
2501×
    fn a_certificate_round_trips_and_verifies_with_its_issuer_only() {
2511×
        let hub_sk = SecretKey::generate();
2521×
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
2531×
        let (_, sc) = issue(&hub_sk, &hub, 10);
2541×
        let back = SignedCertificate::decode(&sc.encode()).unwrap();
2551×
        assert_eq!(back, sc);
2561×
        assert_eq!(back.cert.claims[0].0, "environment", "claims kept sorted");
2571×
        assert_eq!(back.cert.claim("run"), Some("12"));
2581×
        assert!(back.verify(&hub).is_ok());
2591×
        let other_sk = SecretKey::generate();
2601×
        let other = other_sk.identity("elsewhere", "[email protected]", 1);
2611×
        assert!(matches!(
2621×
            back.verify(&other),
263
            Err(CertError::WrongIssuer(_))
264
        ));
265
        // Changed after signing: the signature no longer covers it.
2661×
        let mut forged = back.clone();
2671×
        forged.cert.subject = "repo:andy/demo:bookmark:release".into();
2681×
        assert!(matches!(
2691×
            forged.verify(&hub),
270
            Err(CertError::BadSignature(_))
271
        ));
272
        // Signed by another key in the issuer's name.
2731×
        let mut stolen = back.clone();
2741×
        stolen.sig = other_sk.sign(Domain::Certificate, &hub.hash(), &back.cert.hash());
2751×
        assert!(matches!(
2761×
            stolen.verify(&hub),
277
            Err(CertError::BadSignature(_))
278
        ));
2791×
    }
280
281
    #[test]
2821×
    fn it_covers_its_scope_and_window_only() {
2831×
        let hub_sk = SecretKey::generate();
2841×
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
2851×
        let (_, sc) = issue(&hub_sk, &hub, 10);
2861×
        let c = &sc.cert;
2871×
        assert!(c.covers(Scope::CHANGES, 1_000) && c.covers(Scope::REFS, 1_000 + 10 * 60_000));
2881×
        assert!(!c.covers(Scope::SEAL, 2_000), "not a scope it gives");
2891×
        assert!(!c.covers(Scope::CHANGES, 999), "before it");
2901×
        assert!(
2911×
            !c.covers(Scope::CHANGES, 1_000 + 10 * 60_000 + 1),
292
            "after it"
293
        );
2941×
        assert!(!c.covers(Scope::NONE, 2_000));
2951×
        let (_, long) = issue(&hub_sk, &hub, 61);
2961×
        assert_eq!(long.verify(&hub), Err(CertError::BadLifetime));
2971×
    }
298
299
    #[test]
3001×
    fn the_proof_of_possession_is_bound_to_its_credential() {
3011×
        let sk = SecretKey::generate();
3021×
        let id = sk.identity("job", "[email protected]", 1);
3031×
        let proof = sk
3041×
            .sign_challenge(&id.hash(), &certify_challenge("flowjob_aaa"))
3051×
            .unwrap();
3061×
        assert!(
3071×
            crate::identity::verify_challenge(&id, &proof, &certify_challenge("flowjob_aaa"))
3081×
                .is_ok()
309
        );
3101×
        assert!(
3111×
            crate::identity::verify_challenge(&id, &proof, &certify_challenge("flowjob_bbb"))
3121×
                .is_err()
313
        );
3141×
    }
315
316
    #[test]
3171×
    fn a_signature_by_the_short_lived_key_names_it() {
3181×
        let hub_sk = SecretKey::generate();
3191×
        let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1);
3201×
        let (job_sk, sc) = issue(&hub_sk, &hub, 10);
3211×
        let target = ObjectRef([5; 32]);
3221×
        let sig = job_sk.sign(Domain::Change, &sc.cert.key_id(), &target);
3231×
        assert_eq!(sig.key, sc.cert.key_id());
3241×
        assert!(verify(&sc.cert.key, &sig, Domain::Change, &target).is_ok());
3251×
    }
326
}