1use std::fmt;
2use std::time::Duration;
3
4use ring::aead::{AES_256_GCM, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
5use sofie_core::{Error, Result, Timestamp, Uuid};
6
7use crate::signing::PurposeKeys;
8use sofie_core::encoding::base64::{self, STANDARD, URL_SAFE};
9
10pub const SIGNING_KEYS: &str = "SOFIE_SIGNING_KEYS";
11pub const OLD_SIGNING_KEYS: &str = "SOFIE_SESSION_KEYS";
13pub(crate) const KEY_LENGTH: usize = 32;
14const ABSOLUTE_LIFETIME_SECONDS: i64 = 30 * 24 * 60 * 60;
15const IDLE_TIMEOUT_SECONDS: i64 = 12 * 60 * 60;
16const REFRESH_AFTER_SECONDS: i64 = 5 * 60;
17const PURPOSE: &[u8] = b"sofie-session-v2";
19const VALUE_PURPOSE: &[u8] = b"sofie-value-v1:";
21const FIXED_LENGTH: usize = 16 + 16 + 8 + 8;
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct SessionClaims {
25 session: Uuid,
27 principal: Uuid,
28 area: String,
29 issued_at: i64,
30 last_seen: i64,
31}
32
33impl SessionClaims {
34 pub const ABSOLUTE_LIFETIME: Duration =
35 Duration::from_secs(ABSOLUTE_LIFETIME_SECONDS.unsigned_abs());
36
37 #[must_use]
38 pub fn new(session: Uuid, area: &str, principal: Uuid, now: Timestamp) -> Self {
39 Self {
40 session,
41 principal,
42 area: area.to_owned(),
43 issued_at: now.as_second(),
44 last_seen: now.as_second(),
45 }
46 }
47
48 #[must_use]
50 pub fn issued_at(self, at: Timestamp) -> Self {
51 Self {
52 issued_at: at.as_second(),
53 ..self
54 }
55 }
56
57 #[must_use]
58 pub fn session(&self) -> Uuid {
59 self.session
60 }
61
62 #[must_use]
63 pub fn principal(&self) -> Uuid {
64 self.principal
65 }
66
67 #[must_use]
68 pub fn area(&self) -> &str {
69 &self.area
70 }
71
72 #[must_use]
73 pub fn remaining_seconds(&self, now: Timestamp) -> i64 {
74 (self.issued_at + ABSOLUTE_LIFETIME_SECONDS - now.as_second()).max(0)
75 }
76
77 #[must_use]
78 pub fn needs_refresh(&self, now: Timestamp) -> bool {
79 now.as_second() - self.last_seen >= REFRESH_AFTER_SECONDS
80 }
81
82 #[must_use]
83 pub fn seen_at(self, now: Timestamp) -> Self {
84 Self {
85 last_seen: now.as_second(),
86 ..self
87 }
88 }
89
90 pub fn seal(&self, keys: &SigningKeys) -> Result<String> {
91 keys.sealed(PURPOSE, self.to_bytes())
92 }
93
94 pub fn open(
95 keys: &SigningKeys,
96 sealed: &str,
97 now: Timestamp,
98 ) -> std::result::Result<Self, OpenError> {
99 let plain = keys.opened(PURPOSE, sealed)?;
100 let claims = Self::from_bytes(&plain).ok_or(OpenError::Corrupt)?;
101 if !claims.is_live(now) {
102 return Err(OpenError::Expired);
103 }
104 Ok(claims)
105 }
106
107 fn is_live(&self, now: Timestamp) -> bool {
108 let now = now.as_second();
109 now < self.issued_at + ABSOLUTE_LIFETIME_SECONDS
110 && now < self.last_seen + IDLE_TIMEOUT_SECONDS
111 }
112
113 fn to_bytes(&self) -> Vec<u8> {
114 let mut bytes = Vec::with_capacity(FIXED_LENGTH + self.area.len());
115 bytes.extend_from_slice(self.session.as_bytes());
116 bytes.extend_from_slice(self.principal.as_bytes());
117 bytes.extend_from_slice(&self.issued_at.to_be_bytes());
118 bytes.extend_from_slice(&self.last_seen.to_be_bytes());
119 bytes.extend_from_slice(self.area.as_bytes());
120 bytes
121 }
122
123 fn from_bytes(bytes: &[u8]) -> Option<Self> {
124 let (session, rest) = bytes.split_first_chunk::<16>()?;
125 let (principal, rest) = rest.split_first_chunk::<16>()?;
126 let (issued_at, rest) = rest.split_first_chunk::<8>()?;
127 let (last_seen, area) = rest.split_first_chunk::<8>()?;
128 Some(Self {
129 session: Uuid::from_bytes(*session),
130 principal: Uuid::from_bytes(*principal),
131 area: String::from_utf8(area.to_vec()).ok()?,
132 issued_at: i64::from_be_bytes(*issued_at),
133 last_seen: i64::from_be_bytes(*last_seen),
134 })
135 }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub enum OpenError {
140 Malformed,
141 Unauthenticated,
142 Expired,
143 Corrupt,
145}
146
147impl fmt::Display for OpenError {
148 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149 formatter.write_str(match self {
150 Self::Malformed => "the sealed session is malformed",
151 Self::Unauthenticated => "no key held opens the sealed session",
152 Self::Expired => "the session has expired",
153 Self::Corrupt => "the sealed session opened but holds no claims",
154 })
155 }
156}
157
158impl std::error::Error for OpenError {}
159
160pub struct SigningKeys {
162 first: Key,
163 rest: Vec<Key>,
164}
165
166pub(crate) struct Key {
167 sealing: LessSafeKey,
168 pub(crate) signing: PurposeKeys,
169}
170
171impl Key {
172 fn new(key: &[u8; KEY_LENGTH]) -> Self {
173 let sealing = UnboundKey::new(&AES_256_GCM, key)
174 .expect("AES-256-GCM takes any key of KEY_LENGTH bytes");
175 Self {
176 sealing: LessSafeKey::new(sealing),
177 signing: crate::signing::purpose_keys(key),
178 }
179 }
180}
181
182impl SigningKeys {
183 pub fn from_environment() -> Result<Self> {
184 Self::read(sofie_core::environment_variable)
185 }
186
187 pub fn read(lookup: impl Fn(&str) -> Result<Option<String>>) -> Result<Self> {
188 let text = keys_text(lookup)?.ok_or_else(|| {
189 keys_error("is not set; set it to one or more comma-separated base64 keys of 32 bytes")
190 })?;
191 Self::parse(&text)
192 }
193
194 pub fn parse(text: &str) -> Result<Self> {
195 let keys = text
196 .split(',')
197 .enumerate()
198 .map(|(index, key)| decode_key(index, key.trim()))
199 .collect::<Result<Vec<_>>>()?;
200 Self::new(&keys)
201 }
202
203 pub fn new(keys: &[[u8; KEY_LENGTH]]) -> Result<Self> {
204 let (first, rest) = keys
205 .split_first()
206 .ok_or_else(|| keys_error("holds no key"))?;
207 Ok(Self {
208 first: Key::new(first),
209 rest: rest.iter().map(Key::new).collect(),
210 })
211 }
212
213 pub(crate) fn first(&self) -> &Key {
214 &self.first
215 }
216
217 pub(crate) fn all(&self) -> impl Iterator<Item = &Key> {
218 std::iter::once(&self.first).chain(&self.rest)
219 }
220
221 pub fn generate() -> Result<Self> {
222 Self::new(&[crate::random_bytes("a key")?])
223 }
224
225 pub fn seal_value(&self, purpose: &str, plain: &[u8]) -> Result<String> {
226 self.sealed(&value_purpose(purpose), plain.to_vec())
227 }
228
229 #[must_use]
230 pub fn open_value(&self, purpose: &str, sealed: &str) -> Option<Vec<u8>> {
231 self.opened(&value_purpose(purpose), sealed).ok()
232 }
233
234 fn sealed(&self, purpose: &[u8], mut plain: Vec<u8>) -> Result<String> {
235 let nonce: [u8; NONCE_LEN] = crate::random_bytes("a nonce")?;
236 self.first
237 .sealing
238 .seal_in_place_append_tag(
239 Nonce::assume_unique_for_key(nonce),
240 Aad::from(purpose),
241 &mut plain,
242 )
243 .map_err(|_| Error::internal_message("sealing a value failed"))?;
244 let mut bytes = nonce.to_vec();
245 bytes.append(&mut plain);
246 Ok(base64::encode(&URL_SAFE, &bytes))
247 }
248
249 fn opened(&self, purpose: &[u8], sealed: &str) -> std::result::Result<Vec<u8>, OpenError> {
250 let bytes = base64::decode(&URL_SAFE, sealed).ok_or(OpenError::Malformed)?;
251 let (nonce, ciphertext) = bytes
252 .split_first_chunk::<NONCE_LEN>()
253 .ok_or(OpenError::Malformed)?;
254 self.all()
255 .find_map(|key| open_with(&key.sealing, *nonce, purpose, ciphertext))
256 .ok_or(OpenError::Unauthenticated)
257 }
258}
259
260fn keys_text(lookup: impl Fn(&str) -> Result<Option<String>>) -> Result<Option<String>> {
263 match (lookup(SIGNING_KEYS)?, lookup(OLD_SIGNING_KEYS)?) {
264 (Some(keys), Some(old)) if keys != old => Err(keys_error(&format!(
265 "and {OLD_SIGNING_KEYS} are both set and differ; keep only {SIGNING_KEYS}"
266 ))),
267 (Some(keys), _) | (None, Some(keys)) => Ok(Some(keys)),
268 (None, None) => Ok(None),
269 }
270}
271
272fn value_purpose(purpose: &str) -> Vec<u8> {
273 [VALUE_PURPOSE, purpose.as_bytes()].concat()
274}
275
276impl fmt::Debug for SigningKeys {
277 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
278 formatter.write_str("SigningKeys(…)")
279 }
280}
281
282fn open_with(
284 key: &LessSafeKey,
285 nonce: [u8; NONCE_LEN],
286 purpose: &[u8],
287 ciphertext: &[u8],
288) -> Option<Vec<u8>> {
289 let mut plain = ciphertext.to_vec();
290 let length = key
291 .open_in_place(
292 Nonce::assume_unique_for_key(nonce),
293 Aad::from(purpose),
294 &mut plain,
295 )
296 .ok()?
297 .len();
298 plain.truncate(length);
299 Some(plain)
300}
301
302fn decode_key(index: usize, text: &str) -> Result<[u8; KEY_LENGTH]> {
304 base64::decode(&STANDARD, text)
305 .and_then(|bytes| <[u8; KEY_LENGTH]>::try_from(bytes).ok())
306 .ok_or_else(|| {
307 keys_error(&format!(
308 "key {} is not the base64 of 32 bytes; generate one with `openssl rand -base64 32`",
309 index + 1
310 ))
311 })
312}
313
314fn keys_error(problem: &str) -> Error {
315 Error::internal_message(format!("{SIGNING_KEYS} {problem}"))
316}
317
318#[cfg(test)]
319pub(crate) mod tests {
320 use super::*;
321
322 const HOUR: i64 = 60 * 60;
323
324 fn at(second: i64) -> Timestamp {
325 Timestamp::from_second(1_700_000_000 + second).unwrap()
326 }
327
328 pub(crate) fn keys(byte: u8) -> SigningKeys {
329 SigningKeys::new(&[[byte; KEY_LENGTH]]).unwrap()
330 }
331
332 pub(crate) fn claims() -> SessionClaims {
333 SessionClaims::new(
334 Uuid::new_v4().unwrap(),
335 "member",
336 Uuid::now_v7().unwrap(),
337 at(0),
338 )
339 }
340
341 #[test]
342 fn a_sealed_session_opens_with_its_claims_and_hides_them() {
343 let keys = keys(1);
344 let claims = claims();
345
346 let sealed = claims.seal(&keys).unwrap();
347
348 assert_eq!(
349 SessionClaims::open(&keys, &sealed, at(60)),
350 Ok(claims.clone())
351 );
352 assert!(!sealed.contains("member"));
353 assert_ne!(
354 claims.seal(&keys).unwrap(),
355 sealed,
356 "every seal has its own nonce"
357 );
358 }
359
360 #[test]
361 fn a_sealed_value_opens_only_for_its_own_purpose() {
362 let sealing = keys(1);
363 let sealed = sealing.seal_value("totp", b"secret").unwrap();
364
365 assert_eq!(
366 sealing.open_value("totp", &sealed),
367 Some(b"secret".to_vec())
368 );
369 assert_eq!(sealing.open_value("pending", &sealed), None);
370 assert_eq!(
371 SessionClaims::open(&sealing, &sealed, at(0)),
372 Err(OpenError::Unauthenticated)
373 );
374 assert_eq!(keys(2).open_value("totp", &sealed), None);
375 let session = claims().seal(&sealing).unwrap();
376 assert_eq!(sealing.open_value("", &session), None);
377 }
378
379 #[test]
380 fn a_tampered_session_is_refused() {
381 let keys = keys(1);
382 let sealed = claims().seal(&keys).unwrap();
383 let mut bytes = base64::decode(&URL_SAFE, &sealed).unwrap();
384
385 for index in [0, NONCE_LEN, bytes.len() - 1] {
386 bytes[index] ^= 1;
387 assert_eq!(
388 SessionClaims::open(&keys, &base64::encode(&URL_SAFE, &bytes), at(0)),
389 Err(OpenError::Unauthenticated)
390 );
391 bytes[index] ^= 1;
392 }
393 assert_eq!(
394 SessionClaims::open(&keys, "not a session", at(0)),
395 Err(OpenError::Malformed)
396 );
397 assert_eq!(
398 SessionClaims::open(&keys, "", at(0)),
399 Err(OpenError::Malformed)
400 );
401 }
402
403 #[test]
404 fn a_session_sealed_with_another_key_is_refused() {
405 let sealed = claims().seal(&keys(1)).unwrap();
406
407 assert_eq!(
408 SessionClaims::open(&keys(2), &sealed, at(0)),
409 Err(OpenError::Unauthenticated)
410 );
411 }
412
413 #[test]
414 fn a_session_that_opens_without_claims_is_corrupt() {
415 let keys = keys(1);
416 let sealed = keys.sealed(PURPOSE, b"too short".to_vec()).unwrap();
417
418 assert_eq!(
419 SessionClaims::open(&keys, &sealed, at(0)),
420 Err(OpenError::Corrupt)
421 );
422 }
423
424 #[test]
425 fn keys_rotate_by_putting_the_successor_first() {
426 let old = claims().seal(&keys(1)).unwrap();
427 let rotated = SigningKeys::new(&[[2; KEY_LENGTH], [1; KEY_LENGTH]]).unwrap();
428
429 let new = claims().seal(&rotated).unwrap();
430
431 assert!(SessionClaims::open(&rotated, &old, at(0)).is_ok());
432 assert!(SessionClaims::open(&keys(2), &new, at(0)).is_ok());
433 assert_eq!(
434 SessionClaims::open(&keys(1), &new, at(0)),
435 Err(OpenError::Unauthenticated)
436 );
437 }
438
439 #[test]
440 fn a_session_ends_thirty_days_after_sign_in_however_active() {
441 let keys = keys(1);
442 let day = 24 * HOUR;
443 let active = claims().seen_at(at(30 * day - 60));
444 let sealed = active.seal(&keys).unwrap();
445
446 assert!(SessionClaims::open(&keys, &sealed, at(30 * day - 1)).is_ok());
447 assert_eq!(
448 SessionClaims::open(&keys, &sealed, at(30 * day)),
449 Err(OpenError::Expired)
450 );
451 assert_eq!(active.remaining_seconds(at(29 * day)), day);
452 }
453
454 #[test]
455 fn a_session_signed_back_in_keeps_its_first_sign_ins_lifetime() {
456 let keys = keys(1);
457 let day = 24 * HOUR;
458 let resumed = SessionClaims::new(
459 Uuid::new_v4().unwrap(),
460 "member",
461 Uuid::now_v7().unwrap(),
462 at(29 * day),
463 )
464 .issued_at(at(0));
465 let sealed = resumed.seal(&keys).unwrap();
466
467 assert_eq!(resumed.remaining_seconds(at(29 * day)), day);
468 assert_eq!(
469 SessionClaims::open(&keys, &sealed, at(30 * day)),
470 Err(OpenError::Expired)
471 );
472 }
473
474 #[test]
475 fn a_session_ends_after_twelve_idle_hours_and_a_refresh_extends_it() {
476 let keys = keys(1);
477 let claims = claims();
478 let sealed = claims.seal(&keys).unwrap();
479 let refreshed = claims.clone().seen_at(at(11 * HOUR)).seal(&keys).unwrap();
480
481 assert!(SessionClaims::open(&keys, &sealed, at(12 * HOUR - 1)).is_ok());
482 assert_eq!(
483 SessionClaims::open(&keys, &sealed, at(12 * HOUR)),
484 Err(OpenError::Expired)
485 );
486 assert!(SessionClaims::open(&keys, &refreshed, at(22 * HOUR)).is_ok());
487 }
488
489 #[test]
490 fn a_refresh_is_due_after_five_minutes() {
491 let claims = claims();
492
493 assert!(!claims.needs_refresh(at(299)));
494 assert!(claims.needs_refresh(at(300)));
495 }
496
497 #[test]
498 fn keys_are_parsed_from_base64_and_never_quoted() {
499 let key = base64::encode(&STANDARD, &[7; KEY_LENGTH]);
500 assert!(SigningKeys::parse(&format!("{key}=, {key}")).is_ok());
501
502 let short = base64::encode(&STANDARD, &[7; 16]);
503 let error = SigningKeys::parse(&format!("{key},{short}")).unwrap_err();
504 let message = error.to_string();
505 assert!(
506 message.starts_with("SOFIE_SIGNING_KEYS key 2 is not"),
507 "{message}"
508 );
509 assert!(!message.contains(&short) && !message.contains(&key));
510 assert!(SigningKeys::parse("").is_err());
511 assert_eq!(format!("{:?}", keys(1)), "SigningKeys(…)");
512 }
513
514 fn read(variables: &[(&str, &str)]) -> Result<SigningKeys> {
515 SigningKeys::read(|name| {
516 Ok(variables
517 .iter()
518 .find(|(variable, _)| *variable == name)
519 .map(|(_, value)| (*value).to_owned()))
520 })
521 }
522
523 #[test]
524 fn keys_are_read_under_the_old_name_too_unless_both_differ() {
525 let key = base64::encode(&STANDARD, &[7; KEY_LENGTH]);
526 let other = base64::encode(&STANDARD, &[8; KEY_LENGTH]);
527 assert!(read(&[(SIGNING_KEYS, &key)]).is_ok());
528 assert!(read(&[(OLD_SIGNING_KEYS, &key)]).is_ok());
529 assert!(read(&[(SIGNING_KEYS, &key), (OLD_SIGNING_KEYS, &key)]).is_ok());
530
531 let differing = read(&[(SIGNING_KEYS, &key), (OLD_SIGNING_KEYS, &other)]);
532 let message = differing.unwrap_err().to_string();
533 assert_eq!(
534 message,
535 "SOFIE_SIGNING_KEYS and SOFIE_SESSION_KEYS are both set and differ; \
536 keep only SOFIE_SIGNING_KEYS"
537 );
538 let missing = read(&[]).unwrap_err().to_string();
539 assert!(
540 missing.starts_with("SOFIE_SIGNING_KEYS is not set"),
541 "{missing}"
542 );
543 }
544}