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(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 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 #[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 #[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 pub fn audience(&self) -> AudienceId {
151 self.0.audience
152 }
153 pub fn caller(&self) -> Principal {
155 self.0.caller
156 }
157 pub fn until_ns(&self) -> u64 {
159 self.0.record.until_ns
160 }
161 pub fn state(&self) -> LoginRecordState {
164 self.0.record.state
165 }
166}
167
168pub 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 #[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 #[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 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 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 #[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 pub fn occupancy(&self) -> (usize, usize) {
324 (self.admitted, self.unknown)
325 }
326
327 pub fn observed_at_ns(&self) -> u64 {
330 self.observed_ns
331 }
332
333 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 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 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 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 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 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
494 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
495 }
496 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 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}