From 593f917742cafaa3e51526a35fed704e082d5931 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Wed, 12 Aug 2020 10:44:54 +0200 Subject: [PATCH] introduce Ticket struct and add tests and compatibility tests Signed-off-by: Wolfgang Bumiller --- src/tools/ticket.rs | 329 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 324 insertions(+), 5 deletions(-) diff --git a/src/tools/ticket.rs b/src/tools/ticket.rs index 82fa1101..e6c6442e 100644 --- a/src/tools/ticket.rs +++ b/src/tools/ticket.rs @@ -1,18 +1,337 @@ //! Generate and verify Authentication tickets -use anyhow::{bail, Error}; +use std::borrow::Cow; +use std::io; +use std::marker::PhantomData; + +use anyhow::{bail, format_err, Error}; use base64; -use openssl::pkey::{PKey, Public, Private}; +use openssl::pkey::{PKey, Public, Private, HasPublic}; use openssl::sign::{Signer, Verifier}; use openssl::hash::MessageDigest; +use percent_encoding::{AsciiSet, percent_decode_str, percent_encode}; use crate::api2::types::Userid; use crate::tools::epoch_now_u64; pub const TICKET_LIFETIME: i64 = 3600*2; // 2 hours -const TERM_PREFIX: &str = "PBSTERM"; +pub const TERM_PREFIX: &str = "PBSTERM"; + +/// Stringified ticket data must not contain colons... +const TICKET_ASCIISET: &AsciiSet = &percent_encoding::CONTROLS.add(b':'); + +/// An empty type implementing [`ToString`] and [`FromStr`](std::str::FromStr), used for tickets +/// with no data. +pub struct Empty; + +impl ToString for Empty { + fn to_string(&self) -> String { + String::new() + } +} + +impl std::str::FromStr for Empty { + type Err = Error; + + fn from_str(s: &str) -> Result { + if !s.is_empty() { + bail!("unexpected ticket data, should be empty"); + } + Ok(Empty) + } +} + +/// An API ticket consists of a ticket type (prefix), type-dependent data, optional additional +/// authenticaztion data, a timestamp and a signature. We store these values in the form +/// `::::`. +/// +/// The signature is made over the string consisting of prefix, data, timestamp and aad joined +/// together by colons. If there is no additional authentication data it will be skipped together +/// with the colon separating it from the timestamp. +pub struct Ticket +where + T: ToString + std::str::FromStr, +{ + prefix: Cow<'static, str>, + data: String, + time: i64, + signature: Option>, + _type_marker: PhantomData, +} + +impl Ticket +where + T: ToString + std::str::FromStr, + ::Err: std::fmt::Debug, +{ + /// Prepare a new ticket for signing. + pub fn new(prefix: &'static str, data: &T) -> Result { + Ok(Self { + prefix: Cow::Borrowed(prefix), + data: data.to_string(), + time: epoch_now_u64()? as i64, + signature: None, + _type_marker: PhantomData, + }) + } + + /// Get the ticket prefix. + pub fn prefix(&self) -> &str { + &self.prefix + } + + /// Get the ticket's time stamp in seconds since the unix epoch. + pub fn time(&self) -> i64 { + self.time + } + + /// Get the raw string data contained in the ticket. The `verify` method will call `parse()` + /// this in the end, so using this method directly is discouraged as it does not verify the + /// signature. + pub fn raw_data(&self) -> &str { + &self.data + } + + /// Serialize the ticket into a writer. + /// + /// This only writes a string. We use `io::write` instead of `fmt::Write` so we can reuse the + /// same function for openssl's `Verify`, which only implements `io::Write`. + fn write_data(&self, f: &mut dyn io::Write) -> Result<(), Error> { + write!( + f, + "{}:{}:{:08X}", + percent_encode(self.prefix.as_bytes(), &TICKET_ASCIISET), + percent_encode(self.data.as_bytes(), &TICKET_ASCIISET), + self.time, + ) + .map_err(Error::from) + } + + /// Write additional authentication data to the verifier. + fn write_aad(f: &mut dyn io::Write, aad: Option<&str>) -> Result<(), Error> { + if let Some(aad) = aad { + write!(f, ":{}", percent_encode(aad.as_bytes(), &TICKET_ASCIISET))?; + } + Ok(()) + } + + /// Change the ticket's time, used mostly for testing. + #[cfg(test)] + fn change_time(&mut self, time: i64) -> &mut Self { + self.time = time; + self + } + + /// Sign the ticket. + pub fn sign(&mut self, keypair: &PKey, aad: Option<&str>) -> Result { + let mut output = Vec::::new(); + let mut signer = Signer::new(MessageDigest::sha256(), &keypair) + .map_err(|err| format_err!("openssl error creating signer for ticket: {}", err))?; + + self.write_data(&mut output) + .map_err(|err| format_err!("error creating ticket: {}", err))?; + + signer + .update(&output) + .map_err(Error::from) + .and_then(|()| Self::write_aad(&mut signer, aad)) + .map_err(|err| format_err!("error signing ticket: {}", err))?; + + // See `Self::write_data` for why this is safe + let mut output = unsafe { String::from_utf8_unchecked(output) }; + + let signature = signer + .sign_to_vec() + .map_err(|err| format_err!("error finishing ticket signature: {}", err))?; + + use std::fmt::Write; + write!( + &mut output, + "::{}", + base64::encode_config(&signature, base64::STANDARD_NO_PAD), + )?; + + self.signature = Some(signature); + + Ok(output) + } + + /// `verify` with an additional time frame parameter, not usually required since we always use + /// the same time frame. + pub fn verify_with_time_frame( + &self, + keypair: &PKey

