]>
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 | 8 | use openssl::hash::MessageDigest; |
3f3ae19d WB |
9 | use openssl::pkey::{HasPublic, PKey, Private}; |
10 | use openssl::sign::{Signer, Verifier}; | |
11 | use percent_encoding::{percent_decode_str, percent_encode, AsciiSet}; | |
8d04280b | 12 | |
e7cb4dc5 | 13 | use crate::api2::types::Userid; |
e693818a | 14 | |
3f3ae19d | 15 | pub const TICKET_LIFETIME: i64 = 3600 * 2; // 2 hours |
e5662b04 | 16 | |
593f9177 WB |
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(), | |
6a7be83e | 71 | time: proxmox::tools::time::epoch_i64(), |
593f9177 WB |
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 | ||
6a7be83e | 176 | let age = proxmox::tools::time::epoch_i64() - self.time; |
593f9177 WB |
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( | |
3f3ae19d WB |
220 | parts |
221 | .next() | |
222 | .ok_or_else(|| format_err!("ticket without prefix"))?, | |
593f9177 WB |
223 | ) |
224 | .decode_utf8() | |
225 | .map_err(|err| format_err!("invalid ticket, error decoding prefix: {}", err))?; | |
226 | ||
227 | let data = percent_decode_str( | |
3f3ae19d WB |
228 | parts |
229 | .next() | |
230 | .ok_or_else(|| format_err!("ticket without data"))?, | |
593f9177 WB |
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 | ||
3f3ae19d WB |
243 | let remainder = parts |
244 | .next() | |
245 | .ok_or_else(|| format_err!("ticket without signature"))?; | |
593f9177 WB |
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 | ||
72dc6832 | 272 | use super::Ticket; |
593f9177 | 273 | use crate::api2::types::Userid; |
593f9177 WB |
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 | ||
3f3ae19d WB |
285 | let parsed = |
286 | Ticket::<Userid>::parse(&ticket).expect("failed to parse generated test ticket"); | |
593f9177 WB |
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); | |
593f9177 WB |
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... | |
3f3ae19d WB |
303 | let rsa = |
304 | openssl::rsa::Rsa::generate(1024).expect("failed to generate RSA key for testing"); | |
593f9177 WB |
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| { | |
6a7be83e | 315 | t.change_time(proxmox::tools::time::epoch_i64() + 0x1000_0000); |
593f9177 WB |
316 | false |
317 | }); | |
8d04280b | 318 | } |
8d04280b | 319 | } |