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
87impl LoginRevocationChange {
88 #[cfg(feature = "sessions")]
90 pub fn encode(&self) -> Result<Vec<u8>, LoginRevocationError> {
91 crate::storage_codec::encode(
92 &(self.audience, self.caller, self.record),
93 crate::storage_codec::LOGIN,
94 MAX_RECORD_BYTES,
95 LoginRevocationError::Storage,
96 LoginRevocationError::InvalidPolicy,
97 )
98 }
99
100 #[cfg(feature = "session-restore")]
103 pub fn decode(bytes: &[u8]) -> Result<Self, LoginRevocationError> {
104 let (audience, caller, record): (_, Principal, Record) = crate::storage_codec::decode(
105 bytes,
106 crate::storage_codec::LOGIN,
107 MAX_RECORD_BYTES,
108 LoginRevocationError::InvalidPolicy,
109 LoginRevocationError::InvalidPolicy,
110 )?;
111 if !is_login_caller(caller) {
112 return Err(LoginRevocationError::InvalidCaller);
113 }
114 if let Some(login) = record.login()
115 && (login.issuer == Principal::anonymous()
116 || login.subject == Principal::anonymous()
117 || login.started_at_ns >= login.deadline_ns
118 || login.deadline_ns - login.started_at_ns > crate::limits::MAX_LOGIN_NS)
119 {
120 return Err(LoginRevocationError::InvalidPolicy);
121 }
122 let change = Self {
123 audience,
124 caller,
125 record,
126 };
127 Ok(change)
128 }
129
130 pub fn audience(&self) -> AudienceId {
132 self.audience
133 }
134 pub fn caller(&self) -> Principal {
136 self.caller
137 }
138 pub fn until_ns(&self) -> u64 {
140 self.record.until_ns
141 }
142 pub fn state(&self) -> LoginRecordState {
145 self.record.state
146 }
147}
148
149pub struct MemoryLoginRevocations {
169 audience: AudienceId,
170 records: BTreeMap<Principal, Record>,
171 expiry: BTreeSet<(u64, Principal)>,
172 max_admitted: usize,
173 max_unknown: usize,
174 admitted: usize,
175 unknown: usize,
176 skew_ns: u64,
177 observed_ns: u64,
178}
179
180impl MemoryLoginRevocations {
181 #[cfg(feature = "session-restore")]
185 pub fn records(&self) -> impl ExactSizeIterator<Item = LoginRevocationChange> + '_ {
186 self.records
187 .iter()
188 .map(|(&caller, &record)| LoginRevocationChange {
189 audience: self.audience,
190 caller,
191 record,
192 })
193 }
194
195 #[cfg(feature = "session-restore")]
204 pub fn restore_records(
205 mut self,
206 observed_ns: u64,
207 expected_counts: (usize, usize),
208 records: impl IntoIterator<Item = LoginRevocationChange>,
209 ) -> Result<Self, LoginRevocationError> {
210 if !self.records.is_empty()
211 || observed_ns > self.observed_ns
212 || expected_counts.0 > self.max_admitted
213 || expected_counts.1 > self.max_unknown
214 {
215 return Err(LoginRevocationError::InvalidPolicy);
216 }
217 let max_until = retention(observed_ns, self.skew_ns)?;
218 for change in records {
219 if change.audience != self.audience || self.records.contains_key(&change.caller) {
220 return Err(LoginRevocationError::InvalidPolicy);
221 }
222 let record = change.record;
223 if record.until_ns == 0 || record.until_ns > max_until {
224 return Err(LoginRevocationError::InvalidPolicy);
225 }
226 if let Some(login) = record.login() {
227 let minimum = login
228 .deadline_ns
229 .checked_add(self.skew_ns)
230 .ok_or(LoginRevocationError::Clock)?;
231 if login.started_at_ns > observed_ns
232 || record.until_ns < minimum
233 || (!record.revoked() && record.until_ns != minimum)
234 {
235 return Err(LoginRevocationError::InvalidPolicy);
236 }
237 }
238 let (used, expected) = if record.admitted_pool() {
240 (self.admitted, expected_counts.0)
241 } else {
242 (self.unknown, expected_counts.1)
243 };
244 if used >= expected {
245 return Err(LoginRevocationError::Capacity);
246 }
247 self.install(change.caller, record);
248 }
249 if self.occupancy() != expected_counts {
250 return Err(LoginRevocationError::InvalidPolicy);
251 }
252 Ok(self)
253 }
254
255 pub fn new(
258 audience: AudienceId,
259 max_admitted: usize,
260 max_unknown: usize,
261 skew_ns: u64,
262 now_ns: u64,
263 ) -> Result<Self, LoginRevocationError> {
264 if max_admitted == 0 || max_unknown == 0 {
265 return Err(LoginRevocationError::InvalidPolicy);
266 }
267 retention(now_ns, skew_ns)?;
268 Ok(Self {
269 audience,
270 records: BTreeMap::new(),
271 expiry: BTreeSet::new(),
272 max_admitted,
273 max_unknown,
274 admitted: 0,
275 unknown: 0,
276 skew_ns,
277 observed_ns: now_ns,
278 })
279 }
280
281 #[cfg(feature = "sessions")]
282 pub(crate) fn audience(&self) -> AudienceId {
283 self.audience
284 }
285
286 #[cfg(feature = "sessions")]
290 pub(crate) fn observe_login(
291 &mut self,
292 caller: Principal,
293 now_ns: u64,
294 ) -> Result<Option<LoginBinding>, LoginRevocationError> {
295 self.observe(caller, now_ns)?;
296 let record = self.records.get(&caller).filter(|r| r.until_ns > now_ns);
297 if record.is_some_and(|r| r.revoked()) {
298 return Err(LoginRevocationError::Revoked);
299 }
300 Ok(record.and_then(|r| r.login()))
301 }
302
303 pub fn occupancy(&self) -> (usize, usize) {
305 (self.admitted, self.unknown)
306 }
307
308 pub fn observed_at_ns(&self) -> u64 {
311 self.observed_ns
312 }
313
314 pub fn check_reservation(
321 &mut self,
322 caller: Principal,
323 claims: &PortableClaims,
324 now_ns: u64,
325 ) -> Result<(), LoginRevocationError> {
326 self.reservation(caller, claims, now_ns).map(|_| ())
327 }
328
329 #[cfg(all(
330 feature = "sessions",
331 any(
332 feature = "portable-signature-verification",
333 feature = "certified-proof-verification"
334 )
335 ))]
336 pub(crate) fn check_prepared_reservation(
337 &mut self,
338 caller: Principal,
339 prepared: &super::ContextualClaims<'_, '_>,
340 now_ns: u64,
341 ) -> Result<(), LoginRevocationError> {
342 self.observe(caller, now_ns)?;
343 self.reservation_checked(
344 caller,
345 &prepared.decoded().claims,
346 prepared.browser_caller(),
347 now_ns,
348 )
349 .map(|_| ())
350 }
351
352 pub fn with_reservation<T, E: From<LoginRevocationError>>(
362 &mut self,
363 caller: Principal,
364 verified: &VerifiedPortableClaims,
365 now_ns: u64,
366 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
367 ) -> Result<T, E> {
368 self.observe(caller, now_ns)?;
369 let record =
370 self.reservation_checked(caller, verified.claims(), verified.browser_caller(), now_ns)?;
371 if verified.verified_at_ns() != now_ns {
372 return Err(LoginRevocationError::InvalidPolicy.into());
373 }
374 self.commit_record(caller, record, commit)
375 }
376
377 pub fn with_revocation<E: From<LoginRevocationError>>(
392 &mut self,
393 unknown_budget: &mut SessionAdmissionBudget,
394 caller: Principal,
395 now_ns: u64,
396 commit: impl FnOnce(&LoginRevocationChange) -> Result<(), E>,
397 ) -> Result<u64, E> {
398 self.observe(caller, now_ns)?;
399 let old = self
400 .records
401 .get(&caller)
402 .copied()
403 .filter(|r| r.until_ns > now_ns);
404 let record = if let Some(old) = old.filter(|r| r.revoked()) {
405 old
406 } else {
407 let until_ns = retention(now_ns, self.skew_ns)?;
408 if old.is_some_and(|r| r.until_ns > until_ns) {
409 return Err(LoginRevocationError::Clock.into());
410 }
411 Record {
412 until_ns,
413 state: LoginRecordState::Revoked(old.and_then(Record::login)),
414 }
415 };
416 self.capacity(caller, record)?;
417 if old.is_none() {
418 unknown_budget
419 .consume(now_ns)
420 .map_err(|error| match error {
421 AdmissionBudgetError::Exhausted => LoginRevocationError::Capacity,
422 AdmissionBudgetError::ClockRollback => LoginRevocationError::Clock,
423 AdmissionBudgetError::InvalidPolicy => LoginRevocationError::InvalidPolicy,
424 })?;
425 }
426 self.commit_record(caller, record, commit)?;
427 Ok(record.until_ns)
428 }
429
430 fn commit_record<T, E>(
433 &mut self,
434 caller: Principal,
435 record: Record,
436 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
437 ) -> Result<T, E> {
438 let value = commit(&LoginRevocationChange {
439 audience: self.audience,
440 caller,
441 record,
442 })?;
443 self.install(caller, record);
444 Ok(value)
445 }
446
447 pub fn is_revoked(
450 &mut self,
451 caller: Principal,
452 now_ns: u64,
453 ) -> Result<bool, LoginRevocationError> {
454 self.observe(caller, now_ns)?;
455 Ok(self
456 .records
457 .get(&caller)
458 .is_some_and(|r| r.revoked() && r.until_ns > now_ns))
459 }
460
461 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
464 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
465 }
466 pub fn prune_with_commit(
471 &mut self,
472 now_ns: u64,
473 budget: usize,
474 commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
475 ) -> Result<usize, LoginRevocationError> {
476 self.clock(now_ns)?;
477 let callers: Vec<_> = self
478 .expiry
479 .iter()
480 .take_while(|(until, _)| *until <= now_ns)
481 .take(budget.min(128))
482 .map(|(_, caller)| *caller)
483 .collect();
484 if callers.is_empty() {
485 return Ok(0);
486 }
487 let mut counts = self.occupancy();
488 for caller in &callers {
489 if self.records[caller].admitted_pool() {
490 counts.0 -= 1;
491 } else {
492 counts.1 -= 1;
493 }
494 }
495 commit(&callers, counts)?;
496 for caller in &callers {
497 self.remove(*caller);
498 }
499 Ok(callers.len())
500 }
501
502 fn reservation(
503 &mut self,
504 caller: Principal,
505 claims: &PortableClaims,
506 now_ns: u64,
507 ) -> Result<Record, LoginRevocationError> {
508 self.observe(caller, now_ns)?;
509 validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
510 self.reservation_checked(
511 caller,
512 claims,
513 browser_principal(&claims.browser_public_key),
514 now_ns,
515 )
516 }
517
518 fn reservation_checked(
521 &self,
522 caller: Principal,
523 claims: &PortableClaims,
524 browser_caller: Principal,
525 now_ns: u64,
526 ) -> Result<Record, LoginRevocationError> {
527 let login_deadline_ns = claims.login_deadline_ns;
528 if claims.audience != self.audience || browser_caller != caller {
529 return Err(LoginRevocationError::InvalidPolicy);
530 }
531 let until_ns = login_deadline_ns
532 .checked_add(self.skew_ns)
533 .ok_or(LoginRevocationError::Clock)?;
534 let login = LoginBinding {
535 issuer: claims.issuer,
536 subject: claims.account_subject,
537 started_at_ns: claims.login_started_at_ns,
538 deadline_ns: claims.login_deadline_ns,
539 };
540 if let Some(old) = self.records.get(&caller).filter(|r| r.until_ns > now_ns) {
541 if old.revoked() {
542 return Err(LoginRevocationError::Revoked);
543 }
544 if old.login() != Some(login) {
545 return Err(LoginRevocationError::InvalidPolicy);
546 }
547 }
548 let record = Record {
549 until_ns,
550 state: LoginRecordState::Reserved(login),
551 };
552 self.capacity(caller, record)?;
553 Ok(record)
554 }
555
556 fn clock(&mut self, now_ns: u64) -> Result<(), LoginRevocationError> {
557 if now_ns < self.observed_ns {
558 return Err(LoginRevocationError::Clock);
559 }
560 self.observed_ns = now_ns;
561 Ok(())
562 }
563
564 fn observe(&mut self, caller: Principal, now_ns: u64) -> Result<(), LoginRevocationError> {
565 if !is_login_caller(caller) {
566 return Err(LoginRevocationError::InvalidCaller);
567 }
568 self.clock(now_ns)
569 }
570
571 fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
572 let reuses_pool = self
573 .records
574 .get(&caller)
575 .is_some_and(|old| old.admitted_pool() == record.admitted_pool());
576 let (count, limit) = if record.admitted_pool() {
577 (self.admitted, self.max_admitted)
578 } else {
579 (self.unknown, self.max_unknown)
580 };
581 if !reuses_pool && count >= limit {
582 return Err(LoginRevocationError::Capacity);
583 }
584 Ok(())
585 }
586
587 fn remove(&mut self, caller: Principal) {
588 if let Some(old) = self.records.remove(&caller) {
589 self.expiry.remove(&(old.until_ns, caller));
590 if old.admitted_pool() {
591 self.admitted -= 1;
592 } else {
593 self.unknown -= 1;
594 }
595 }
596 }
597
598 fn install(&mut self, caller: Principal, record: Record) {
599 self.remove(caller);
600 if record.admitted_pool() {
601 self.admitted += 1;
602 } else {
603 self.unknown += 1;
604 }
605 self.expiry.insert((record.until_ns, caller));
606 self.records.insert(caller, record);
607 }
608}
609
610fn retention(now_ns: u64, skew_ns: u64) -> Result<u64, LoginRevocationError> {
611 skew_ns
612 .checked_mul(2)
613 .and_then(|skew| crate::limits::MAX_LOGIN_NS.checked_add(skew))
614 .and_then(|ttl| now_ns.checked_add(ttl))
615 .ok_or(LoginRevocationError::Clock)
616}