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