Skip to main content

ic_auth/portable/
revocation.rs

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