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