]> git.proxmox.com Git - proxmox-backup.git/blob - src/tools/ticket.rs
avoid chrono dependency, depend on proxmox 0.3.8
[proxmox-backup.git] / src / tools / ticket.rs
1 //! Generate and verify Authentication tickets
2
3 use std::borrow::Cow;
4 use std::io;
5 use std::marker::PhantomData;
6
7 use anyhow::{bail, format_err, Error};
8 use openssl::hash::MessageDigest;
9 use openssl::pkey::{HasPublic, PKey, Private};
10 use openssl::sign::{Signer, Verifier};
11 use percent_encoding::{percent_decode_str, percent_encode, AsciiSet};
12
13 use crate::api2::types::Userid;
14
15 pub const TICKET_LIFETIME: i64 = 3600 * 2; // 2 hours
16
17 pub const TERM_PREFIX: &str = "PBSTERM";
18
19 /// Stringified ticket data must not contain colons...
20 const TICKET_ASCIISET: &AsciiSet = &percent_encoding::CONTROLS.add(b':');
21
22 /// An empty type implementing [`ToString`] and [`FromStr`](std::str::FromStr), used for tickets
23 /// with no data.
24 pub struct Empty;
25
26 impl ToString for Empty {
27 fn to_string(&self) -> String {
28 String::new()
29 }
30 }
31
32 impl std::str::FromStr for Empty {
33 type Err = Error;
34
35 fn from_str(s: &str) -> Result<Self, Error> {
36 if !s.is_empty() {
37 bail!("unexpected ticket data, should be empty");
38 }
39 Ok(Empty)
40 }
41 }
42
43 /// An API ticket consists of a ticket type (prefix), type-dependent data, optional additional
44 /// authenticaztion data, a timestamp and a signature. We store these values in the form
45 /// `<prefix>:<stringified data>:<timestamp>::<signature>`.
46 ///
47 /// The signature is made over the string consisting of prefix, data, timestamp and aad joined
48 /// together by colons. If there is no additional authentication data it will be skipped together
49 /// with the colon separating it from the timestamp.
50 pub struct Ticket<T>
51 where
52 T: ToString + std::str::FromStr,
53 {
54 prefix: Cow<'static, str>,
55 data: String,
56 time: i64,
57 signature: Option<Vec<u8>>,
58 _type_marker: PhantomData<T>,
59 }
60
61 impl<T> Ticket<T>
62 where
63 T: ToString + std::str::FromStr,
64 <T as std::str::FromStr>::Err: std::fmt::Debug,
65 {
66 /// Prepare a new ticket for signing.
67 pub fn new(prefix: &'static str, data: &T) -> Result<Self, Error> {
68 Ok(Self {
69 prefix: Cow::Borrowed(prefix),
70 data: data.to_string(),
71 time: proxmox::tools::time::epoch_i64(),
72 signature: None,
73 _type_marker: PhantomData,
74 })
75 }
76
77 /// Get the ticket prefix.
78 pub fn prefix(&self) -> &str {
79 &self.prefix
80 }
81
82 /// Get the ticket's time stamp in seconds since the unix epoch.
83 pub fn time(&self) -> i64 {
84 self.time
85 }
86
87 /// Get the raw string data contained in the ticket. The `verify` method will call `parse()`
88 /// this in the end, so using this method directly is discouraged as it does not verify the
89 /// signature.
90 pub fn raw_data(&self) -> &str {
91 &self.data
92 }
93
94 /// Serialize the ticket into a writer.
95 ///
96 /// This only writes a string. We use `io::write` instead of `fmt::Write` so we can reuse the
97 /// same function for openssl's `Verify`, which only implements `io::Write`.
98 fn write_data(&self, f: &mut dyn io::Write) -> Result<(), Error> {
99 write!(
100 f,
101 "{}:{}:{:08X}",
102 percent_encode(self.prefix.as_bytes(), &TICKET_ASCIISET),
103 percent_encode(self.data.as_bytes(), &TICKET_ASCIISET),
104 self.time,
105 )
106 .map_err(Error::from)
107 }
108
109 /// Write additional authentication data to the verifier.
110 fn write_aad(f: &mut dyn io::Write, aad: Option<&str>) -> Result<(), Error> {
111 if let Some(aad) = aad {
112 write!(f, ":{}", percent_encode(aad.as_bytes(), &TICKET_ASCIISET))?;
113 }
114 Ok(())
115 }
116
117 /// Change the ticket's time, used mostly for testing.
118 #[cfg(test)]
119 fn change_time(&mut self, time: i64) -> &mut Self {
120 self.time = time;
121 self
122 }
123
124 /// Sign the ticket.
125 pub fn sign(&mut self, keypair: &PKey<Private>, aad: Option<&str>) -> Result<String, Error> {
126 let mut output = Vec::<u8>::new();
127 let mut signer = Signer::new(MessageDigest::sha256(), &keypair)
128 .map_err(|err| format_err!("openssl error creating signer for ticket: {}", err))?;
129
130 self.write_data(&mut output)
131 .map_err(|err| format_err!("error creating ticket: {}", err))?;
132
133 signer
134 .update(&output)
135 .map_err(Error::from)
136 .and_then(|()| Self::write_aad(&mut signer, aad))
137 .map_err(|err| format_err!("error signing ticket: {}", err))?;
138
139 // See `Self::write_data` for why this is safe
140 let mut output = unsafe { String::from_utf8_unchecked(output) };
141
142 let signature = signer
143 .sign_to_vec()
144 .map_err(|err| format_err!("error finishing ticket signature: {}", err))?;
145
146 use std::fmt::Write;
147 write!(
148 &mut output,
149 "::{}",
150 base64::encode_config(&signature, base64::STANDARD_NO_PAD),
151 )?;
152
153 self.signature = Some(signature);
154
155 Ok(output)
156 }
157
158 /// `verify` with an additional time frame parameter, not usually required since we always use
159 /// the same time frame.
160 pub fn verify_with_time_frame<P: HasPublic>(
161 &self,
162 keypair: &PKey<P>,
163 prefix: &str,
164 aad: Option<&str>,
165 time_frame: std::ops::Range<i64>,
166 ) -> Result<T, Error> {
167 if self.prefix != prefix {
168 bail!("ticket with invalid prefix");
169 }
170
171 let signature = match self.signature.as_ref() {
172 Some(sig) => sig,
173 None => bail!("invalid ticket without signature"),
174 };
175
176 let age = proxmox::tools::time::epoch_i64() - self.time;
177 if age < time_frame.start {
178 bail!("invalid ticket - timestamp newer than expected");
179 }
180 if age > time_frame.end {
181 bail!("invalid ticket - expired");
182 }
183
184 let mut verifier = Verifier::new(MessageDigest::sha256(), &keypair)?;
185
186 self.write_data(&mut verifier)
187 .and_then(|()| Self::write_aad(&mut verifier, aad))
188 .map_err(|err| format_err!("error verifying ticket: {}", err))?;
189
190 let is_valid: bool = verifier
191 .verify(&signature)
192 .map_err(|err| format_err!("openssl error verifying ticket: {}", err))?;
193
194 if !is_valid {
195 bail!("ticket with invalid signature");
196 }
197
198 self.data
199 .parse()
200 .map_err(|err| format_err!("failed to parse contained ticket data: {:?}", err))
201 }
202
203 /// Verify the ticket with the provided key pair. The additional authentication data needs to
204 /// match the one used when generating the ticket, and the ticket's age must fall into the time
205 /// frame.
206 pub fn verify<P: HasPublic>(
207 &self,
208 keypair: &PKey<P>,
209 prefix: &str,
210 aad: Option<&str>,
211 ) -> Result<T, Error> {
212 self.verify_with_time_frame(keypair, prefix, aad, -300..TICKET_LIFETIME)
213 }
214
215 /// Parse a ticket string.
216 pub fn parse(ticket: &str) -> Result<Self, Error> {
217 let mut parts = ticket.splitn(4, ':');
218
219 let prefix = percent_decode_str(
220 parts
221 .next()
222 .ok_or_else(|| format_err!("ticket without prefix"))?,
223 )
224 .decode_utf8()
225 .map_err(|err| format_err!("invalid ticket, error decoding prefix: {}", err))?;
226
227 let data = percent_decode_str(
228 parts
229 .next()
230 .ok_or_else(|| format_err!("ticket without data"))?,
231 )
232 .decode_utf8()
233 .map_err(|err| format_err!("invalid ticket, error decoding data: {}", err))?;
234
235 let time = i64::from_str_radix(
236 parts
237 .next()
238 .ok_or_else(|| format_err!("ticket without timestamp"))?,
239 16,
240 )
241 .map_err(|err| format_err!("ticket with bad timestamp: {}", err))?;
242
243 let remainder = parts
244 .next()
245 .ok_or_else(|| format_err!("ticket without signature"))?;
246 // <prefix>:<data>:<time>::signature - the 4th `.next()` swallows the first colon in the
247 // double-colon!
248 if !remainder.starts_with(':') {
249 bail!("ticket without signature separator");
250 }
251 let signature = base64::decode_config(&remainder[1..], base64::STANDARD_NO_PAD)
252 .map_err(|err| format_err!("ticket with bad signature: {}", err))?;
253
254 Ok(Self {
255 prefix: Cow::Owned(prefix.into_owned()),
256 data: data.into_owned(),
257 time,
258 signature: Some(signature),
259 _type_marker: PhantomData,
260 })
261 }
262 }
263
264 pub fn term_aad(userid: &Userid, path: &str, port: u16) -> String {
265 format!("{}{}{}", userid, path, port)
266 }
267
268 #[cfg(test)]
269 mod test {
270 use openssl::pkey::{PKey, Private};
271
272 use super::Ticket;
273 use crate::api2::types::Userid;
274
275 fn simple_test<F>(key: &PKey<Private>, aad: Option<&str>, modify: F)
276 where
277 F: FnOnce(&mut Ticket<Userid>) -> bool,
278 {
279 let userid = Userid::root_userid();
280
281 let mut ticket = Ticket::new("PREFIX", userid).expect("failed to create Ticket struct");
282 let should_work = modify(&mut ticket);
283 let ticket = ticket.sign(key, aad).expect("failed to sign test ticket");
284
285 let parsed =
286 Ticket::<Userid>::parse(&ticket).expect("failed to parse generated test ticket");
287 if should_work {
288 let check: Userid = parsed
289 .verify(key, "PREFIX", aad)
290 .expect("failed to verify test ticket");
291
292 assert_eq!(*userid, check);
293 } else {
294 parsed
295 .verify(key, "PREFIX", aad)
296 .expect_err("failed to verify test ticket");
297 }
298 }
299
300 #[test]
301 fn test_tickets() {
302 // first we need keys, for testing we use small keys for speed...
303 let rsa =
304 openssl::rsa::Rsa::generate(1024).expect("failed to generate RSA key for testing");
305 let key = openssl::pkey::PKey::<openssl::pkey::Private>::from_rsa(rsa)
306 .expect("failed to create PKey for RSA key");
307
308 simple_test(&key, Some("secret aad data"), |_| true);
309 simple_test(&key, None, |_| true);
310 simple_test(&key, None, |t| {
311 t.change_time(0);
312 false
313 });
314 simple_test(&key, None, |t| {
315 t.change_time(proxmox::tools::time::epoch_i64() + 0x1000_0000);
316 false
317 });
318 }
319 }