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 max_login_ttl_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.max_login_ttl_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.deadline_ns;
247 if login.deadline_ns - login.started_at_ns > self.max_login_ttl_ns
248 || login.started_at_ns > observed_ns
249 || record.until_ns < minimum
250 || (!record.revoked() && record.until_ns != minimum)
251 {
252 return Err(LoginRevocationError::InvalidPolicy);
253 }
254 }
255 let (used, expected) = if record.admitted_pool() {
257 (self.admitted, expected_counts.0)
258 } else {
259 (self.unknown, expected_counts.1)
260 };
261 if used >= expected {
262 return Err(LoginRevocationError::Capacity);
263 }
264 self.install(change.0.caller, record);
265 }
266 if self.occupancy() != expected_counts {
267 return Err(LoginRevocationError::InvalidPolicy);
268 }
269 Ok(self)
270 }
271
272 pub fn new(
276 audience: AudienceId,
277 max_admitted: usize,
278 max_unknown: usize,
279 max_login_ttl_ns: u64,
280 now_ns: u64,
281 ) -> Result<Self, LoginRevocationError> {
282 if max_admitted == 0
283 || max_unknown == 0
284 || max_login_ttl_ns == 0
285 || max_login_ttl_ns > crate::limits::MAX_LOGIN_NS
286 {
287 return Err(LoginRevocationError::InvalidPolicy);
288 }
289 retention(now_ns, max_login_ttl_ns)?;
290 Ok(Self {
291 audience,
292 records: BTreeMap::new(),
293 expiry: BTreeSet::new(),
294 max_admitted,
295 max_unknown,
296 admitted: 0,
297 unknown: 0,
298 max_login_ttl_ns,
299 observed_ns: now_ns,
300 })
301 }
302
303 #[cfg(feature = "sessions")]
304 pub(crate) fn audience(&self) -> AudienceId {
305 self.audience
306 }
307
308 #[cfg(feature = "sessions")]
312 pub(crate) fn observe_login(
313 &mut self,
314 caller: Principal,
315 now_ns: u64,
316 ) -> Result<Option<LoginBinding>, LoginRevocationError> {
317 self.observe(caller, now_ns)?;
318 let record = self.records.get(&caller).filter(|r| r.until_ns > now_ns);
319 if record.is_some_and(|r| r.revoked()) {
320 return Err(LoginRevocationError::Revoked);
321 }
322 Ok(record.and_then(|r| r.login()))
323 }
324
325 pub fn occupancy(&self) -> (usize, usize) {
327 (self.admitted, self.unknown)
328 }
329
330 pub fn observed_at_ns(&self) -> u64 {
333 self.observed_ns
334 }
335
336 pub fn check_reservation(
343 &mut self,
344 caller: Principal,
345 claims: &PortableClaims,
346 now_ns: u64,
347 ) -> Result<(), LoginRevocationError> {
348 self.reservation(caller, claims, now_ns).map(|_| ())
349 }
350
351 #[cfg(all(
352 feature = "sessions",
353 any(
354 feature = "portable-signature-verification",
355 feature = "certified-proof-verification"
356 )
357 ))]
358 pub(crate) fn check_prepared_reservation(
359 &mut self,
360 caller: Principal,
361 prepared: &super::ContextualClaims<'_, '_>,
362 now_ns: u64,
363 ) -> Result<(), LoginRevocationError> {
364 self.observe(caller, now_ns)?;
365 self.reservation_checked(
366 caller,
367 &prepared.decoded().claims,
368 prepared.browser_caller(),
369 now_ns,
370 )
371 .map(|_| ())
372 }
373
374 pub fn with_reservation<T, E: From<LoginRevocationError>>(
384 &mut self,
385 caller: Principal,
386 verified: &VerifiedPortableClaims,
387 now_ns: u64,
388 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
389 ) -> Result<T, E> {
390 self.observe(caller, now_ns)?;
391 let record =
392 self.reservation_checked(caller, verified.claims(), verified.browser_caller(), now_ns)?;
393 if verified.verified_at_ns() != now_ns {
394 return Err(LoginRevocationError::InvalidPolicy.into());
395 }
396 self.commit_record(caller, record, commit)
397 }
398
399 pub fn with_revocation<E: From<LoginRevocationError>>(
416 &mut self,
417 unknown_budget: &mut SessionAdmissionBudget,
418 caller: Principal,
419 now_ns: u64,
420 commit: impl FnOnce(&LoginRevocationChange) -> Result<(), E>,
421 ) -> Result<u64, E> {
422 self.observe(caller, now_ns)?;
423 let old = self
424 .records
425 .get(&caller)
426 .copied()
427 .filter(|r| r.until_ns > now_ns);
428 let record = if let Some(old) = old {
429 Record {
430 until_ns: old.until_ns,
431 state: LoginRecordState::Revoked(old.login()),
432 }
433 } else {
434 let until_ns = retention(now_ns, self.max_login_ttl_ns)?;
435 Record {
436 until_ns,
437 state: LoginRecordState::Revoked(old.and_then(Record::login)),
438 }
439 };
440 self.capacity(caller, record)?;
441 if old.is_none() {
442 unknown_budget
443 .consume(now_ns)
444 .map_err(|error| match error {
445 AdmissionBudgetError::Exhausted => LoginRevocationError::Capacity,
446 AdmissionBudgetError::ClockRollback => LoginRevocationError::Clock,
447 AdmissionBudgetError::InvalidPolicy => LoginRevocationError::InvalidPolicy,
448 })?;
449 }
450 self.commit_record(caller, record, commit)?;
451 Ok(record.until_ns)
452 }
453
454 fn commit_record<T, E: From<LoginRevocationError>>(
457 &mut self,
458 caller: Principal,
459 record: Record,
460 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
461 ) -> Result<T, E> {
462 let change = LoginRevocationChange(LoginData {
463 audience: self.audience,
464 caller,
465 record,
466 });
467 if let Some(previous) = self.records.get(&caller) {
468 change.check_replacement(
469 &LoginRevocationChange(LoginData {
470 audience: self.audience,
471 caller,
472 record: *previous,
473 }),
474 self.observed_ns,
475 )?;
476 }
477 let value = commit(&change)?;
478 self.install(caller, record);
479 Ok(value)
480 }
481
482 pub fn is_revoked(
485 &mut self,
486 caller: Principal,
487 now_ns: u64,
488 ) -> Result<bool, LoginRevocationError> {
489 self.observe(caller, now_ns)?;
490 Ok(self
491 .records
492 .get(&caller)
493 .is_some_and(|r| r.revoked() && r.until_ns > now_ns))
494 }
495
496 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
499 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
500 }
501 pub fn prune_with_commit(
506 &mut self,
507 now_ns: u64,
508 budget: usize,
509 commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
510 ) -> Result<usize, LoginRevocationError> {
511 self.clock(now_ns)?;
512 let mut callers = Vec::new();
513 let mut counts = self.occupancy();
514 for (_, caller) in self
515 .expiry
516 .iter()
517 .take_while(|(until, _)| *until <= now_ns)
518 .take(budget.min(128))
519 {
520 if self.records[caller].admitted_pool() {
521 counts.0 -= 1;
522 } else {
523 counts.1 -= 1;
524 }
525 callers.push(*caller);
526 }
527 if callers.is_empty() {
528 return Ok(0);
529 }
530 commit(&callers, counts)?;
531 for caller in &callers {
532 self.remove(*caller);
533 }
534 Ok(callers.len())
535 }
536
537 fn reservation(
538 &mut self,
539 caller: Principal,
540 claims: &PortableClaims,
541 now_ns: u64,
542 ) -> Result<Record, LoginRevocationError> {
543 self.observe(caller, now_ns)?;
544 validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
545 self.reservation_checked(
546 caller,
547 claims,
548 browser_principal(&claims.browser_public_key),
549 now_ns,
550 )
551 }
552
553 fn reservation_checked(
556 &self,
557 caller: Principal,
558 claims: &PortableClaims,
559 browser_caller: Principal,
560 now_ns: u64,
561 ) -> Result<Record, LoginRevocationError> {
562 let login_deadline_ns = claims.login_deadline_ns;
563 if claims.audience != self.audience
564 || browser_caller != caller
565 || claims
566 .login_deadline_ns
567 .checked_sub(claims.login_started_at_ns)
568 .is_none_or(|ttl| ttl == 0 || ttl > self.max_login_ttl_ns)
569 {
570 return Err(LoginRevocationError::InvalidPolicy);
571 }
572 let until_ns = login_deadline_ns;
573 let login = LoginBinding {
574 issuer: claims.issuer,
575 subject: claims.account_subject,
576 started_at_ns: claims.login_started_at_ns,
577 deadline_ns: claims.login_deadline_ns,
578 };
579 if let Some(old) = self.records.get(&caller).filter(|r| r.until_ns > now_ns) {
580 if old.revoked() {
581 return Err(LoginRevocationError::Revoked);
582 }
583 if old.login() != Some(login) {
584 return Err(LoginRevocationError::InvalidPolicy);
585 }
586 }
587 let record = Record {
588 until_ns,
589 state: LoginRecordState::Reserved(login),
590 };
591 self.capacity(caller, record)?;
592 Ok(record)
593 }
594
595 fn clock(&mut self, now_ns: u64) -> Result<(), LoginRevocationError> {
596 if now_ns < self.observed_ns {
597 return Err(LoginRevocationError::Clock);
598 }
599 self.observed_ns = now_ns;
600 Ok(())
601 }
602
603 fn observe(&mut self, caller: Principal, now_ns: u64) -> Result<(), LoginRevocationError> {
604 if !is_login_caller(caller) {
605 return Err(LoginRevocationError::InvalidCaller);
606 }
607 self.clock(now_ns)
608 }
609
610 fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
611 let reuses_pool = self
612 .records
613 .get(&caller)
614 .is_some_and(|old| old.admitted_pool() == record.admitted_pool());
615 let (count, limit) = if record.admitted_pool() {
616 (self.admitted, self.max_admitted)
617 } else {
618 (self.unknown, self.max_unknown)
619 };
620 if !reuses_pool && count >= limit {
621 return Err(LoginRevocationError::Capacity);
622 }
623 Ok(())
624 }
625
626 fn remove(&mut self, caller: Principal) {
627 if let Some(old) = self.records.remove(&caller) {
628 self.expiry.remove(&(old.until_ns, caller));
629 if old.admitted_pool() {
630 self.admitted -= 1;
631 } else {
632 self.unknown -= 1;
633 }
634 }
635 }
636
637 fn install(&mut self, caller: Principal, record: Record) {
638 self.remove(caller);
639 if record.admitted_pool() {
640 self.admitted += 1;
641 } else {
642 self.unknown += 1;
643 }
644 self.expiry.insert((record.until_ns, caller));
645 self.records.insert(caller, record);
646 }
647}
648
649fn retention(now_ns: u64, max_login_ttl_ns: u64) -> Result<u64, LoginRevocationError> {
650 now_ns
651 .checked_add(max_login_ttl_ns)
652 .ok_or(LoginRevocationError::Clock)
653}
654
655#[cfg(test)]
656mod tests;