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