|
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 { |
|
40 |
+ |
pub fn to_value(&self) -> Value { |
|
41 |
+ |
let mut claims = self.claims.clone(); |
|
42 |
+ |
claims.sort(); |
|
43 |
+ |
Value::map([ |
|
44 |
+ |
(0, Value::Uint(1)), |
|
45 |
+ |
(1, Value::Bytes(self.key.encode())), |
|
46 |
+ |
(2, Value::Bytes(self.issuer.0.to_vec())), |
|
47 |
+ |
(3, Value::Text(self.subject.clone())), |
|
48 |
+ |
( |
|
49 |
+ |
4, |
|
50 |
+ |
Value::Array( |
|
51 |
+ |
claims |
|
52 |
+ |
.into_iter() |
|
53 |
+ |
.map(|(k, v)| Value::Array(vec![Value::Text(k), Value::Text(v)])) |
|
54 |
+ |
.collect(), |
|
55 |
+ |
), |
|
56 |
+ |
), |
|
57 |
+ |
( |
|
58 |
+ |
5, |
|
59 |
+ |
Value::Array( |
|
60 |
+ |
self.scope |
|
61 |
+ |
.names() |
|
62 |
+ |
.into_iter() |
|
63 |
+ |
.map(|n| Value::Text(n.to_string())) |
|
64 |
+ |
.collect(), |
|
65 |
+ |
), |
|
66 |
+ |
), |
|
67 |
+ |
(6, Value::Uint(self.not_before)), |
|
68 |
+ |
(7, Value::Uint(self.expires)), |
|
69 |
+ |
]) |
|
70 |
+ |
} |
|
71 |
+ |
|
|
72 |
+ |
pub fn encode(&self) -> Vec<u8> { |
|
73 |
+ |
cbor::encode(&self.to_value()) |
|
74 |
+ |
} |
|
75 |
+ |
|
|
76 |
+ |
pub fn hash(&self) -> ObjectRef { |
|
77 |
+ |
hash_object(ObjectType::Certificate, &self.encode()) |
|
78 |
+ |
} |
|
79 |
+ |
|
|
80 |
+ |
/// The short-lived key's KeyId: what its signatures name. |
|
81 |
+ |
pub fn key_id(&self) -> KeyId { |
|
82 |
+ |
self.key.hash() |
|
83 |
+ |
} |
|
84 |
+ |
|
|
85 |
+ |
pub fn decode(b: &[u8]) -> Result<Certificate, ObjectError> { |
|
86 |
+ |
let v = cbor::decode(b)?; |
|
87 |
+ |
let m = map(&v, "certificate")?; |
|
88 |
+ |
check_version(m)?; |
|
89 |
+ |
let key = Identity::decode(bytes(field(m, 1, "key")?, "key")?)?; |
|
90 |
+ |
let mut claims = Vec::new(); |
|
91 |
+ |
for c in array(field(m, 4, "claims")?, "claims")? { |
|
92 |
+ |
let pair = array(c, "claim")?; |
|
93 |
+ |
if pair.len() != 2 { |
|
94 |
+ |
return Err(ObjectError::Schema("claim: expected [name, value]".into())); |
|
95 |
+ |
} |
|
96 |
+ |
claims.push(( |
|
97 |
+ |
text(&pair[0], "claim name")?.to_owned(), |
|
98 |
+ |
text(&pair[1], "claim value")?.to_owned(), |
|
99 |
+ |
)); |
|
100 |
+ |
} |
|
101 |
+ |
let mut scope = Scope::NONE; |
|
102 |
+ |
for n in array(field(m, 5, "scope")?, "scope")? { |
|
103 |
+ |
let n = text(n, "scope")?; |
|
104 |
+ |
scope = scope |
|
105 |
+ |
| Scope::named(n).ok_or_else(|| { |
|
106 |
+ |
ObjectError::Schema(format!("certificate: unknown scope {n:?}")) |
|
107 |
+ |
})?; |
|
108 |
+ |
} |
|
109 |
+ |
Ok(Certificate { |
|
110 |
+ |
key, |
|
111 |
+ |
issuer: oref(field(m, 2, "issuer")?, "issuer")?, |
|
112 |
+ |
subject: text(field(m, 3, "subject")?, "subject")?.to_owned(), |
|
113 |
+ |
claims, |
|
114 |
+ |
scope, |
|
115 |
+ |
not_before: uint(field(m, 6, "not_before")?, "not_before")?, |
|
116 |
+ |
expires: uint(field(m, 7, "expires")?, "expires")?, |
|
117 |
+ |
}) |
|
118 |
+ |
} |
|
119 |
+ |
|
|
120 |
+ |
/// Whether the key may sign within `scope` at `at_ms`, a time the |
|
121 |
+ |
/// signer did not choose. |
|
122 |
+ |
pub fn covers(&self, scope: Scope, at_ms: u64) -> bool { |
|
123 |
+ |
!scope.is_empty() |
|
124 |
+ |
&& self.scope.has(scope) |
|
125 |
+ |
&& self.not_before <= at_ms |
|
126 |
+ |
&& at_ms <= self.expires |
|
127 |
+ |
} |
|
128 |
+ |
|
|
129 |
+ |
/// The claim called `name`, if the certificate has one. |
|
130 |
+ |
pub fn claim(&self, name: &str) -> Option<&str> { |
|
131 |
+ |
self.claims |
|
132 |
+ |
.iter() |
|
133 |
+ |
.find(|(k, _)| k == name) |
|
134 |
+ |
.map(|(_, v)| v.as_str()) |
|
135 |
+ |
} |
|
136 |
+ |
} |
|
137 |
+ |
|
|
138 |
+ |
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] |
|
139 |
+ |
pub enum CertError { |
|
140 |
+ |
#[error("the certificate names issuer {0}, not this one")] |
|
141 |
+ |
WrongIssuer(String), |
|
142 |
+ |
#[error("the issuer's signature does not verify: {0}")] |
|
143 |
+ |
BadSignature(String), |
|
144 |
+ |
#[error("the certificate is valid for no time, or for longer than an hour")] |
|
145 |
+ |
BadLifetime, |
|
146 |
+ |
#[error("the certificate gives no scope")] |
|
147 |
+ |
NoScope, |
|
148 |
+ |
#[error("{0}")] |
|
149 |
+ |
Schema(String), |
|
150 |
+ |
} |
|
151 |
+ |
|
|
152 |
+ |
impl From<ObjectError> for CertError { |
|
153 |
+ |
fn from(e: ObjectError) -> Self { |
|
154 |
+ |
CertError::Schema(e.to_string()) |
|
155 |
+ |
} |
|
156 |
+ |
} |
|
157 |
+ |
|
|
158 |
+ |
/// A certificate with its issuer's signature over its ObjectRef. |
|
159 |
+ |
#[derive(Clone, Debug, PartialEq, Eq)] |
|
160 |
+ |
pub struct SignedCertificate { |
|
161 |
+ |
pub cert: Certificate, |
|
162 |
+ |
pub sig: Signature, |
|
163 |
+ |
} |
|
164 |
+ |
|
|
165 |
+ |
impl SignedCertificate { |
|
166 |
+ |
/// Issue `cert`, signed by the issuer `issuer` holds the secret of. |
|
167 |
+ |
pub fn sign(mut cert: Certificate, issuer: &Identity, sk: &SecretKey) -> SignedCertificate { |
|
168 |
+ |
// Claims in their canonical order, as encoded. |
|
169 |
+ |
cert.claims.sort(); |
|
170 |
+ |
let sig = sk.sign(Domain::Certificate, &issuer.hash(), &cert.hash()); |
|
171 |
+ |
SignedCertificate { cert, sig } |
|
172 |
+ |
} |
|
173 |
+ |
|
|
174 |
+ |
/// Signed by `issuer`, which it names, and well formed. Whether to |
|
175 |
+ |
/// trust the issuer for its subject is the policy's question. |
|
176 |
+ |
pub fn verify(&self, issuer: &Identity) -> Result<(), CertError> { |
|
177 |
+ |
let c = &self.cert; |
|
178 |
+ |
if issuer.hash() != c.issuer || self.sig.key != c.issuer { |
|
179 |
+ |
return Err(CertError::WrongIssuer(c.issuer.short(12))); |
|
180 |
+ |
} |
|
181 |
+ |
verify(issuer, &self.sig, Domain::Certificate, &c.hash()) |
|
182 |
+ |
.map_err(|e| CertError::BadSignature(e.to_string()))?; |
|
183 |
+ |
if c.expires <= c.not_before || c.expires - c.not_before > MAX_LIFETIME_MS { |
|
184 |
+ |
return Err(CertError::BadLifetime); |
|
185 |
+ |
} |
|
186 |
+ |
if c.scope.is_empty() { |
|
187 |
+ |
return Err(CertError::NoScope); |
|
188 |
+ |
} |
|
189 |
+ |
Ok(()) |
|
190 |
+ |
} |
|
191 |
+ |
|
|
192 |
+ |
/// `[certificate bytes, signature]`. |
|
193 |
+ |
pub fn encode(&self) -> Vec<u8> { |
|
194 |
+ |
cbor::encode(&Value::Array(vec![ |
|
195 |
+ |
Value::Bytes(self.cert.encode()), |
|
196 |
+ |
self.sig.to_value(), |
|
197 |
+ |
])) |
|
198 |
+ |
} |
|
199 |
+ |
|
|
200 |
+ |
pub fn decode(b: &[u8]) -> Result<SignedCertificate, CertError> { |
|
201 |
+ |
let v = cbor::decode(b).map_err(ObjectError::from)?; |
|
202 |
+ |
let a = array(&v, "signed certificate")?; |
|
203 |
+ |
if a.len() != 2 { |
|
204 |
+ |
return Err(CertError::Schema( |
|
205 |
+ |
"signed certificate: expected [certificate, signature]".into(), |
|
206 |
+ |
)); |
|
207 |
+ |
} |
|
208 |
+ |
Ok(SignedCertificate { |
|
209 |
+ |
cert: Certificate::decode(bytes(&a[0], "certificate")?)?, |
|
210 |
+ |
sig: Signature::from_value(&a[1])?, |
|
211 |
+ |
}) |
|
212 |
+ |
} |
|
213 |
+ |
} |
|
214 |
+ |
|
|
215 |
+ |
#[cfg(test)] |
|
216 |
+ |
mod tests { |
|
217 |
+ |
use super::*; |
|
218 |
+ |
|
|
219 |
+ |
fn issue( |
|
220 |
+ |
issuer_sk: &SecretKey, |
|
221 |
+ |
issuer: &Identity, |
|
222 |
+ |
minutes: u64, |
|
223 |
+ |
) -> (SecretKey, SignedCertificate) { |
|
224 |
+ |
let job_sk = SecretKey::generate(); |
|
225 |
+ |
let cert = Certificate { |
|
226 |
+ |
key: job_sk.identity("job 12 build", "[email protected]", 1_000), |
|
227 |
+ |
issuer: issuer.hash(), |
|
228 |
+ |
subject: "repo:andy/demo:bookmark:main".into(), |
|
229 |
+ |
claims: vec![ |
|
230 |
+ |
("run".into(), "12".into()), |
|
231 |
+ |
("environment".into(), "prod".into()), |
|
232 |
+ |
], |
|
233 |
+ |
scope: Scope::CHANGES | Scope::REFS, |
|
234 |
+ |
not_before: 1_000, |
|
235 |
+ |
expires: 1_000 + minutes * 60_000, |
|
236 |
+ |
}; |
|
237 |
+ |
(job_sk, SignedCertificate::sign(cert, issuer, issuer_sk)) |
|
238 |
+ |
} |
|
239 |
+ |
|
|
240 |
+ |
#[test] |
|
241 |
+ |
fn a_certificate_round_trips_and_verifies_with_its_issuer_only() { |
|
242 |
+ |
let hub_sk = SecretKey::generate(); |
|
243 |
+ |
let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
244 |
+ |
let (_, sc) = issue(&hub_sk, &hub, 10); |
|
245 |
+ |
let back = SignedCertificate::decode(&sc.encode()).unwrap(); |
|
246 |
+ |
assert_eq!(back, sc); |
|
247 |
+ |
assert_eq!(back.cert.claims[0].0, "environment", "claims kept sorted"); |
|
248 |
+ |
assert_eq!(back.cert.claim("run"), Some("12")); |
|
249 |
+ |
assert!(back.verify(&hub).is_ok()); |
|
250 |
+ |
let other_sk = SecretKey::generate(); |
|
251 |
+ |
let other = other_sk.identity("elsewhere", "[email protected]", 1); |
|
252 |
+ |
assert!(matches!( |
|
253 |
+ |
back.verify(&other), |
|
254 |
+ |
Err(CertError::WrongIssuer(_)) |
|
255 |
+ |
)); |
|
256 |
+ |
// Changed after signing: the signature no longer covers it. |
|
257 |
+ |
let mut forged = back.clone(); |
|
258 |
+ |
forged.cert.subject = "repo:andy/demo:bookmark:release".into(); |
|
259 |
+ |
assert!(matches!( |
|
260 |
+ |
forged.verify(&hub), |
|
261 |
+ |
Err(CertError::BadSignature(_)) |
|
262 |
+ |
)); |
|
263 |
+ |
// Signed by another key in the issuer's name. |
|
264 |
+ |
let mut stolen = back.clone(); |
|
265 |
+ |
stolen.sig = other_sk.sign(Domain::Certificate, &hub.hash(), &back.cert.hash()); |
|
266 |
+ |
assert!(matches!( |
|
267 |
+ |
stolen.verify(&hub), |
|
268 |
+ |
Err(CertError::BadSignature(_)) |
|
269 |
+ |
)); |
|
270 |
+ |
} |
|
271 |
+ |
|
|
272 |
+ |
#[test] |
|
273 |
+ |
fn it_covers_its_scope_and_window_only() { |
|
274 |
+ |
let hub_sk = SecretKey::generate(); |
|
275 |
+ |
let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
276 |
+ |
let (_, sc) = issue(&hub_sk, &hub, 10); |
|
277 |
+ |
let c = &sc.cert; |
|
278 |
+ |
assert!(c.covers(Scope::CHANGES, 1_000) && c.covers(Scope::REFS, 1_000 + 10 * 60_000)); |
|
279 |
+ |
assert!(!c.covers(Scope::SEAL, 2_000), "not a scope it gives"); |
|
280 |
+ |
assert!(!c.covers(Scope::CHANGES, 999), "before it"); |
|
281 |
+ |
assert!( |
|
282 |
+ |
!c.covers(Scope::CHANGES, 1_000 + 10 * 60_000 + 1), |
|
283 |
+ |
"after it" |
|
284 |
+ |
); |
|
285 |
+ |
assert!(!c.covers(Scope::NONE, 2_000)); |
|
286 |
+ |
let (_, long) = issue(&hub_sk, &hub, 61); |
|
287 |
+ |
assert_eq!(long.verify(&hub), Err(CertError::BadLifetime)); |
|
288 |
+ |
} |
|
289 |
+ |
|
|
290 |
+ |
#[test] |
|
291 |
+ |
fn a_signature_by_the_short_lived_key_names_it() { |
|
292 |
+ |
let hub_sk = SecretKey::generate(); |
|
293 |
+ |
let hub = hub_sk.identity("oxygenhub.io", "[email protected]", 1); |
|
294 |
+ |
let (job_sk, sc) = issue(&hub_sk, &hub, 10); |
|
295 |
+ |
let target = ObjectRef([5; 32]); |
|
296 |
+ |
let sig = job_sk.sign(Domain::Change, &sc.cert.key_id(), &target); |
|
297 |
+ |
assert_eq!(sig.key, sc.cert.key_id()); |
|
298 |
+ |
assert!(verify(&sc.cert.key, &sig, Domain::Change, &target).is_ok()); |
|
299 |
+ |
} |
|
300 |
+ |
} |