diff --git a/pbs-tools/Cargo.toml b/pbs-tools/Cargo.toml index 17ef9112..a3aa81c1 100644 --- a/pbs-tools/Cargo.toml +++ b/pbs-tools/Cargo.toml @@ -8,12 +8,15 @@ description = "common tools used throughout pbs" # This must not depend on any subcrates more closely related to pbs itself. [dependencies] anyhow = "1.0" +base64 = "0.12" libc = "0.2" nix = "0.19.1" nom = "5.1" openssl = "0.10" +percent-encoding = "2.1" regex = "1.2" serde = "1.0" serde_json = "1.0" +url = "2.1" proxmox = { version = "0.11.5", default-features = false, features = [] } diff --git a/pbs-tools/src/json.rs b/pbs-tools/src/json.rs index 7a43c700..6d7d923b 100644 --- a/pbs-tools/src/json.rs +++ b/pbs-tools/src/json.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Error}; +use anyhow::{bail, format_err, Error}; use serde_json::Value; // Generate canonical json @@ -47,3 +47,46 @@ pub fn write_canonical_json(value: &Value, output: &mut Vec) -> Result<(), E } Ok(()) } + +pub fn json_object_to_query(data: Value) -> Result { + let mut query = url::form_urlencoded::Serializer::new(String::new()); + + let object = data.as_object().ok_or_else(|| { + format_err!("json_object_to_query: got wrong data type (expected object).") + })?; + + for (key, value) in object { + match value { + Value::Bool(b) => { + query.append_pair(key, &b.to_string()); + } + Value::Number(n) => { + query.append_pair(key, &n.to_string()); + } + Value::String(s) => { + query.append_pair(key, &s); + } + Value::Array(arr) => { + for element in arr { + match element { + Value::Bool(b) => { + query.append_pair(key, &b.to_string()); + } + Value::Number(n) => { + query.append_pair(key, &n.to_string()); + } + Value::String(s) => { + query.append_pair(key, &s); + } + _ => bail!( + "json_object_to_query: unable to handle complex array data types." + ), + } + } + } + _ => bail!("json_object_to_query: unable to handle complex data types."), + } + } + + Ok(query.finish()) +} diff --git a/pbs-tools/src/lib.rs b/pbs-tools/src/lib.rs index 533ec1f1..72b0e9fd 100644 --- a/pbs-tools/src/lib.rs +++ b/pbs-tools/src/lib.rs @@ -4,8 +4,9 @@ pub mod fs; pub mod json; pub mod nom; pub mod process_locker; -pub mod str; pub mod sha; +pub mod str; +pub mod ticket; mod command; pub use command::{command_output, command_output_as_string, run_command}; diff --git a/pbs-tools/src/ticket.rs b/pbs-tools/src/ticket.rs new file mode 100644 index 00000000..b4e41619 --- /dev/null +++ b/pbs-tools/src/ticket.rs @@ -0,0 +1,332 @@ +//! Generate and verify Authentication tickets + +use std::borrow::Cow; +use std::io; +use std::marker::PhantomData; + +use anyhow::{bail, format_err, Error}; +use openssl::hash::MessageDigest; +use openssl::pkey::{HasPublic, PKey, Private}; +use openssl::sign::{Signer, Verifier}; +use percent_encoding::{percent_decode_str, percent_encode, AsciiSet}; + +pub const TICKET_LIFETIME: i64 = 3600 * 2; // 2 hours + +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: proxmox::tools::time::epoch_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 = proxmox::tools::time::epoch_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, - 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 = proxmox::tools::time::epoch_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"))?; - // ::