]> git.proxmox.com Git - proxmox-backup.git/blame - src/tools/ticket.rs
introduce Ticket struct
[proxmox-backup.git] / src / tools / ticket.rs
CommitLineData
add5861e 1//! Generate and verify Authentication tickets
8d04280b 2
593f9177
WB
3use std::borrow::Cow;
4use std::io;
5use std::marker::PhantomData;
6
7use anyhow::{bail, format_err, Error};
8d04280b
DM
8use base64;
9
593f9177 10use openssl::pkey::{PKey, Public, Private, HasPublic};
8d04280b
DM
11use openssl::sign::{Signer, Verifier};
12use openssl::hash::MessageDigest;
593f9177 13use percent_encoding::{AsciiSet, percent_decode_str, percent_encode};
8d04280b 14
e7cb4dc5 15use crate::api2::types::Userid;
e693818a
DC
16use crate::tools::epoch_now_u64;
17
e5662b04
DM
18pub const TICKET_LIFETIME: i64 = 3600*2; // 2 hours
19
593f9177
WB
20pub const TERM_PREFIX: &str = "PBSTERM";
21
22/// Stringified ticket data must not contain colons...
23const 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.
27pub struct Empty;
28
29impl ToString for Empty {
30 fn to_string(&self) -> String {
31 String::new()
32 }
33}
34
35impl 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.
53pub struct Ticket<T>
54where
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
64impl<T> Ticket<T>
65where
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
261pub fn term_aad(userid: &Userid, path: &str, port: u16) -> String {
262 format!("{}{}{}", userid, path, port)
263}
264
265#[cfg(test)]
266mod 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
336pub 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
350pub 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
367pub 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(&timestamp);
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
404pub 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}