Skip to main content

ic_auth/portable/
revocation.rs

1//! Bounded volatile revocation bookkeeping, not an authentication endpoint.
2
3use super::{VerifiedPortableClaims, browser_principal, is_login_caller, validate_shape};
4use crate::admission_budget::{AdmissionBudgetError, SessionAdmissionBudget};
5use ic_auth_protocol_types::{AudienceId, PortableClaims, Principal};
6use std::collections::{BTreeMap, BTreeSet};
7use thiserror::Error;
8
9#[cfg(feature = "sessions")]
10const MAX_RECORD_BYTES: usize = 1024;
11
12/// A refusal leaves records unchanged; trusted clock observations are retained.
13#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
14pub enum LoginRevocationError {
15    #[error("logout requires an authenticated self-authenticating caller")]
16    InvalidCaller,
17    #[error("invalid revocation limits or authenticated login deadline")]
18    InvalidPolicy,
19    #[error("login is locally revoked")]
20    Revoked,
21    #[error("revocation capacity is exhausted")]
22    Capacity,
23    #[error("revocation clock rolled back or retention arithmetic overflowed")]
24    Clock,
25    #[error("host session/replay transaction failed")]
26    Storage,
27}
28
29#[derive(Clone, Copy)]
30#[cfg_attr(feature = "sessions", derive(serde::Serialize))]
31#[cfg_attr(feature = "session-restore", derive(serde::Deserialize))]
32struct Record {
33    until_ns: u64,
34    state: LoginRecordState,
35}
36
37// A reservation always owns admitted capacity; a tombstone retains that pool
38// exactly when it retains an authenticated login binding.
39/// Ledger state projected without inferring state from independent flags.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41#[cfg_attr(feature = "sessions", derive(serde::Serialize))]
42#[cfg_attr(feature = "session-restore", derive(serde::Deserialize))]
43pub enum LoginRecordState {
44    Reserved(LoginBinding),
45    Revoked(Option<LoginBinding>),
46}
47
48impl Record {
49    fn login(self) -> Option<LoginBinding> {
50        match self.state {
51            LoginRecordState::Reserved(login) => Some(login),
52            LoginRecordState::Revoked(login) => login,
53        }
54    }
55
56    fn revoked(self) -> bool {
57        matches!(self.state, LoginRecordState::Revoked(_))
58    }
59
60    fn admitted_pool(self) -> bool {
61        self.login().is_some()
62    }
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66#[cfg_attr(feature = "sessions", derive(serde::Serialize))]
67#[cfg_attr(feature = "session-restore", derive(serde::Deserialize))]
68#[cfg_attr(feature = "sessions", serde(deny_unknown_fields))]
69/// Authenticated login association retained by a reservation or known tombstone.
70/// This value alone is not authentication or admission evidence.
71pub struct LoginBinding {
72    pub issuer: Principal,
73    pub subject: Principal,
74    pub started_at_ns: u64,
75    pub deadline_ns: u64,
76}
77
78/// Proposed ledger record from a serialized commit callback or trusted storage.
79/// A durable host must persist this with its session/replay changes before
80/// returning success. This is not a credential or reusable authorization result.
81pub struct LoginRevocationChange {
82    audience: AudienceId,
83    caller: Principal,
84    record: Record,
85}
86
87impl LoginRevocationChange {
88    /// Encode the proposed record for the host's atomic durable commit.
89    #[cfg(feature = "sessions")]
90    pub fn encode(&self) -> Result<Vec<u8>, LoginRevocationError> {
91        crate::storage_codec::encode(
92            &(self.audience, self.caller, self.record),
93            crate::storage_codec::LOGIN,
94            MAX_RECORD_BYTES,
95            LoginRevocationError::Storage,
96            LoginRevocationError::InvalidPolicy,
97        )
98    }
99
100    /// Decode trusted storage only; never accept these bytes from a client.
101    /// This checks shape, not provenance, completeness, rollback or live authority.
102    #[cfg(feature = "session-restore")]
103    pub fn decode(bytes: &[u8]) -> Result<Self, LoginRevocationError> {
104        let (audience, caller, record): (_, Principal, Record) = crate::storage_codec::decode(
105            bytes,
106            crate::storage_codec::LOGIN,
107            MAX_RECORD_BYTES,
108            LoginRevocationError::InvalidPolicy,
109            LoginRevocationError::InvalidPolicy,
110        )?;
111        if !is_login_caller(caller) {
112            return Err(LoginRevocationError::InvalidCaller);
113        }
114        if let Some(login) = record.login()
115            && (login.issuer == Principal::anonymous()
116                || login.subject == Principal::anonymous()
117                || login.started_at_ns >= login.deadline_ns
118                || login.deadline_ns - login.started_at_ns > crate::limits::MAX_LOGIN_NS)
119        {
120            return Err(LoginRevocationError::InvalidPolicy);
121        }
122        let change = Self {
123            audience,
124            caller,
125            record,
126        };
127        Ok(change)
128    }
129
130    /// Protected trust domain owning the record.
131    pub fn audience(&self) -> AudienceId {
132        self.audience
133    }
134    /// Authenticated runtime caller whose record changes.
135    pub fn caller(&self) -> Principal {
136        self.caller
137    }
138    /// Exclusive retention deadline, including the configured clock margin.
139    pub fn until_ns(&self) -> u64 {
140        self.record.until_ns
141    }
142    /// Typed state from this checked ledger record. Storage codecs retain their
143    /// existing frozen representation independently of this Rust projection.
144    pub fn state(&self) -> LoginRecordState {
145        self.record.state
146    }
147}
148
149/// Bounded reference ledger for one host-installed trust domain. Callers are
150/// runtime principals, never request fields; this ledger cannot authenticate them.
151/// One admitted slot survives lease expiry until the login deadline plus skew.
152/// Unknown callers have a separate pool and cannot consume admitted capacity.
153///
154/// The host MUST serialize these operations with proof/session/replay state and
155/// authority changes. Commit callbacks receive the exact proposed login record
156/// and must atomically write it with that other state in durable backends, or
157/// return an error without effects. No await/reentrancy may split the operation.
158/// This ledger commits its staged record only after callback success, with no
159/// further fallible policy/storage checks. Callbacks must not return success
160/// before their commit, or commit and then return an error.
161///
162/// This is not portable admission or a logout endpoint. No proof verification,
163/// startup/enrollment readiness or durable storage is supplied here. Hosts MUST
164/// apply those gates and a qualified clock-skew bound separately. Fresh unknown
165/// revocations consume an explicit host-retained budget before the commit callback.
166/// Losing this ledger requires the protocol's fresh login/reset floors; restoring
167/// sessions alone is unsafe. Never recreate it per request or share across domains.
168pub struct MemoryLoginRevocations {
169    audience: AudienceId,
170    records: BTreeMap<Principal, Record>,
171    expiry: BTreeSet<(u64, Principal)>,
172    max_admitted: usize,
173    max_unknown: usize,
174    admitted: usize,
175    unknown: usize,
176    skew_ns: u64,
177    observed_ns: u64,
178}
179
180impl MemoryLoginRevocations {
181    /// Stream the complete physical ledger under a serialized host checkpoint.
182    /// Persist these records with occupancy, clock and session/replay/authority
183    /// state atomically; expired entries count until explicit pruning.
184    #[cfg(feature = "session-restore")]
185    pub fn records(&self) -> impl ExactSizeIterator<Item = LoginRevocationChange> + '_ {
186        self.records
187            .iter()
188            .map(|(&caller, &record)| LoginRevocationChange {
189                audience: self.audience,
190                caller,
191                record,
192            })
193    }
194
195    /// Rebuild a fresh ledger from a complete, trusted atomic storage generation.
196    /// `self` must be newly constructed at current trusted time. The host supplies
197    /// that generation's persisted clock high-water mark and physical pool counts,
198    /// including expired records, from the same transaction as the records.
199    /// Failure consumes the partial ledger; no usable partial state is returned.
200    /// Counts detect missing entries, not malicious rollback: hosts MUST protect
201    /// snapshot provenance/generation and restore reset/authority/session/replay
202    /// state together, keeping admission closed until fresh enrollment is checked.
203    #[cfg(feature = "session-restore")]
204    pub fn restore_records(
205        mut self,
206        observed_ns: u64,
207        expected_counts: (usize, usize),
208        records: impl IntoIterator<Item = LoginRevocationChange>,
209    ) -> Result<Self, LoginRevocationError> {
210        if !self.records.is_empty()
211            || observed_ns > self.observed_ns
212            || expected_counts.0 > self.max_admitted
213            || expected_counts.1 > self.max_unknown
214        {
215            return Err(LoginRevocationError::InvalidPolicy);
216        }
217        let max_until = retention(observed_ns, self.skew_ns)?;
218        for change in records {
219            if change.audience != self.audience || self.records.contains_key(&change.caller) {
220                return Err(LoginRevocationError::InvalidPolicy);
221            }
222            let record = change.record;
223            if record.until_ns == 0 || record.until_ns > max_until {
224                return Err(LoginRevocationError::InvalidPolicy);
225            }
226            if let Some(login) = record.login() {
227                let minimum = login
228                    .deadline_ns
229                    .checked_add(self.skew_ns)
230                    .ok_or(LoginRevocationError::Clock)?;
231                if login.started_at_ns > observed_ns
232                    || record.until_ns < minimum
233                    || (!record.revoked() && record.until_ns != minimum)
234                {
235                    return Err(LoginRevocationError::InvalidPolicy);
236                }
237            }
238            // Stop at the persisted counts, even for an unbounded input iterator.
239            let (used, expected) = if record.admitted_pool() {
240                (self.admitted, expected_counts.0)
241            } else {
242                (self.unknown, expected_counts.1)
243            };
244            if used >= expected {
245                return Err(LoginRevocationError::Capacity);
246            }
247            self.install(change.caller, record);
248        }
249        if self.occupancy() != expected_counts {
250            return Err(LoginRevocationError::InvalidPolicy);
251        }
252        Ok(self)
253    }
254
255    /// Fixed record counts bound memory; no caller-supplied blobs are retained.
256    /// `skew_ns` is deployment-qualified protected configuration, not a default.
257    pub fn new(
258        audience: AudienceId,
259        max_admitted: usize,
260        max_unknown: usize,
261        skew_ns: u64,
262        now_ns: u64,
263    ) -> Result<Self, LoginRevocationError> {
264        if max_admitted == 0 || max_unknown == 0 {
265            return Err(LoginRevocationError::InvalidPolicy);
266        }
267        retention(now_ns, skew_ns)?;
268        Ok(Self {
269            audience,
270            records: BTreeMap::new(),
271            expiry: BTreeSet::new(),
272            max_admitted,
273            max_unknown,
274            admitted: 0,
275            unknown: 0,
276            skew_ns,
277            observed_ns: now_ns,
278        })
279    }
280
281    #[cfg(feature = "sessions")]
282    pub(crate) fn audience(&self) -> AudienceId {
283        self.audience
284    }
285
286    // One observation for a serialized session operation. Missing reservation is
287    // checked after the session's ordinary liveness/authority refusals; denial
288    // still precedes those refusals. This is never retained between operations.
289    #[cfg(feature = "sessions")]
290    pub(crate) fn observe_login(
291        &mut self,
292        caller: Principal,
293        now_ns: u64,
294    ) -> Result<Option<LoginBinding>, LoginRevocationError> {
295        self.observe(caller, now_ns)?;
296        let record = self.records.get(&caller).filter(|r| r.until_ns > now_ns);
297        if record.is_some_and(|r| r.revoked()) {
298            return Err(LoginRevocationError::Revoked);
299        }
300        Ok(record.and_then(|r| r.login()))
301    }
302
303    /// Physical pool occupancy, including expired entries until bounded pruning.
304    pub fn occupancy(&self) -> (usize, usize) {
305        (self.admitted, self.unknown)
306    }
307
308    /// Clock high-water mark for an atomic host checkpoint, including refusals.
309    /// Durable hosts retain this with pool counts and the complete record set.
310    pub fn observed_at_ns(&self) -> u64 {
311        self.observed_ns
312    }
313
314    /// Cheap refusal before proof verification. This does not authenticate claims,
315    /// reserve capacity or permit a session commit; success is only permission to
316    /// continue checking. Untrusted claims can cause refusal, never admission.
317    /// Records and pool occupancy stay unchanged; trusted clock observations remain.
318    /// The host must serialize this check, verification and `with_reservation`
319    /// with authority/session/replay changes. The final reservation rechecks state.
320    pub fn check_reservation(
321        &mut self,
322        caller: Principal,
323        claims: &PortableClaims,
324        now_ns: u64,
325    ) -> Result<(), LoginRevocationError> {
326        self.reservation(caller, claims, now_ns).map(|_| ())
327    }
328
329    #[cfg(all(
330        feature = "sessions",
331        any(
332            feature = "portable-signature-verification",
333            feature = "certified-proof-verification"
334        )
335    ))]
336    pub(crate) fn check_prepared_reservation(
337        &mut self,
338        caller: Principal,
339        prepared: &super::ContextualClaims<'_, '_>,
340        now_ns: u64,
341    ) -> Result<(), LoginRevocationError> {
342        self.observe(caller, now_ns)?;
343        self.reservation_checked(
344            caller,
345            &prepared.decoded().claims,
346            prepared.browser_caller(),
347            now_ns,
348        )
349        .map(|_| ())
350    }
351
352    /// Reserve before committing a fully verified admission's session/replay pair.
353    /// The issuer/account and original login window stay unchanged on renewal.
354    /// Verified claims must match this ledger domain, caller and current clock.
355    /// The host still enforces current authority and replay under the same lock.
356    /// A live tombstone rejects even an exact admission retry before its callback.
357    /// No reservation is installed if session/replay storage in `commit` fails.
358    /// The callback receives the proposed record for that same atomic commit.
359    /// Its error is returned unchanged; `E` also carries ledger refusals through
360    /// `From<LoginRevocationError>`. No separate storage-error channel is needed.
361    pub fn with_reservation<T, E: From<LoginRevocationError>>(
362        &mut self,
363        caller: Principal,
364        verified: &VerifiedPortableClaims,
365        now_ns: u64,
366        commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
367    ) -> Result<T, E> {
368        self.observe(caller, now_ns)?;
369        let record =
370            self.reservation_checked(caller, verified.claims(), verified.browser_caller(), now_ns)?;
371        if verified.verified_at_ns() != now_ns {
372            return Err(LoginRevocationError::InvalidPolicy.into());
373        }
374        self.commit_record(caller, record, commit)
375    }
376
377    /// Stage local denial, then atomically remove this caller's host sessions in
378    /// `commit`. Return the denial deadline only after that callback commits.
379    /// An existing live tombstone keeps its deadline; the callback still runs so
380    /// retry never falsely acknowledges a failed host operation. No proof needed.
381    /// An admitted reservation converts in its own pool even if unknowns fill theirs.
382    /// A caller without a live record consumes one unknown-logout budget credit
383    /// before the callback; exhaustion returns Capacity, never false success.
384    /// Keep that receiver-wide budget separate from fresh proof verification so
385    /// unknown logout traffic cannot spend admission credit. Failure retains the
386    /// charge; hosts must return normal errors rather than trap/roll back credit.
387    /// Live reservations and tombstone retries do not compete for this budget.
388    /// The callback receives the proposed tombstone, including retained binding.
389    /// Its error is returned unchanged; `E` also carries ledger refusals through
390    /// `From<LoginRevocationError>`. A failed callback never installs denial.
391    pub fn with_revocation<E: From<LoginRevocationError>>(
392        &mut self,
393        unknown_budget: &mut SessionAdmissionBudget,
394        caller: Principal,
395        now_ns: u64,
396        commit: impl FnOnce(&LoginRevocationChange) -> Result<(), E>,
397    ) -> Result<u64, E> {
398        self.observe(caller, now_ns)?;
399        let old = self
400            .records
401            .get(&caller)
402            .copied()
403            .filter(|r| r.until_ns > now_ns);
404        let record = if let Some(old) = old.filter(|r| r.revoked()) {
405            old
406        } else {
407            let until_ns = retention(now_ns, self.skew_ns)?;
408            if old.is_some_and(|r| r.until_ns > until_ns) {
409                return Err(LoginRevocationError::Clock.into());
410            }
411            Record {
412                until_ns,
413                state: LoginRecordState::Revoked(old.and_then(Record::login)),
414            }
415        };
416        self.capacity(caller, record)?;
417        if old.is_none() {
418            unknown_budget
419                .consume(now_ns)
420                .map_err(|error| match error {
421                    AdmissionBudgetError::Exhausted => LoginRevocationError::Capacity,
422                    AdmissionBudgetError::ClockRollback => LoginRevocationError::Clock,
423                    AdmissionBudgetError::InvalidPolicy => LoginRevocationError::InvalidPolicy,
424                })?;
425        }
426        self.commit_record(caller, record, commit)?;
427        Ok(record.until_ns)
428    }
429
430    // One publication point: prepare all records before the host callback, and
431    // change heap state only after its atomic commit succeeds.
432    fn commit_record<T, E>(
433        &mut self,
434        caller: Principal,
435        record: Record,
436        commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
437    ) -> Result<T, E> {
438        let value = commit(&LoginRevocationChange {
439            audience: self.audience,
440            caller,
441            record,
442        })?;
443        self.install(caller, record);
444        Ok(value)
445    }
446
447    /// Consult local denial before any session use, including exact retries.
448    /// Absence is not authentication or authorization; other gates still apply.
449    pub fn is_revoked(
450        &mut self,
451        caller: Principal,
452        now_ns: u64,
453    ) -> Result<bool, LoginRevocationError> {
454        self.observe(caller, now_ns)?;
455        Ok(self
456            .records
457            .get(&caller)
458            .is_some_and(|r| r.revoked() && r.until_ns > now_ns))
459    }
460
461    /// Explicit maintenance, at most 128 expired entries; never evict live state.
462    /// The host clock must be monotonic, including across failed operations.
463    pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
464        self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
465    }
466    /// Commit bounded expired-login deletion and resulting admitted/unknown counts
467    /// before heap changes. Empty work skips the callback. Failure preserves all
468    /// records but retains the trusted clock observation, as other refusals do.
469    /// Hosts persist clock/authority metadata separately even when no rows expire.
470    pub fn prune_with_commit(
471        &mut self,
472        now_ns: u64,
473        budget: usize,
474        commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
475    ) -> Result<usize, LoginRevocationError> {
476        self.clock(now_ns)?;
477        let callers: Vec<_> = self
478            .expiry
479            .iter()
480            .take_while(|(until, _)| *until <= now_ns)
481            .take(budget.min(128))
482            .map(|(_, caller)| *caller)
483            .collect();
484        if callers.is_empty() {
485            return Ok(0);
486        }
487        let mut counts = self.occupancy();
488        for caller in &callers {
489            if self.records[caller].admitted_pool() {
490                counts.0 -= 1;
491            } else {
492                counts.1 -= 1;
493            }
494        }
495        commit(&callers, counts)?;
496        for caller in &callers {
497            self.remove(*caller);
498        }
499        Ok(callers.len())
500    }
501
502    fn reservation(
503        &mut self,
504        caller: Principal,
505        claims: &PortableClaims,
506        now_ns: u64,
507    ) -> Result<Record, LoginRevocationError> {
508        self.observe(caller, now_ns)?;
509        validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
510        self.reservation_checked(
511            caller,
512            claims,
513            browser_principal(&claims.browser_public_key),
514            now_ns,
515        )
516    }
517
518    // Only canonical decoded or verified claims can skip static shape checks.
519    // Caller/clock and all mutable ledger state are checked on every operation.
520    fn reservation_checked(
521        &self,
522        caller: Principal,
523        claims: &PortableClaims,
524        browser_caller: Principal,
525        now_ns: u64,
526    ) -> Result<Record, LoginRevocationError> {
527        let login_deadline_ns = claims.login_deadline_ns;
528        if claims.audience != self.audience || browser_caller != caller {
529            return Err(LoginRevocationError::InvalidPolicy);
530        }
531        let until_ns = login_deadline_ns
532            .checked_add(self.skew_ns)
533            .ok_or(LoginRevocationError::Clock)?;
534        let login = LoginBinding {
535            issuer: claims.issuer,
536            subject: claims.account_subject,
537            started_at_ns: claims.login_started_at_ns,
538            deadline_ns: claims.login_deadline_ns,
539        };
540        if let Some(old) = self.records.get(&caller).filter(|r| r.until_ns > now_ns) {
541            if old.revoked() {
542                return Err(LoginRevocationError::Revoked);
543            }
544            if old.login() != Some(login) {
545                return Err(LoginRevocationError::InvalidPolicy);
546            }
547        }
548        let record = Record {
549            until_ns,
550            state: LoginRecordState::Reserved(login),
551        };
552        self.capacity(caller, record)?;
553        Ok(record)
554    }
555
556    fn clock(&mut self, now_ns: u64) -> Result<(), LoginRevocationError> {
557        if now_ns < self.observed_ns {
558            return Err(LoginRevocationError::Clock);
559        }
560        self.observed_ns = now_ns;
561        Ok(())
562    }
563
564    fn observe(&mut self, caller: Principal, now_ns: u64) -> Result<(), LoginRevocationError> {
565        if !is_login_caller(caller) {
566            return Err(LoginRevocationError::InvalidCaller);
567        }
568        self.clock(now_ns)
569    }
570
571    fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
572        let reuses_pool = self
573            .records
574            .get(&caller)
575            .is_some_and(|old| old.admitted_pool() == record.admitted_pool());
576        let (count, limit) = if record.admitted_pool() {
577            (self.admitted, self.max_admitted)
578        } else {
579            (self.unknown, self.max_unknown)
580        };
581        if !reuses_pool && count >= limit {
582            return Err(LoginRevocationError::Capacity);
583        }
584        Ok(())
585    }
586
587    fn remove(&mut self, caller: Principal) {
588        if let Some(old) = self.records.remove(&caller) {
589            self.expiry.remove(&(old.until_ns, caller));
590            if old.admitted_pool() {
591                self.admitted -= 1;
592            } else {
593                self.unknown -= 1;
594            }
595        }
596    }
597
598    fn install(&mut self, caller: Principal, record: Record) {
599        self.remove(caller);
600        if record.admitted_pool() {
601            self.admitted += 1;
602        } else {
603            self.unknown += 1;
604        }
605        self.expiry.insert((record.until_ns, caller));
606        self.records.insert(caller, record);
607    }
608}
609
610fn retention(now_ns: u64, skew_ns: u64) -> Result<u64, LoginRevocationError> {
611    skew_ns
612        .checked_mul(2)
613        .and_then(|skew| crate::limits::MAX_LOGIN_NS.checked_add(skew))
614        .and_then(|ttl| now_ns.checked_add(ttl))
615        .ok_or(LoginRevocationError::Clock)
616}