//! 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 (the transparency log's integrated time, §14.8, else a host's //! acceptance). 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, /// The grant that lets the subject act for an author, when an agent /// asked with one (field 8, only when present). pub grant: Option, } impl Certificate { pub fn to_value(&self) -> Value { let mut claims = self.claims.clone(); claims.sort(); let mut v = 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)), ]); if let (Some(g), Value::Map(m)) = (&self.grant, &mut v) { m.insert(8, Value::Bytes(g.encode())); } v } 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")?, grant: match m.get(&8) { Some(g) => Some( SignedGrant::decode(bytes(g, "grant")?) .map_err(|e| ObjectError::Schema(format!("grant: {e}")))?, ), None => None, }, }) } /// 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()) } } /// An author's grant (spec §14.7): it lets the keys an issuer certifies /// for `subject` act for the author, as a delegate does, without a key of /// their own. An agent's fresh keys then sign and seal changes in the /// author's name while the grant lasts; the grant travels inside their /// certificates, so a verifier needs nothing else. Signed by the author. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Grant { /// The author's KeyId, which signs the grant. pub grantor: KeyId, /// The subject whose certified keys act for it: `agent:NAME@ACCOUNT`. pub subject: String, /// The issuer whose certificates count. pub issuer: KeyId, /// What they may do for the author: changes, seal. pub scope: Scope, /// Valid from and until, in wall milliseconds. pub not_before: u64, pub expires: u64, } impl Grant { pub fn to_value(&self) -> Value { Value::map([ (0, Value::Uint(1)), (1, Value::Bytes(self.grantor.0.to_vec())), (2, Value::Text(self.subject.clone())), (3, Value::Bytes(self.issuer.0.to_vec())), ( 4, Value::Array( self.scope .names() .into_iter() .map(|n| Value::Text(n.to_string())) .collect(), ), ), (5, Value::Uint(self.not_before)), (6, Value::Uint(self.expires)), ]) } pub fn encode(&self) -> Vec { cbor::encode(&self.to_value()) } pub fn hash(&self) -> ObjectRef { hash_object(ObjectType::Grant, &self.encode()) } pub fn decode(b: &[u8]) -> Result { let v = cbor::decode(b)?; let m = map(&v, "grant")?; check_version(m)?; let mut scope = Scope::NONE; for n in array(field(m, 4, "scope")?, "scope")? { let n = text(n, "scope")?; scope = scope | Scope::named(n) .ok_or_else(|| ObjectError::Schema(format!("grant: unknown scope {n:?}")))?; } Ok(Grant { grantor: oref(field(m, 1, "grantor")?, "grantor")?, subject: text(field(m, 2, "subject")?, "subject")?.to_owned(), issuer: oref(field(m, 3, "issuer")?, "issuer")?, scope, not_before: uint(field(m, 5, "not_before")?, "not_before")?, expires: uint(field(m, 6, "expires")?, "expires")?, }) } /// Whether it lets its subject do `scope` for the author at `at_ms` /// (the change's own time, as for a delegation). 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 } } /// A grant with its author's signature over its ObjectRef. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SignedGrant { pub grant: Grant, pub sig: Signature, } impl SignedGrant { /// Grant `grant`, signed by the author `grantor` holds the secret of. pub fn sign(grant: Grant, grantor: &Identity, sk: &SecretKey) -> SignedGrant { let sig = sk.sign(Domain::Grant, &grantor.hash(), &grant.hash()); SignedGrant { grant, sig } } /// Signed by `grantor`, which it names, and well formed. pub fn verify(&self, grantor: &Identity) -> Result<(), CertError> { let g = &self.grant; if grantor.hash() != g.grantor || self.sig.key != g.grantor { return Err(CertError::WrongIssuer(g.grantor.short(12))); } verify(grantor, &self.sig, Domain::Grant, &g.hash()) .map_err(|e| CertError::BadSignature(e.to_string()))?; if g.expires <= g.not_before { return Err(CertError::BadLifetime); } if g.scope.is_empty() { return Err(CertError::NoScope); } Ok(()) } /// `[grant bytes, signature]`. pub fn encode(&self) -> Vec { cbor::encode(&Value::Array(vec![ Value::Bytes(self.grant.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 grant")?; if a.len() != 2 { return Err(CertError::Schema( "signed grant: expected [grant, signature]".into(), )); } Ok(SignedGrant { grant: Grant::decode(bytes(&a[0], "grant")?)?, sig: Signature::from_value(&a[1])?, }) } } /// What a fresh key signs (a challenge, §14.3) to show its holder asks for /// the certificate: bound to the credential it is asked with, so the /// proof cannot be replayed with another. pub fn certify_challenge(credential: &str) -> Vec { let mut c = b"oxy1 certify ".to_vec(); c.extend_from_slice(blake3::hash(credential.as_bytes()).as_bytes()); c } #[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, grant: None, }; (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 a_grant_rides_in_its_certificate_and_verifies_with_its_author_only() { let hub_sk = SecretKey::generate(); let hub = hub_sk.identity("oxygenhub.io", "hub@oxygenhub.io", 1); let ada_sk = SecretKey::generate(); let ada = ada_sk.identity("Ada", "ada@example.org", 1); let g = SignedGrant::sign( Grant { grantor: ada.hash(), subject: "agent:claude@ada".into(), issuer: hub.hash(), scope: Scope::CHANGES | Scope::SEAL, not_before: 1_000, expires: 2_000, }, &ada, &ada_sk, ); assert!(g.verify(&ada).is_ok()); assert!(g.verify(&hub).is_err(), "only its author's"); assert!(g.grant.covers(Scope::SEAL, 1_500)); assert!(!g.grant.covers(Scope::REFS, 1_500)); assert!(!g.grant.covers(Scope::SEAL, 2_001)); let (_, plain) = issue(&hub_sk, &hub, 10); assert!( !matches!(cbor::decode(&plain.cert.encode()).unwrap(), Value::Map(m) if m.contains_key(&8)), "no grant, no field 8" ); let mut with = plain.cert.clone(); with.grant = Some(g.clone()); let sc = SignedCertificate::sign(with, &hub, &hub_sk); let back = SignedCertificate::decode(&sc.encode()).unwrap(); assert_eq!(back.cert.grant, Some(g)); assert!(back.verify(&hub).is_ok()); assert_ne!(back.cert.hash(), plain.cert.hash()); } #[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 the_proof_of_possession_is_bound_to_its_credential() { let sk = SecretKey::generate(); let id = sk.identity("job", "flow@oxygenhub.io", 1); let proof = sk .sign_challenge(&id.hash(), &certify_challenge("flowjob_aaa")) .unwrap(); assert!( crate::identity::verify_challenge(&id, &proof, &certify_challenge("flowjob_aaa")) .is_ok() ); assert!( crate::identity::verify_challenge(&id, &proof, &certify_challenge("flowjob_bbb")) .is_err() ); } #[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()); } }