Skip to main content

ic_auth/portable/
revocation.rs

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