Skip to main content

orbit_auth/
fleet.rs

1use std::num::NonZeroUsize;
2use std::sync::{Arc, Mutex};
3
4use hmac::{Hmac, Mac};
5use lru::LruCache;
6use orbit_core::Fleet;
7use serde::{Deserialize, Serialize};
8use sha2::Sha256;
9use zeroize::Zeroizing;
10
11use crate::keyring::{context, derive};
12use crate::table::{
13    CONSUMED, REVOKED, Record, RecordProtection, SESSION, Snapshot, StoreKey,
14    Table, find, insert, subject_sessions,
15};
16use crate::{
17    Authority, CacheKey, CachedPrincipal, Claims, Error, MemoryCache,
18    Principal, PrincipalCache, Purpose, Result, SecretKey, StateProtection,
19    ValidationHook, Validator,
20};
21
22/// Random session identity. It is not a bearer credential.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct SessionId(pub [u8; 16]);
25
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub(crate) struct SessionBinding {
29    pub(crate) id: SessionId,
30    generation: u64,
31}
32
33/// Issuer-side handle, never serialized into SHM. Live validation checks state
34/// even when a caller retained a handle predating refresh or revocation.
35pub struct Session {
36    binding: SessionBinding,
37    subject: String,
38    created_at: u64,
39    expires_at: u64,
40    realm: String,
41    issuer: String,
42}
43impl Session {
44    pub fn id(&self) -> SessionId {
45        self.binding.id
46    }
47    pub fn subject(&self) -> &str {
48        &self.subject
49    }
50    pub fn created_at(&self) -> u64 {
51        self.created_at
52    }
53    pub fn expires_at(&self) -> u64 {
54        self.expires_at
55    }
56    pub(crate) fn binding(&self) -> SessionBinding {
57        self.binding.clone()
58    }
59    pub(crate) fn check_issuance(
60        &self,
61        authority: &Authority,
62        claims: &Claims,
63    ) -> Result<()> {
64        if self.realm != authority.realm
65            || self.issuer != authority.issuer
66            || claims.subject != self.subject
67            || claims.issued_at < self.created_at
68            || claims.expires_at > self.expires_at
69        {
70            return Err(Error::InvalidInput);
71        }
72        Ok(())
73    }
74}
75
76struct SessionView {
77    snapshot: Snapshot,
78    // Compared once against the shared keyed subject tag, then checked locally.
79    subject: String,
80}
81
82/// Local decoded Principals plus fleet-native live sessions and replay state.
83/// Backing follows the supplied Fleet. SHM survives handle drop and restart;
84/// memory state lives as long as a handle retains it. No background driver.
85///
86/// Shared records contain only keyed identifiers and fixed control metadata.
87/// Owner-only OS permissions restrict access. `Blake3State` verifies
88/// integrity; `EncryptedState` also hides metadata. `UnprotectedState` explicitly
89/// trusts every writer. Protected modes still permit deletion/rollback attacks.
90/// `state_key` stays stable across token-key rotations and is never stored in SHM.
91pub struct FleetAuth<P: StateProtection> {
92    _fleet: Arc<Fleet>,
93    table: Table,
94    local: MemoryCache,
95    sessions: Mutex<LruCache<SessionId, SessionView>>,
96    state_key: Zeroizing<[u8; 32]>,
97    protection: RecordProtection<P>,
98    realm: String,
99    issuer: String,
100}
101impl<P: StateProtection> FleetAuth<P> {
102    /// Construct with an explicitly selected shared-state protection policy.
103    /// There is no default policy.
104    ///
105    /// `local_capacity` bounds each local Principal/session-view LRU. A cached
106    /// protected view is reused only while every shared byte matches its local copy.
107    /// UnprotectedState trusts the commit revision instead.
108    /// Peers sharing a physical table must select the same policy. Token crypto
109    /// and live session/replay checks are enabled for every policy.
110    pub fn new(
111        fleet: Arc<Fleet>,
112        authority: &Authority,
113        state_key: SecretKey,
114        local_capacity: NonZeroUsize,
115        _policy: P,
116    ) -> Result<Self> {
117        let info = context(&[
118            b"orbit-auth/v2/fleet-state",
119            fleet.name().as_bytes(),
120            authority.realm.as_bytes(),
121            authority.issuer.as_bytes(),
122        ]);
123        Ok(Self {
124            table: Table::new(fleet.clone(), P::ID)?,
125            // The physical table may contain multiple realm/issuer namespaces.
126            // Its protection key is fleet-scoped; record lookup identifiers below bind
127            // the realm/issuer. All users of one table share this state secret.
128            protection: RecordProtection::new(derive(
129                &state_key,
130                &context(&[
131                    b"orbit-auth/v4/fleet-state-protection",
132                    fleet.name().as_bytes(),
133                    &P::ID.to_le_bytes(),
134                ]),
135            )),
136            _fleet: fleet,
137            local: MemoryCache::new(local_capacity),
138            sessions: Mutex::new(LruCache::new(local_capacity)),
139            state_key: derive(&state_key, &info),
140            realm: authority.realm.clone(),
141            issuer: authority.issuer.clone(),
142        })
143    }
144
145    /// Clear this handle's decoded/validated views; shared authority is preserved.
146    pub fn clear_local(&self) {
147        self.local.clear();
148        self.sessions.lock().unwrap_or_else(|e| e.into_inner()).clear();
149    }
150
151    pub fn create_session(
152        &self,
153        subject: impl Into<String>,
154        now: u64,
155        expires_at: u64,
156    ) -> Result<Session> {
157        self.create(subject.into(), now, expires_at, None)
158    }
159
160    /// Admit a new session and revoke the oldest excess sessions of this
161    /// subject under the same writer lock. The new session is always retained.
162    /// No new index, shared-memory layout or background driver is introduced.
163    pub fn create_session_with_limit(
164        &self,
165        subject: impl Into<String>,
166        now: u64,
167        expires_at: u64,
168        limit: NonZeroUsize,
169    ) -> Result<Session> {
170        self.create(subject.into(), now, expires_at, Some(limit))
171    }
172
173    fn create(
174        &self,
175        subject: String,
176        now: u64,
177        expires_at: u64,
178        limit: Option<NonZeroUsize>,
179    ) -> Result<Session> {
180        if !crate::principal::valid_label(&subject) || now >= expires_at {
181            return Err(Error::InvalidInput);
182        }
183        let mut id = [0; 16];
184        getrandom::fill(&mut id).map_err(|_| Error::Randomness)?;
185        let id = SessionId(id);
186        let record = Record {
187            key: self.session_key(id),
188            subject: self.subject_tag(&subject),
189            created_at: now,
190            expires_at,
191            generation: 0,
192            status: SESSION,
193        };
194        self.table.write(|slots| {
195            if find(slots, record.key, &self.protection)?.is_some() {
196                return Err(Error::InvalidInput);
197            }
198            let mut live = match limit {
199                Some(_) => subject_sessions(
200                    slots,
201                    record.subject,
202                    now,
203                    &self.protection,
204                )?,
205                None => Vec::new(),
206            };
207            // Do not revoke anything if admission itself fails (e.g. full table).
208            insert(slots, record, now, &self.protection)?;
209            if let Some(limit) = limit {
210                live.sort_unstable_by_key(|session| {
211                    (session.record.created_at, session.index)
212                });
213                let excess = live.len().saturating_sub(limit.get() - 1);
214                for session in live.into_iter().take(excess) {
215                    let mut old = session.record;
216                    old.status = REVOKED;
217                    slots[session.index].store(
218                        old,
219                        session.index,
220                        &self.protection,
221                    )?;
222                }
223            }
224            Ok(())
225        })?;
226        Ok(self.handle(id, subject, record))
227    }
228
229    /// Revoke every live session of this subject in this realm/issuer.
230    /// Writers are serialized; readers observe each authenticated replacement.
231    pub fn revoke_subject_sessions(
232        &self,
233        subject: &str,
234        now: u64,
235    ) -> Result<usize> {
236        if !crate::principal::valid_label(subject) {
237            return Err(Error::InvalidInput);
238        }
239        self.table.write(|slots| {
240            let live = subject_sessions(
241                slots,
242                self.subject_tag(subject),
243                now,
244                &self.protection,
245            )?;
246            for session in &live {
247                let mut record = session.record;
248                record.status = REVOKED;
249                slots[session.index].store(
250                    record,
251                    session.index,
252                    &self.protection,
253                )?;
254            }
255            Ok(live.len())
256        })
257    }
258
259    /// Extend live subject sessions using the caller's declared policy.
260    /// The callback receives creation and current expiry times. Validate all
261    /// target horizons before writing; never resurrect or shorten a session.
262    pub fn extend_subject_sessions(
263        &self,
264        subject: &str,
265        now: u64,
266        horizon: impl Fn(u64, u64) -> u64,
267    ) -> Result<usize> {
268        if !crate::principal::valid_label(subject) {
269            return Err(Error::InvalidInput);
270        }
271        self.table.write(|slots| {
272            let live = subject_sessions(
273                slots,
274                self.subject_tag(subject),
275                now,
276                &self.protection,
277            )?;
278            let targets: Vec<_> = live
279                .into_iter()
280                .map(|session| {
281                    let target = horizon(
282                        session.record.created_at,
283                        session.record.expires_at,
284                    );
285                    (session, target)
286                })
287                .collect();
288            if targets.iter().any(|(session, target)| {
289                *target < session.record.expires_at || *target <= now
290            }) {
291                return Err(Error::InvalidInput);
292            }
293            let mut changed = 0;
294            for (session, target) in targets {
295                if target > session.record.expires_at {
296                    let mut record = session.record;
297                    record.expires_at = target;
298                    slots[session.index].store(
299                        record,
300                        session.index,
301                        &self.protection,
302                    )?;
303                    changed += 1;
304                }
305            }
306            Ok(changed)
307        })
308    }
309
310    /// Atomically publish revocation. Every subsequent validation rechecks the
311    /// authenticated record, including warm local cache hits. Already admitted work is
312    /// not cancelled; no notification or polling interval delays the next check.
313    pub fn revoke_session(
314        &self,
315        id: SessionId,
316        now: u64,
317    ) -> Result<()> {
318        let key = self.session_key(id);
319        self.table.write(|slots| {
320            let snapshot =
321                find(slots, key, &self.protection)?.ok_or(Error::Revoked)?;
322            let mut record = snapshot.record;
323            if !matches!(record.status, SESSION | REVOKED) {
324                return Err(Error::PolicyUnavailable);
325            }
326            if now < record.created_at {
327                return Err(Error::NotYetValid);
328            }
329            record.status = REVOKED;
330            slots[snapshot.index].store(
331                record,
332                snapshot.index,
333                &self.protection,
334            )?;
335            Ok(())
336        })
337    }
338
339    /// The live session behind an id, for the subject that owns it: a handle
340    /// a worker that did not create the session uses to issue its credentials
341    /// or read its horizon. Lock-free; a raced read takes one locked retry.
342    pub fn session(
343        &self,
344        id: SessionId,
345        subject: &str,
346        now: u64,
347    ) -> Result<Session> {
348        if !crate::principal::valid_label(subject) {
349            return Err(Error::InvalidInput);
350        }
351        let snapshot = self
352            .table
353            .lookup(self.session_key(id), &self.protection)?
354            .ok_or(Error::Revoked)?;
355        if snapshot.record.subject != self.subject_tag(subject) {
356            return Err(Error::InvalidInput);
357        }
358        self.check_live(snapshot.record, now)?;
359        Ok(self.handle(id, subject.to_owned(), snapshot.record))
360    }
361
362    /// Move a live session's horizon forward on activity the application
363    /// trusts, without a credential and without consuming a refresh generation.
364    /// `subject` must own the session. The horizon never moves back: shortening
365    /// a session is revocation. Credentials issued earlier keep their own
366    /// expiry; the returned handle issues ones that reach the new horizon.
367    pub fn extend_session(
368        &self,
369        id: SessionId,
370        subject: &str,
371        now: u64,
372        expires_at: u64,
373    ) -> Result<Session> {
374        if !crate::principal::valid_label(subject) || now >= expires_at {
375            return Err(Error::InvalidInput);
376        }
377        let key = self.session_key(id);
378        let tag = self.subject_tag(subject);
379        self.table.write(|slots| {
380            let snapshot =
381                find(slots, key, &self.protection)?.ok_or(Error::Revoked)?;
382            let mut record = snapshot.record;
383            if record.subject != tag {
384                return Err(Error::InvalidInput);
385            }
386            self.check_live(record, now)?;
387            if expires_at < record.expires_at {
388                return Err(Error::InvalidInput);
389            }
390            if expires_at > record.expires_at {
391                record.expires_at = expires_at;
392                slots[snapshot.index].store(
393                    record,
394                    snapshot.index,
395                    &self.protection,
396                )?;
397            }
398            Ok(self.handle(id, subject.to_owned(), record))
399        })
400    }
401
402    /// Validate and consume the current refresh generation atomically. Exactly
403    /// one concurrent caller succeeds. The explicit renewed expiry may preserve
404    /// or extend the previous horizon. The returned handle issues new credentials.
405    /// The application owns renewal/delegation limits. If a response is lost,
406    /// the consumed credential is not retried; reauthentication is required.
407    pub fn refresh(
408        &self,
409        validator: &Validator<'_>,
410        token: &str,
411        now: u64,
412        expires_at: u64,
413    ) -> Result<Session> {
414        if validator.validation.purpose != Purpose::Refresh {
415            return Err(Error::InvalidInput);
416        }
417        let principal = validator.validate(token, now, self)?;
418        let binding = principal.session.as_ref().ok_or(Error::InvalidToken)?;
419        let key = self.session_key(binding.id);
420        let subject = self.subject_tag(principal.subject());
421        self.table.write(|slots| {
422            let snapshot =
423                find(slots, key, &self.protection)?.ok_or(Error::Revoked)?;
424            let mut record = snapshot.record;
425            self.check_record(record, subject, &principal, now)?;
426            if expires_at < record.expires_at {
427                return Err(Error::InvalidInput);
428            }
429            record.generation = record
430                .generation
431                .checked_add(1)
432                .ok_or(Error::PolicyUnavailable)?;
433            record.expires_at = expires_at;
434            slots[snapshot.index].store(
435                record,
436                snapshot.index,
437                &self.protection,
438            )?;
439            Ok(self.handle(binding.id, principal.subject().to_owned(), record))
440        })
441    }
442
443    /// One-use hook. Unexpired consumed IDs never evict; full/unavailable denies.
444    pub fn replay_guard(&self) -> ReplayGuard<'_, P> {
445        ReplayGuard(self)
446    }
447
448    fn handle(
449        &self,
450        id: SessionId,
451        subject: String,
452        record: Record,
453    ) -> Session {
454        Session {
455            binding: SessionBinding { id, generation: record.generation },
456            subject,
457            created_at: record.created_at,
458            expires_at: record.expires_at,
459            realm: self.realm.clone(),
460            issuer: self.issuer.clone(),
461        }
462    }
463    fn session_key(
464        &self,
465        id: SessionId,
466    ) -> StoreKey {
467        StoreKey(mac(self.state_key.as_ref(), &context(&[b"session", &id.0])))
468    }
469    fn subject_tag(
470        &self,
471        subject: &str,
472    ) -> [u64; 4] {
473        mac(
474            self.state_key.as_ref(),
475            &context(&[b"subject", subject.as_bytes()]),
476        )
477    }
478    fn check_scope(
479        &self,
480        principal: &Principal,
481        now: u64,
482    ) -> Result<()> {
483        self.check_scope_lapsed(principal, now)?;
484        if now >= principal.claims.expires_at {
485            return Err(Error::Expired);
486        }
487        Ok(())
488    }
489    fn check_scope_lapsed(
490        &self,
491        principal: &Principal,
492        now: u64,
493    ) -> Result<()> {
494        if principal.realm != self.realm || principal.issuer != self.issuer {
495            return Err(Error::InvalidToken);
496        }
497        if now < principal.claims.not_before || now < principal.claims.issued_at
498        {
499            return Err(Error::NotYetValid);
500        }
501        Ok(())
502    }
503    /// The live-session part of `check`, after the scope was accepted.
504    fn check_session(
505        &self,
506        principal: &Principal,
507        now: u64,
508    ) -> Result<()> {
509        let binding = principal.session.as_ref().ok_or(Error::InvalidToken)?;
510        {
511            let mut views =
512                self.sessions.lock().map_err(|_| Error::PolicyUnavailable)?;
513            if let Some(view) = views.get(&binding.id) {
514                if self.table.unchanged::<P>(&view.snapshot)? {
515                    if view.subject != principal.subject() {
516                        return Err(Error::InvalidToken);
517                    }
518                    return self.check_metadata(
519                        view.snapshot.record,
520                        principal,
521                        now,
522                    );
523                }
524                views.pop(&binding.id);
525            }
526        }
527        let key = self.session_key(binding.id);
528        let snapshot =
529            self.table.lookup(key, &self.protection)?.ok_or(Error::Revoked)?;
530        self.check_record(
531            snapshot.record,
532            self.subject_tag(principal.subject()),
533            principal,
534            now,
535        )?;
536        self.sessions.lock().map_err(|_| Error::PolicyUnavailable)?.put(
537            binding.id,
538            SessionView { snapshot, subject: principal.subject().to_owned() },
539        );
540        Ok(())
541    }
542    fn check_record(
543        &self,
544        record: Record,
545        subject: [u64; 4],
546        principal: &Principal,
547        now: u64,
548    ) -> Result<()> {
549        if record.subject != subject {
550            return Err(Error::InvalidToken);
551        }
552        self.check_metadata(record, principal, now)
553    }
554    fn check_live(
555        &self,
556        record: Record,
557        now: u64,
558    ) -> Result<()> {
559        match record.status {
560            REVOKED => return Err(Error::Revoked),
561            SESSION => (),
562            _ => return Err(Error::PolicyUnavailable),
563        }
564        if now >= record.expires_at {
565            return Err(Error::Expired);
566        }
567        if now < record.created_at {
568            return Err(Error::NotYetValid);
569        }
570        Ok(())
571    }
572    fn check_metadata(
573        &self,
574        record: Record,
575        principal: &Principal,
576        now: u64,
577    ) -> Result<()> {
578        self.check_live(record, now)?;
579        if principal.claims.issued_at < record.created_at
580            || principal.claims.expires_at > record.expires_at
581        {
582            return Err(Error::InvalidToken);
583        }
584        let binding = principal.session.as_ref().ok_or(Error::InvalidToken)?;
585        if principal.claims.purpose == Purpose::Refresh
586            && binding.generation != record.generation
587        {
588            return Err(Error::Replayed);
589        }
590        Ok(())
591    }
592}
593
594impl<P: StateProtection> PrincipalCache for FleetAuth<P> {
595    fn get(
596        &self,
597        key: &CacheKey,
598    ) -> Option<CachedPrincipal> {
599        self.local.get(key)
600    }
601    fn insert(
602        &self,
603        key: CacheKey,
604        entry: CachedPrincipal,
605    ) {
606        if key == entry.key {
607            self.local.insert(key, entry);
608        }
609    }
610}
611
612impl<P: StateProtection> ValidationHook for FleetAuth<P> {
613    fn check(
614        &self,
615        principal: &Principal,
616        now: u64,
617    ) -> Result<()> {
618        self.check_scope(principal, now)?;
619        self.check_session(principal, now)
620    }
621
622    /// A lapsed credential of a live session passes the session checks; the
623    /// record's own horizon, revocation and subject still decide.
624    fn check_lapsed(
625        &self,
626        principal: &Principal,
627        now: u64,
628    ) -> Result<()> {
629        self.check_scope_lapsed(principal, now)?;
630        self.check_session(principal, now)
631    }
632}
633
634pub struct ReplayGuard<'a, P: StateProtection>(&'a FleetAuth<P>);
635impl<P: StateProtection> ValidationHook for ReplayGuard<'_, P> {
636    fn check(
637        &self,
638        principal: &Principal,
639        now: u64,
640    ) -> Result<()> {
641        let auth = self.0;
642        auth.check_scope(principal, now)?;
643        let key = StoreKey(mac(
644            auth.state_key.as_ref(),
645            &context(&[b"replay", &principal.jti.0]),
646        ));
647        // Replay admission and live session check are one locked operation.
648        auth.table.write(|slots| {
649            if let Some(binding) = &principal.session {
650                let snapshot = find(
651                    slots,
652                    auth.session_key(binding.id),
653                    &auth.protection,
654                )?
655                .ok_or(Error::Revoked)?;
656                auth.check_record(
657                    snapshot.record,
658                    auth.subject_tag(principal.subject()),
659                    principal,
660                    now,
661                )?;
662            }
663            if find(slots, key, &auth.protection)?.is_some() {
664                return Err(Error::Replayed);
665            }
666            insert(
667                slots,
668                Record {
669                    key,
670                    subject: [0; 4],
671                    created_at: now,
672                    expires_at: principal.claims.expires_at,
673                    generation: 0,
674                    status: CONSUMED,
675                },
676                now,
677                &auth.protection,
678            )
679        })
680    }
681}
682
683fn mac(
684    key: &[u8],
685    value: &[u8],
686) -> [u64; 4] {
687    let mut mac =
688        <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(key).expect("HMAC key");
689    mac.update(value);
690    let bytes = mac.finalize().into_bytes();
691    std::array::from_fn(|i| {
692        u64::from_le_bytes(
693            bytes[i * 8..i * 8 + 8].try_into().expect("eight bytes"),
694        )
695    })
696}
697
698#[cfg(test)]
699#[path = "../tests/unit/fleet.rs"]
700mod tests;