//! Certificates for short-lived keys (spec §14.7, oxygenhub#241, //! oxygenhub docs/keyless.md). An issuer, a hub's key, binds a fresh key //! to the identity that proved itself (an OIDC subject) for minutes: a Flow //! job or an agent signs with that key and throws it away. The certificate //! is signed with the issuer's composite key, so it is post-quantum like //! every other signature in Oxygen. //! //! Whether to trust an issuer, and for which subjects, is the trust //! policy's (§14.5); whether a signature was made inside the certificate's //! window needs time the signer does not choose, which the caller passes //! in (a host's acceptance now, the transparency log's later). use crate::cbor::{self, Value}; use crate::hash::{hash_object, KeyId, ObjectRef, ObjectType}; use crate::identity::{verify, Domain, Identity, Scope, SecretKey, Signature}; use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, ObjectError}; /// The longest a certificate may be valid. Jobs get ten minutes; an hour /// leaves room for an issuer that rounds up, and no more. pub const MAX_LIFETIME_MS: u64 = 60 * 60 * 1000; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Certificate { /// The short-lived key's public identity. pub key: Identity, /// The issuer's KeyId (a hub's key). pub issuer: KeyId, /// Who proved it: `repo:OWNER/NAME:bookmark:B`, `agent:NAME@ACCOUNT`. pub subject: String, /// What else the proof said (environment, run, job), sorted by name. pub claims: Vec<(String, String)>, /// What the key may sign: changes, refs, seals. pub scope: Scope, /// Valid from and until, in wall milliseconds. pub not_before: u64, pub expires: u64, } impl Certificate { pub fn to_value(&self) -> Value { let mut claims = self.claims.clone(); claims.sort(); Value::map([ (0, Value::Uint(1)), (1, Value::Bytes(self.key.encode())), (2, Value::Bytes(self.issuer.0.to_vec())), (3, Value::Text(self.subject.clone())), ( 4, Value::Array( claims .into_iter() .map(|(k, v)| Value::Array(vec![Value::Text(k), Value::Text(v)])) .collect(), ), ), ( 5, Value::Array( self.scope .names() .into_iter() .map(|n| Value::Text(n.to_string())) .collect(), ), ), (6, Value::Uint(self.not_before)), (7, Value::Uint(self.expires)), ]) } pub fn encode(&self) -> Vec { cbor::encode(&self.to_value()) } pub fn hash(&self) -> ObjectRef { hash_object(ObjectType::Certificate, &self.encode()) } /// The short-lived key's KeyId: what its signatures name. pub fn key_id(&self) -> KeyId { self.key.hash() } pub fn decode(b: &[u8]) -> Result { let v = cbor::decode(b)?; let m = map(&v, "certificate")?; check_version(m)?; let key = Identity::decode(bytes(field(m, 1, "key")?, "key")?)?; let mut claims = Vec::new(); for c in array(field(m, 4, "claims")?, "claims")? { let pair = array(c, "claim")?; if pair.len() != 2 { return Err(ObjectError::Schema("claim: expected [name, value]".into())); } claims.push(( text(&pair[0], "claim name")?.to_owned(), text(&pair[1], "claim value")?.to_owned(), )); } let mut scope = Scope::NONE; for n in array(field(m, 5, "scope")?, "scope")? { let n = text(n, "scope")?; scope = scope | Scope::named(n).ok_or_else(|| { ObjectError::Schema(format!("certificate: unknown scope {n:?}")) })?; } Ok(Certificate { key, issuer: oref(field(m, 2, "issuer")?, "issuer")?, subject: text(field(m, 3, "subject")?, "subject")?.to_owned(), claims, scope, not_before: uint(field(m, 6, "not_before")?, "not_before")?, expires: uint(field(m, 7, "expires")?, "expires")?, }) } /// Whether the key may sign within `scope` at `at_ms`, a time the /// signer did not choose. pub fn covers(&self, scope: Scope, at_ms: u64) -> bool { !scope.is_empty() && self.scope.has(scope) && self.not_before <= at_ms && at_ms <= self.expires } /// The claim called `name`, if the certificate has one. pub fn claim(&self, name: &str) -> Option<&str> { self.claims .iter() .find(|(k, _)| k == name) .map(|(_, v)| v.as_str()) } } #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum CertError { #[error("the certificate names issuer {0}, not this one")] WrongIssuer(String), #[error("the issuer's signature does not verify: {0}")] BadSignature(String), #[error("the certificate is valid for no time, or for longer than an hour")] BadLifetime, #[error("the certificate gives no scope")] NoScope, #[error("{0}")] Schema(String), } impl From for CertError { fn from(e: ObjectError) -> Self { CertError::Schema(e.to_string()) } } /// A certificate with its issuer's signature over its ObjectRef. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SignedCertificate { pub cert: Certificate, pub sig: Signature, } impl SignedCertificate { /// Issue `cert`, signed by the issuer `issuer` holds the secret of. pub fn sign(mut cert: Certificate, issuer: &Identity, sk: &SecretKey) -> SignedCertificate { // Claims in their canonical order, as encoded. cert.claims.sort(); let sig = sk.sign(Domain::Certificate, &issuer.hash(), &cert.hash()); SignedCertificate { cert, sig } } /// Signed by `issuer`, which it names, and well formed. Whether to /// trust the issuer for its subject is the policy's question. pub fn verify(&self, issuer: &Identity) -> Result<(), CertError> { let c = &self.cert; if issuer.hash() != c.issuer || self.sig.key != c.issuer { return Err(CertError::WrongIssuer(c.issuer.short(12))); } verify(issuer, &self.sig, Domain::Certificate, &c.hash()) .map_err(|e| CertError::BadSignature(e.to_string()))?; if c.expires <= c.not_before || c.expires - c.not_before > MAX_LIFETIME_MS { return Err(CertError::BadLifetime); } if c.scope.is_empty() { return Err(CertError::NoScope); } Ok(()) } /// `[certificate bytes, signature]`. pub fn encode(&self) -> Vec { cbor::encode(&Value::Array(vec![ Value::Bytes(self.cert.encode()), self.sig.to_value(), ])) } pub fn decode(b: &[u8]) -> Result { let v = cbor::decode(b).map_err(ObjectError::from)?; let a = array(&v, "signed certificate")?; if a.len() != 2 { return Err(CertError::Schema( "signed certificate: expected [certificate, signature]".into(), )); } Ok(SignedCertificate { cert: Certificate::decode(bytes(&a[0], "certificate")?)?, sig: Signature::from_value(&a[1])?, }) } } #[cfg(test)] mod tests { use super::*; fn issue( issuer_sk: &SecretKey, issuer: &Identity, minutes: u64, ) -> (SecretKey, SignedCertificate) { let job_sk = SecretKey::generate(); let cert = Certificate { key: job_sk.identity("job 12 build", "flow@oxygenhub.io", 1_000), issuer: issuer.hash(), subject: "repo:andy/demo:bookmark:main".into(), claims: vec![ ("run".into(), "12".into()), ("environment".into(), "prod".into()), ], scope: Scope::CHANGES | Scope::REFS, not_before: 1_000, expires: 1_000 + minutes * 60_000, }; (job_sk, SignedCertificate::sign(cert, issuer, issuer_sk)) } #[test] fn a_certificate_round_trips_and_verifies_with_its_issuer_only() { let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("oxygenhub.io", "hub@oxygenhub.io", 1); let (_, sc) = issue(&hub_sk, &hub, 10); let back = SignedCertificate::decode(&sc.encode()).unwrap(); assert_eq!(back, sc); assert_eq!(back.cert.claims[0].0, "environment", "claims kept sorted"); assert_eq!(back.cert.claim("run"), Some("12")); assert!(back.verify(&hub).is_ok()); let other_sk = SecretKey::generate(); let other = other_sk.identity("elsewhere", "x@example.org", 1); assert!(matches!( back.verify(&other), Err(CertError::WrongIssuer(_)) )); // Changed after signing: the signature no longer covers it. let mut forged = back.clone(); forged.cert.subject = "repo:andy/demo:bookmark:release".into(); assert!(matches!( forged.verify(&hub), Err(CertError::BadSignature(_)) )); // Signed by another key in the issuer's name. let mut stolen = back.clone(); stolen.sig = other_sk.sign(Domain::Certificate, &hub.hash(), &back.cert.hash()); assert!(matches!( stolen.verify(&hub), Err(CertError::BadSignature(_)) )); } #[test] fn it_covers_its_scope_and_window_only() { let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("oxygenhub.io", "hub@oxygenhub.io", 1); let (_, sc) = issue(&hub_sk, &hub, 10); let c = &sc.cert; assert!(c.covers(Scope::CHANGES, 1_000) && c.covers(Scope::REFS, 1_000 + 10 * 60_000)); assert!(!c.covers(Scope::SEAL, 2_000), "not a scope it gives"); assert!(!c.covers(Scope::CHANGES, 999), "before it"); assert!( !c.covers(Scope::CHANGES, 1_000 + 10 * 60_000 + 1), "after it" ); assert!(!c.covers(Scope::NONE, 2_000)); let (_, long) = issue(&hub_sk, &hub, 61); assert_eq!(long.verify(&hub), Err(CertError::BadLifetime)); } #[test] fn a_signature_by_the_short_lived_key_names_it() { let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("oxygenhub.io", "hub@oxygenhub.io", 1); let (job_sk, sc) = issue(&hub_sk, &hub, 10); let target = ObjectRef([5; 32]); let sig = job_sk.sign(Domain::Change, &sc.cert.key_id(), &target); assert_eq!(sig.key, sc.cert.key_id()); assert!(verify(&sc.cert.key, &sig, Domain::Change, &target).is_ok()); } }