]>
Commit | Line | Data |
---|---|---|
add5861e | 1 | //! Generate and verify Authentication tickets |
8d04280b | 2 | |
593f9177 WB |
3 | use std::borrow::Cow; |
4 | use std::io; | |
5 | use std::marker::PhantomData; | |
6 | ||
7 | use anyhow::{bail, format_err, Error}; | |
8d04280b DM |
8 | use base64; |
9 | ||
593f9177 | 10 | use openssl::pkey::{PKey, Public, Private, HasPublic}; |
8d04280b DM |
11 | use openssl::sign::{Signer, Verifier}; |
12 | use openssl::hash::MessageDigest; | |
593f9177 | 13 | use percent_encoding::{AsciiSet, percent_decode_str, percent_encode}; |
8d04280b | 14 | |
e7cb4dc5 | 15 | use crate::api2::types::Userid; |
e693818a DC |
16 | use crate::tools::epoch_now_u64; |
17 | ||
e5662b04 DM |
18 | pub const TICKET_LIFETIME: i64 = 3600*2; // 2 hours |
19 | ||
593f9177 WB |
20 | pub const TERM_PREFIX: &str = "PBSTERM"; |
21 | ||
22 | /// Stringified ticket data must not contain colons... | |
23 | const TICKET_ASCIISET: &AsciiSet = &percent_encoding::CONTROLS.add(b':'); | |
24 | ||
25 | /// An empty type implementing [`ToString`] and [`FromStr`](std::str::FromStr), used for tickets | |
26 | /// with no data. | |
27 | pub struct Empty; | |
28 | ||
29 | impl ToString for Empty { | |
30 | fn to_string(&self) -> String { | |
31 | String::new() | |
32 | } | |
33 | } | |
34 | ||
35 | impl std::str::FromStr for Empty { | |
36 | type Err = Error; | |
37 | ||
38 | fn from_str(s: &str) -> Result<Self, Error> { | |
39 | if !s.is_empty() { | |
40 | bail!("unexpected ticket data, should be empty"); | |
41 | } | |
42 | Ok(Empty) | |
43 | } | |
44 | } | |
45 | ||
46 | /// An API ticket consists of a ticket type (prefix), type-dependent data, optional additional | |
47 | /// authenticaztion data, a timestamp and a signature. We store these values in the form | |
48 | /// `<prefix>:<stringified data>:<timestamp>::<signature>`. | |
49 | /// | |
50 | /// The signature is made over the string consisting of prefix, data, timestamp and aad joined | |
51 | /// together by colons. If there is no additional authentication data it will be skipped together | |
52 | /// with the colon separating it from the timestamp. | |
53 | pub struct Ticket<T> | |
54 | where | |
55 | T: ToString + std::str::FromStr, | |
56 | { | |
57 | prefix: Cow<'static, str>, | |
58 | data: String, | |
59 | time: i64, | |
60 | signature: Option<Vec<u8>>, | |
61 | _type_marker: PhantomData<T>, | |
62 | } | |
63 | ||
64 | impl<T> Ticket<T> | |
65 | where | |
66 | T: ToString + std::str::FromStr, | |
67 | <T as std::str::FromStr>::Err: std::fmt::Debug, | |
68 | { | |
69 | /// Prepare a new ticket for signing. | |
70 | pub fn new(prefix: &'static str, data: &T) -> Result<Self, Error> { | |
71 | Ok(Self { | |
72 | prefix: Cow::Borrowed(prefix), | |
73 | data: data.to_string(), | |
74 | time: epoch_now_u64()? as i64, | |
75 | signature: None, | |
76 | _type_marker: PhantomData, | |
77 | }) | |
78 | } | |
79 | ||
80 | /// Get the ticket prefix. | |
81 | pub fn prefix(&self) -> &str { | |
82 | &self.prefix | |
83 | } | |
84 | ||
85 | /// Get the ticket's time stamp in seconds since the unix epoch. | |
86 | pub fn time(&self) -> i64 { | |
87 | self.time | |
88 | } | |
89 | ||
90 | /// Get the raw string data contained in the ticket. The `verify` method will call `parse()` | |
91 | /// this in the end, so using this method directly is discouraged as it does not verify the | |
92 | /// signature. | |
93 | pub fn raw_data(&self) -> &str { | |
94 | &self.data | |
95 | } | |
96 | ||
97 | /// Serialize the ticket into a writer. | |
98 | /// | |
99 | /// This only writes a string. We use `io::write` instead of `fmt::Write` so we can reuse the | |
100 | /// same function for openssl's `Verify`, which only implements `io::Write`. | |
101 | fn write_data(&self, f: &mut dyn io::Write) -> Result<(), Error> { | |
102 | write!( | |
103 | f, | |
104 | "{}:{}:{:08X}", | |
105 | percent_encode(self.prefix.as_bytes(), &TICKET_ASCIISET), | |
106 | percent_encode(self.data.as_bytes(), &TICKET_ASCIISET), | |
107 | self.time, | |
108 | ) | |
109 | .map_err(Error::from) | |
110 | } | |
111 | ||
112 | /// Write additional authentication data to the verifier. | |
113 | fn write_aad(f: &mut dyn io::Write, aad: Option<&str>) -> Result<(), Error> { | |
114 | if let Some(aad) = aad { | |
115 | write!(f, ":{}", percent_encode(aad.as_bytes(), &TICKET_ASCIISET))?; | |
116 | } | |
117 | Ok(()) | |
118 | } | |
119 | ||
120 | /// Change the ticket's time, used mostly for testing. | |
121 | #[cfg(test)] | |
122 | fn change_time(&mut self, time: i64) -> &mut Self { | |
123 | self.time = time; | |
124 | self | |
125 | } | |
126 | ||
127 | /// Sign the ticket. | |
128 | pub fn sign(&mut self, keypair: &PKey<Private>, aad: Option<&str>) -> Result<String, Error> { | |
129 | let mut output = Vec::<u8>::new(); | |
130 | let mut signer = Signer::new(MessageDigest::sha256(), &keypair) | |
131 | .map_err(|err| format_err!("openssl error creating signer for ticket: {}", err))?; | |
132 | ||
133 | self.write_data(&mut output) | |
134 | .map_err(|err| format_err!("error creating ticket: {}", err))?; | |
135 | ||
136 | signer | |
137 | .update(&output) | |
138 | .map_err(Error::from) | |
139 | .and_then(|()| Self::write_aad(&mut signer, aad)) | |
140 | .map_err(|err| format_err!("error signing ticket: {}", err))?; | |
141 | ||
142 | // See `Self::write_data` for why this is safe | |
143 | let mut output = unsafe { String::from_utf8_unchecked(output) }; | |
144 | ||
145 | let signature = signer | |
146 | .sign_to_vec() | |
147 | .map_err(|err| format_err!("error finishing ticket signature: {}", err))?; | |
148 | ||
149 | use std::fmt::Write; | |
150 | write!( | |
151 | &mut output, | |
152 | "::{}", | |
153 | base64::encode_config(&signature, base64::STANDARD_NO_PAD), | |
154 | )?; | |
155 | ||
156 | self.signature = Some(signature); | |
157 | ||
158 | Ok(output) | |
159 | } | |
160 | ||
161 | /// `verify` with an additional time frame parameter, not usually required since we always use | |
162 | /// the same time frame. | |
163 | pub fn verify_with_time_frame<P: HasPublic>( | |
164 | &self, | |
165 | keypair: &PKey<P>, | |
166 | prefix: &str, | |
167 | aad: Option<&str>, | |
168 | time_frame: std::ops::Range<i64>, | |
169 | ) -> Result<T, Error> { | |
170 | if self.prefix != prefix { | |
171 | bail!("ticket with invalid prefix"); | |
172 | } | |
173 | ||
174 | let signature = match self.signature.as_ref() { | |
175 | Some(sig) => sig, | |
176 | None => bail!("invalid ticket without signature"), | |
177 | }; | |
178 | ||
179 | let age = epoch_now_u64()? as i64 - self.time; | |
180 | if age < time_frame.start { | |
181 | bail!("invalid ticket - timestamp newer than expected"); | |
182 | } | |
183 | if age > time_frame.end { | |
184 | bail!("invalid ticket - expired"); | |
185 | } | |
186 | ||
187 | let mut verifier = Verifier::new(MessageDigest::sha256(), &keypair)?; | |
188 | ||
189 | self.write_data(&mut verifier) | |
190 | .and_then(|()| Self::write_aad(&mut verifier, aad)) | |
191 | .map_err(|err| format_err!("error verifying ticket: {}", err))?; | |
192 | ||
193 | let is_valid: bool = verifier | |
194 | .verify(&signature) | |
195 | .map_err(|err| format_err!("openssl error verifying ticket: {}", err))?; | |
196 | ||
197 | if !is_valid { | |
198 | bail!("ticket with invalid signature"); | |
199 | } | |
200 | ||
201 | self.data | |
202 | .parse() | |
203 | .map_err(|err| format_err!("failed to parse contained ticket data: {:?}", err)) | |
204 | } | |
205 | ||
206 | /// Verify the ticket with the provided key pair. The additional authentication data needs to | |
207 | /// match the one used when generating the ticket, and the ticket's age must fall into the time | |
208 | /// frame. | |
209 | pub fn verify<P: HasPublic>( | |
210 | &self, | |
211 | keypair: &PKey<P>, | |
212 | prefix: &str, | |
213 | aad: Option<&str>, | |
214 | ) -> Result<T, Error> { | |
215 | self.verify_with_time_frame(keypair, prefix, aad, -300..TICKET_LIFETIME) | |
216 | } | |
217 | ||
218 | /// Parse a ticket string. | |
219 | pub fn parse(ticket: &str) -> Result<Self, Error> { | |
220 | let mut parts = ticket.splitn(4, ':'); | |
221 | ||
222 | let prefix = percent_decode_str( | |
223 | parts.next().ok_or_else(|| format_err!("ticket without prefix"))?, | |
224 | ) | |
225 | .decode_utf8() | |
226 | .map_err(|err| format_err!("invalid ticket, error decoding prefix: {}", err))?; | |
227 | ||
228 | let data = percent_decode_str( | |
229 | parts.next().ok_or_else(|| format_err!("ticket without data"))?, | |
230 | ) | |
231 | .decode_utf8() | |
232 | .map_err(|err| format_err!("invalid ticket, error decoding data: {}", err))?; | |
233 | ||
234 | let time = i64::from_str_radix( | |
235 | parts | |
236 | .next() | |
237 | .ok_or_else(|| format_err!("ticket without timestamp"))?, | |
238 | 16, | |
239 | ) | |
240 | .map_err(|err| format_err!("ticket with bad timestamp: {}", err))?; | |
241 | ||
242 | let remainder = parts.next().ok_or_else(|| format_err!("ticket without signature"))?; | |
243 | // <prefix>:<data>:<time>::signature - the 4th `.next()` swallows the first colon in the | |
244 | // double-colon! | |
245 | if !remainder.starts_with(':') { | |
246 | bail!("ticket without signature separator"); | |
247 | } | |
248 | let signature = base64::decode_config(&remainder[1..], base64::STANDARD_NO_PAD) | |
249 | .map_err(|err| format_err!("ticket with bad signature: {}", err))?; | |
250 | ||
251 | Ok(Self { | |
252 | prefix: Cow::Owned(prefix.into_owned()), | |
253 | data: data.into_owned(), | |
254 | time, | |
255 | signature: Some(signature), | |
256 | _type_marker: PhantomData, | |
257 | }) | |
258 | } | |
259 | } | |
260 | ||
261 | pub fn term_aad(userid: &Userid, path: &str, port: u16) -> String { | |
262 | format!("{}{}{}", userid, path, port) | |
263 | } | |
264 | ||
265 | #[cfg(test)] | |
266 | mod test { | |
267 | use openssl::pkey::{PKey, Private}; | |
268 | ||
269 | use super::{Ticket, TICKET_LIFETIME}; | |
270 | use crate::api2::types::Userid; | |
271 | use crate::tools::epoch_now_u64; | |
272 | ||
273 | fn simple_test<F>(key: &PKey<Private>, aad: Option<&str>, modify: F) | |
274 | where | |
275 | F: FnOnce(&mut Ticket<Userid>) -> bool, | |
276 | { | |
277 | let userid = Userid::root_userid(); | |
278 | ||
279 | let mut ticket = Ticket::new("PREFIX", userid).expect("failed to create Ticket struct"); | |
280 | let should_work = modify(&mut ticket); | |
281 | let ticket = ticket.sign(key, aad).expect("failed to sign test ticket"); | |
282 | ||
283 | let parsed = Ticket::<Userid>::parse(&ticket) | |
284 | .expect("failed to parse generated test ticket"); | |
285 | if should_work { | |
286 | let check: Userid = parsed | |
287 | .verify(key, "PREFIX", aad) | |
288 | .expect("failed to verify test ticket"); | |
289 | ||
290 | assert_eq!(*userid, check); | |
291 | ||
292 | // Compat check: | |
293 | let (_age, uid) = | |
294 | super::verify_rsa_ticket(key, "PREFIX", &ticket, aad, -300, TICKET_LIFETIME) | |
295 | .expect("failed compatibility verification"); | |
296 | let uid = uid.expect("compat did not return a userid"); | |
297 | assert_eq!(*userid, uid); | |
298 | } else { | |
299 | parsed | |
300 | .verify(key, "PREFIX", aad) | |
301 | .expect_err("failed to verify test ticket"); | |
302 | } | |
303 | } | |
304 | ||
305 | #[test] | |
306 | fn test_tickets() { | |
307 | // first we need keys, for testing we use small keys for speed... | |
308 | let rsa = openssl::rsa::Rsa::generate(1024) | |
309 | .expect("failed to generate RSA key for testing"); | |
310 | let key = openssl::pkey::PKey::<openssl::pkey::Private>::from_rsa(rsa) | |
311 | .expect("failed to create PKey for RSA key"); | |
312 | ||
313 | simple_test(&key, Some("secret aad data"), |_| true); | |
314 | simple_test(&key, None, |_| true); | |
315 | simple_test(&key, None, |t| { | |
316 | t.change_time(0); | |
317 | false | |
318 | }); | |
319 | simple_test(&key, None, |t| { | |
320 | t.change_time(epoch_now_u64().unwrap() as i64 + 0x1000_0000); | |
321 | false | |
322 | }); | |
323 | ||
324 | // compat check: | |
325 | let ticket = | |
326 | super::assemble_rsa_ticket(&key, "PREFIX", Some(Userid::root_userid()), Some("stuff")) | |
327 | .expect("failed to assemble compatibility ticket"); | |
328 | let parsed_uid: Userid = Ticket::parse(&ticket) | |
329 | .expect("failed to parse compatibility ticket") | |
330 | .verify(&key, Some("stuff"), -300..TICKET_LIFETIME) | |
331 | .expect("failed to verify compatibility ticket"); | |
332 | assert_eq!(parsed_uid, *Userid::root_userid()); | |
333 | } | |
334 | } | |
a4d16755 DC |
335 | |
336 | pub fn assemble_term_ticket( | |
337 | keypair: &PKey<Private>, | |
e7cb4dc5 | 338 | userid: &Userid, |
a4d16755 DC |
339 | path: &str, |
340 | port: u16, | |
341 | ) -> Result<String, Error> { | |
342 | assemble_rsa_ticket( | |
343 | keypair, | |
344 | TERM_PREFIX, | |
345 | None, | |
e7cb4dc5 | 346 | Some(&format!("{}{}{}", userid, path, port)), |
a4d16755 DC |
347 | ) |
348 | } | |
349 | ||
350 | pub fn verify_term_ticket( | |
351 | keypair: &PKey<Public>, | |
e7cb4dc5 | 352 | userid: &Userid, |
a4d16755 DC |
353 | path: &str, |
354 | port: u16, | |
355 | ticket: &str, | |
e7cb4dc5 | 356 | ) -> Result<(i64, Option<Userid>), Error> { |
a4d16755 DC |
357 | verify_rsa_ticket( |
358 | keypair, | |
359 | TERM_PREFIX, | |
360 | ticket, | |
e7cb4dc5 | 361 | Some(&format!("{}{}{}", userid, path, port)), |
a4d16755 DC |
362 | -300, |
363 | TICKET_LIFETIME, | |
364 | ) | |
365 | } | |
e5662b04 | 366 | |
8d04280b DM |
367 | pub fn assemble_rsa_ticket( |
368 | keypair: &PKey<Private>, | |
369 | prefix: &str, | |
e7cb4dc5 | 370 | data: Option<&Userid>, |
8d04280b DM |
371 | secret_data: Option<&str>, |
372 | ) -> Result<String, Error> { | |
373 | ||
e693818a | 374 | let epoch = epoch_now_u64()?; |
8d04280b DM |
375 | |
376 | let timestamp = format!("{:08X}", epoch); | |
377 | ||
378 | let mut plain = prefix.to_owned(); | |
379 | plain.push(':'); | |
380 | ||
381 | if let Some(data) = data { | |
e7cb4dc5 WB |
382 | use std::fmt::Write; |
383 | write!(plain, "{}", data)?; | |
8d04280b DM |
384 | plain.push(':'); |
385 | } | |
386 | ||
387 | plain.push_str(×tamp); | |
388 | ||
389 | let mut full = plain.clone(); | |
390 | if let Some(secret) = secret_data { | |
391 | full.push(':'); | |
392 | full.push_str(secret); | |
393 | } | |
394 | ||
395 | let mut signer = Signer::new(MessageDigest::sha256(), &keypair)?; | |
396 | signer.update(full.as_bytes())?; | |
397 | let sign = signer.sign_to_vec()?; | |
398 | ||
399 | let sign_b64 = base64::encode_config(&sign, base64::STANDARD_NO_PAD); | |
400 | ||
401 | Ok(format!("{}::{}", plain, sign_b64)) | |
402 | } | |
403 | ||
593f9177 WB |
404 | pub fn verify_rsa_ticket<P: HasPublic>( |
405 | keypair: &PKey<P>, | |
8d04280b DM |
406 | prefix: &str, |
407 | ticket: &str, | |
408 | secret_data: Option<&str>, | |
409 | min_age: i64, | |
410 | max_age: i64, | |
e7cb4dc5 | 411 | ) -> Result<(i64, Option<Userid>), Error> { |
8d04280b DM |
412 | |
413 | use std::collections::VecDeque; | |
414 | ||
415 | let mut parts: VecDeque<&str> = ticket.split(':').collect(); | |
416 | ||
417 | match parts.pop_front() { | |
418 | Some(text) => if text != prefix { bail!("ticket with invalid prefix"); } | |
419 | None => bail!("ticket without prefix"), | |
420 | } | |
421 | ||
422 | let sign_b64 = match parts.pop_back() { | |
423 | Some(v) => v, | |
424 | None => bail!("ticket without signature"), | |
425 | }; | |
426 | ||
427 | match parts.pop_back() { | |
428 | Some(text) => if text != "" { bail!("ticket with invalid signature separator"); } | |
429 | None => bail!("ticket without signature separator"), | |
430 | } | |
431 | ||
432 | let mut data = None; | |
433 | ||
434 | let mut full = match parts.len() { | |
435 | 2 => { | |
436 | data = Some(parts[0].to_owned()); | |
437 | format!("{}:{}:{}", prefix, parts[0], parts[1]) | |
438 | } | |
439 | 1 => format!("{}:{}", prefix, parts[0]), | |
440 | _ => bail!("ticket with invalid number of components"), | |
441 | }; | |
442 | ||
443 | if let Some(secret) = secret_data { | |
444 | full.push(':'); | |
445 | full.push_str(secret); | |
446 | } | |
447 | ||
448 | let sign = base64::decode_config(sign_b64, base64::STANDARD_NO_PAD)?; | |
449 | ||
450 | let mut verifier = Verifier::new(MessageDigest::sha256(), &keypair)?; | |
451 | verifier.update(full.as_bytes())?; | |
452 | ||
453 | if !verifier.verify(&sign)? { | |
454 | bail!("ticket with invalid signature"); | |
455 | } | |
456 | ||
457 | let timestamp = i64::from_str_radix(parts.pop_back().unwrap(), 16)?; | |
e693818a | 458 | let now = epoch_now_u64()? as i64; |
8d04280b DM |
459 | |
460 | let age = now - timestamp; | |
461 | if age < min_age { | |
462 | bail!("invalid ticket - timestamp newer than expected."); | |
463 | } | |
464 | ||
465 | if age > max_age { | |
466 | bail!("invalid ticket - timestamp too old."); | |
467 | } | |
468 | ||
e7cb4dc5 | 469 | Ok((age, data.map(|s| s.parse()).transpose()?)) |
8d04280b | 470 | } |