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