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