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