, + prefix: &str, + aad: Option<&str>, + time_frame: std::ops::Range, + ) -> Result { + if self.prefix != prefix { + bail!("ticket with invalid prefix"); + } + + let signature = match self.signature.as_ref() { + Some(sig) => sig, + None => bail!("invalid ticket without signature"), + }; + + let age = epoch_now_u64()? as i64 - self.time; + if age < time_frame.start { + bail!("invalid ticket - timestamp newer than expected"); + } + if age > time_frame.end { + bail!("invalid ticket - expired"); + } + + let mut verifier = Verifier::new(MessageDigest::sha256(), &keypair)?; + + self.write_data(&mut verifier) + .and_then(|()| Self::write_aad(&mut verifier, aad)) + .map_err(|err| format_err!("error verifying ticket: {}", err))?; + + let is_valid: bool = verifier + .verify(&signature) + .map_err(|err| format_err!("openssl error verifying ticket: {}", err))?; + + if !is_valid { + bail!("ticket with invalid signature"); + } + + self.data + .parse() + .map_err(|err| format_err!("failed to parse contained ticket data: {:?}", err)) + } + + /// Verify the ticket with the provided key pair. The additional authentication data needs to + /// match the one used when generating the ticket, and the ticket's age must fall into the time + /// frame. + pub fn verify( + &self, + keypair: &PKey

, + prefix: &str, + aad: Option<&str>, + ) -> Result { + self.verify_with_time_frame(keypair, prefix, aad, -300..TICKET_LIFETIME) + } + + /// Parse a ticket string. + pub fn parse(ticket: &str) -> Result { + let mut parts = ticket.splitn(4, ':'); + + let prefix = percent_decode_str( + parts.next().ok_or_else(|| format_err!("ticket without prefix"))?, + ) + .decode_utf8() + .map_err(|err| format_err!("invalid ticket, error decoding prefix: {}", err))?; + + let data = percent_decode_str( + parts.next().ok_or_else(|| format_err!("ticket without data"))?, + ) + .decode_utf8() + .map_err(|err| format_err!("invalid ticket, error decoding data: {}", err))?; + + let time = i64::from_str_radix( + parts + .next() + .ok_or_else(|| format_err!("ticket without timestamp"))?, + 16, + ) + .map_err(|err| format_err!("ticket with bad timestamp: {}", err))?; + + let remainder = parts.next().ok_or_else(|| format_err!("ticket without signature"))?; + // ::

, prefix: &str, ticket: &str, secret_data: Option<&str>,