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