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    /// No cookie outlives it, however often it is refreshed.
35    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    /// Opened but holds no claims: sealed by a framework bug, not by a client.
136    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
152/// The first key seals and signs, every key opens: rotate by putting the successor first.
153pub 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
252/// The keys under their name, else under the old one; both set apart is refused,
253/// as which one signs would otherwise depend on the reader.
254fn 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
274/// Opens a copy, since a failed open garbles its buffer and the next key needs the original.
275fn 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
294/// Names the key by its position only; the key itself is a secret.
295fn 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}