1use 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#[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#[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))]
69pub struct LoginBinding {
72 pub issuer: Principal,
73 pub subject: Principal,
74 pub started_at_ns: u64,
75 pub deadline_ns: u64,
76}
77
78pub 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 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 #[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 #[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 pub fn audience(&self) -> AudienceId {
172 self.audience
173 }
174 pub fn caller(&self) -> Principal {
176 self.caller
177 }
178 pub fn until_ns(&self) -> u64 {
180 self.record.until_ns
181 }
182 pub fn state(&self) -> LoginRecordState {
185 self.record.state
186 }
187}
188
189pub 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 #[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 #[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 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 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 #[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 pub fn occupancy(&self) -> (usize, usize) {
345 (self.admitted, self.unknown)
346 }
347
348 pub fn observed_at_ns(&self) -> u64 {
351 self.observed_ns
352 }
353
354 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 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 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 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 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 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
515 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
516 }
517 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 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}