Skip to main content

sofie_auth/
session.rs

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";
11/// Read until apps move to `SOFIE_SIGNING_KEYS`; `sofie::run` warns while it is in use.
12pub 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;
17/// Bound to every seal, so a value sealed for anything else never opens.
18const PURPOSE: &[u8] = b"sofie-session-v2";
19/// Prefixes a sealed value's own purpose, so no value purpose can spell the session's.
20const 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    /// Chosen at sign-in and kept through refreshes, so a store can list and end the session.
26    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    /// For a session signed back in, which keeps its first sign-in's absolute lifetime.
49    #[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    /// Opened but holds no claims: sealed by a framework bug, not by a client.
144    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
160/// The first key seals and signs, every key opens: rotate by putting the successor first.
161pub 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
260/// The keys under their name, else under the old one; both set apart is refused,
261/// as which one signs would otherwise depend on the reader.
262fn 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
282/// Opens a copy, since a failed open garbles its buffer and the next key needs the original.
283fn 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
302/// Names the key by its position only; the key itself is a secret.
303/// One new key as `SOFIE_SIGNING_KEYS` takes it: what `openssl rand -base64 32` prints.
304pub fn new_signing_key() -> Result<String> {
305    let key: [u8; KEY_LENGTH] = crate::random_bytes("a key")?;
306    Ok(base64::encode_padded(&STANDARD, &key))
307}
308
309fn decode_key(index: usize, text: &str) -> Result<[u8; KEY_LENGTH]> {
310    base64::decode(&STANDARD, text)
311        .and_then(|bytes| <[u8; KEY_LENGTH]>::try_from(bytes).ok())
312        .ok_or_else(|| {
313            keys_error(&format!(
314                "key {} is not the base64 of 32 bytes; generate one with `openssl rand -base64 32`",
315                index + 1
316            ))
317        })
318}
319
320fn keys_error(problem: &str) -> Error {
321    Error::internal_message(format!("{SIGNING_KEYS} {problem}"))
322}
323
324#[cfg(test)]
325pub(crate) mod tests {
326    use super::*;
327
328    const HOUR: i64 = 60 * 60;
329
330    fn at(second: i64) -> Timestamp {
331        Timestamp::from_second(1_700_000_000 + second).unwrap()
332    }
333
334    pub(crate) fn keys(byte: u8) -> SigningKeys {
335        SigningKeys::new(&[[byte; KEY_LENGTH]]).unwrap()
336    }
337
338    pub(crate) fn claims() -> SessionClaims {
339        SessionClaims::new(
340            Uuid::new_v4().unwrap(),
341            "member",
342            Uuid::now_v7().unwrap(),
343            at(0),
344        )
345    }
346
347    #[test]
348    fn a_generated_key_text_is_read_back_and_differs_each_time() {
349        let first = new_signing_key().unwrap();
350        let second = new_signing_key().unwrap();
351
352        assert_ne!(first, second);
353        let keys = SigningKeys::parse(&format!("{first},{second}")).unwrap();
354        let sealed = keys.seal_value("test", b"plain").unwrap();
355        let opened = SigningKeys::parse(&first)
356            .unwrap()
357            .open_value("test", &sealed);
358        assert_eq!(opened.as_deref(), Some(&b"plain"[..]));
359    }
360
361    #[test]
362    fn a_sealed_session_opens_with_its_claims_and_hides_them() {
363        let keys = keys(1);
364        let claims = claims();
365
366        let sealed = claims.seal(&keys).unwrap();
367
368        assert_eq!(
369            SessionClaims::open(&keys, &sealed, at(60)),
370            Ok(claims.clone())
371        );
372        assert!(!sealed.contains("member"));
373        assert_ne!(
374            claims.seal(&keys).unwrap(),
375            sealed,
376            "every seal has its own nonce"
377        );
378    }
379
380    #[test]
381    fn a_sealed_value_opens_only_for_its_own_purpose() {
382        let sealing = keys(1);
383        let sealed = sealing.seal_value("totp", b"secret").unwrap();
384
385        assert_eq!(
386            sealing.open_value("totp", &sealed),
387            Some(b"secret".to_vec())
388        );
389        assert_eq!(sealing.open_value("pending", &sealed), None);
390        assert_eq!(
391            SessionClaims::open(&sealing, &sealed, at(0)),
392            Err(OpenError::Unauthenticated)
393        );
394        assert_eq!(keys(2).open_value("totp", &sealed), None);
395        let session = claims().seal(&sealing).unwrap();
396        assert_eq!(sealing.open_value("", &session), None);
397    }
398
399    #[test]
400    fn a_tampered_session_is_refused() {
401        let keys = keys(1);
402        let sealed = claims().seal(&keys).unwrap();
403        let mut bytes = base64::decode(&URL_SAFE, &sealed).unwrap();
404
405        for index in [0, NONCE_LEN, bytes.len() - 1] {
406            bytes[index] ^= 1;
407            assert_eq!(
408                SessionClaims::open(&keys, &base64::encode(&URL_SAFE, &bytes), at(0)),
409                Err(OpenError::Unauthenticated)
410            );
411            bytes[index] ^= 1;
412        }
413        assert_eq!(
414            SessionClaims::open(&keys, "not a session", at(0)),
415            Err(OpenError::Malformed)
416        );
417        assert_eq!(
418            SessionClaims::open(&keys, "", at(0)),
419            Err(OpenError::Malformed)
420        );
421    }
422
423    #[test]
424    fn a_session_sealed_with_another_key_is_refused() {
425        let sealed = claims().seal(&keys(1)).unwrap();
426
427        assert_eq!(
428            SessionClaims::open(&keys(2), &sealed, at(0)),
429            Err(OpenError::Unauthenticated)
430        );
431    }
432
433    #[test]
434    fn a_session_that_opens_without_claims_is_corrupt() {
435        let keys = keys(1);
436        let sealed = keys.sealed(PURPOSE, b"too short".to_vec()).unwrap();
437
438        assert_eq!(
439            SessionClaims::open(&keys, &sealed, at(0)),
440            Err(OpenError::Corrupt)
441        );
442    }
443
444    #[test]
445    fn keys_rotate_by_putting_the_successor_first() {
446        let old = claims().seal(&keys(1)).unwrap();
447        let rotated = SigningKeys::new(&[[2; KEY_LENGTH], [1; KEY_LENGTH]]).unwrap();
448
449        let new = claims().seal(&rotated).unwrap();
450
451        assert!(SessionClaims::open(&rotated, &old, at(0)).is_ok());
452        assert!(SessionClaims::open(&keys(2), &new, at(0)).is_ok());
453        assert_eq!(
454            SessionClaims::open(&keys(1), &new, at(0)),
455            Err(OpenError::Unauthenticated)
456        );
457    }
458
459    #[test]
460    fn a_session_ends_thirty_days_after_sign_in_however_active() {
461        let keys = keys(1);
462        let day = 24 * HOUR;
463        let active = claims().seen_at(at(30 * day - 60));
464        let sealed = active.seal(&keys).unwrap();
465
466        assert!(SessionClaims::open(&keys, &sealed, at(30 * day - 1)).is_ok());
467        assert_eq!(
468            SessionClaims::open(&keys, &sealed, at(30 * day)),
469            Err(OpenError::Expired)
470        );
471        assert_eq!(active.remaining_seconds(at(29 * day)), day);
472    }
473
474    #[test]
475    fn a_session_signed_back_in_keeps_its_first_sign_ins_lifetime() {
476        let keys = keys(1);
477        let day = 24 * HOUR;
478        let resumed = SessionClaims::new(
479            Uuid::new_v4().unwrap(),
480            "member",
481            Uuid::now_v7().unwrap(),
482            at(29 * day),
483        )
484        .issued_at(at(0));
485        let sealed = resumed.seal(&keys).unwrap();
486
487        assert_eq!(resumed.remaining_seconds(at(29 * day)), day);
488        assert_eq!(
489            SessionClaims::open(&keys, &sealed, at(30 * day)),
490            Err(OpenError::Expired)
491        );
492    }
493
494    #[test]
495    fn a_session_ends_after_twelve_idle_hours_and_a_refresh_extends_it() {
496        let keys = keys(1);
497        let claims = claims();
498        let sealed = claims.seal(&keys).unwrap();
499        let refreshed = claims.clone().seen_at(at(11 * HOUR)).seal(&keys).unwrap();
500
501        assert!(SessionClaims::open(&keys, &sealed, at(12 * HOUR - 1)).is_ok());
502        assert_eq!(
503            SessionClaims::open(&keys, &sealed, at(12 * HOUR)),
504            Err(OpenError::Expired)
505        );
506        assert!(SessionClaims::open(&keys, &refreshed, at(22 * HOUR)).is_ok());
507    }
508
509    #[test]
510    fn a_refresh_is_due_after_five_minutes() {
511        let claims = claims();
512
513        assert!(!claims.needs_refresh(at(299)));
514        assert!(claims.needs_refresh(at(300)));
515    }
516
517    #[test]
518    fn keys_are_parsed_from_base64_and_never_quoted() {
519        let key = base64::encode(&STANDARD, &[7; KEY_LENGTH]);
520        assert!(SigningKeys::parse(&format!("{key}=, {key}")).is_ok());
521
522        let short = base64::encode(&STANDARD, &[7; 16]);
523        let error = SigningKeys::parse(&format!("{key},{short}")).unwrap_err();
524        let message = error.to_string();
525        assert!(
526            message.starts_with("SOFIE_SIGNING_KEYS key 2 is not"),
527            "{message}"
528        );
529        assert!(!message.contains(&short) && !message.contains(&key));
530        assert!(SigningKeys::parse("").is_err());
531        assert_eq!(format!("{:?}", keys(1)), "SigningKeys(…)");
532    }
533
534    fn read(variables: &[(&str, &str)]) -> Result<SigningKeys> {
535        SigningKeys::read(|name| {
536            Ok(variables
537                .iter()
538                .find(|(variable, _)| *variable == name)
539                .map(|(_, value)| (*value).to_owned()))
540        })
541    }
542
543    #[test]
544    fn keys_are_read_under_the_old_name_too_unless_both_differ() {
545        let key = base64::encode(&STANDARD, &[7; KEY_LENGTH]);
546        let other = base64::encode(&STANDARD, &[8; KEY_LENGTH]);
547        assert!(read(&[(SIGNING_KEYS, &key)]).is_ok());
548        assert!(read(&[(OLD_SIGNING_KEYS, &key)]).is_ok());
549        assert!(read(&[(SIGNING_KEYS, &key), (OLD_SIGNING_KEYS, &key)]).is_ok());
550
551        let differing = read(&[(SIGNING_KEYS, &key), (OLD_SIGNING_KEYS, &other)]);
552        let message = differing.unwrap_err().to_string();
553        assert_eq!(
554            message,
555            "SOFIE_SIGNING_KEYS and SOFIE_SESSION_KEYS are both set and differ; \
556             keep only SOFIE_SIGNING_KEYS"
557        );
558        let missing = read(&[]).unwrap_err().to_string();
559        assert!(
560            missing.starts_with("SOFIE_SIGNING_KEYS is not set"),
561            "{missing}"
562        );
563    }
564}