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