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 #[cfg(feature = "sessions")]
101 pub fn encode(&self) -> Result<Vec<u8>, LoginRevocationError> {
102 crate::storage_codec::encode(
103 &StoredLogin {
104 audience: self.audience,
105 caller: self.caller,
106 record: self.record,
107 },
108 crate::storage_codec::LOGIN,
109 MAX_RECORD_BYTES,
110 )
111 .map_err(|error| match error {
112 ic_wire::Error::Limit | ic_wire::Error::Allocation => {
113 LoginRevocationError::InvalidPolicy
114 }
115 _ => LoginRevocationError::Storage,
116 })
117 }
118
119 #[cfg(feature = "session-restore")]
122 pub fn decode(bytes: &[u8]) -> Result<Self, LoginRevocationError> {
123 let StoredLogin {
124 audience,
125 caller,
126 record,
127 } = crate::storage_codec::decode(bytes, crate::storage_codec::LOGIN, MAX_RECORD_BYTES)
128 .map_err(|_| LoginRevocationError::InvalidPolicy)?;
129 if !is_login_caller(caller) {
130 return Err(LoginRevocationError::InvalidCaller);
131 }
132 if let Some(login) = record.login()
133 && (login.issuer == Principal::anonymous()
134 || login.subject == Principal::anonymous()
135 || login.started_at_ns >= login.deadline_ns
136 || login.deadline_ns - login.started_at_ns > crate::limits::MAX_LOGIN_NS)
137 {
138 return Err(LoginRevocationError::InvalidPolicy);
139 }
140 let change = Self {
141 audience,
142 caller,
143 record,
144 };
145 Ok(change)
146 }
147
148 pub fn audience(&self) -> AudienceId {
150 self.audience
151 }
152 pub fn caller(&self) -> Principal {
154 self.caller
155 }
156 pub fn until_ns(&self) -> u64 {
158 self.record.until_ns
159 }
160 pub fn state(&self) -> LoginRecordState {
163 self.record.state
164 }
165}
166
167pub struct MemoryLoginRevocations {
187 audience: AudienceId,
188 records: BTreeMap<Principal, Record>,
189 expiry: BTreeSet<(u64, Principal)>,
190 max_admitted: usize,
191 max_unknown: usize,
192 admitted: usize,
193 unknown: usize,
194 skew_ns: u64,
195 observed_ns: u64,
196}
197
198impl MemoryLoginRevocations {
199 #[cfg(feature = "session-restore")]
203 pub fn records(&self) -> impl ExactSizeIterator<Item = LoginRevocationChange> + '_ {
204 self.records
205 .iter()
206 .map(|(&caller, &record)| LoginRevocationChange {
207 audience: self.audience,
208 caller,
209 record,
210 })
211 }
212
213 #[cfg(feature = "session-restore")]
222 pub fn restore_records(
223 mut self,
224 observed_ns: u64,
225 expected_counts: (usize, usize),
226 records: impl IntoIterator<Item = LoginRevocationChange>,
227 ) -> Result<Self, LoginRevocationError> {
228 if !self.records.is_empty()
229 || observed_ns > self.observed_ns
230 || expected_counts.0 > self.max_admitted
231 || expected_counts.1 > self.max_unknown
232 {
233 return Err(LoginRevocationError::InvalidPolicy);
234 }
235 let max_until = retention(observed_ns, self.skew_ns)?;
236 for change in records {
237 if change.audience != self.audience || self.records.contains_key(&change.caller) {
238 return Err(LoginRevocationError::InvalidPolicy);
239 }
240 let record = change.record;
241 if record.until_ns == 0 || record.until_ns > max_until {
242 return Err(LoginRevocationError::InvalidPolicy);
243 }
244 if let Some(login) = record.login() {
245 let minimum = login
246 .deadline_ns
247 .checked_add(self.skew_ns)
248 .ok_or(LoginRevocationError::Clock)?;
249 if login.started_at_ns > observed_ns
250 || record.until_ns < minimum
251 || (!record.revoked() && record.until_ns != minimum)
252 {
253 return Err(LoginRevocationError::InvalidPolicy);
254 }
255 }
256 let (used, expected) = if record.admitted_pool() {
258 (self.admitted, expected_counts.0)
259 } else {
260 (self.unknown, expected_counts.1)
261 };
262 if used >= expected {
263 return Err(LoginRevocationError::Capacity);
264 }
265 self.install(change.caller, record);
266 }
267 if self.occupancy() != expected_counts {
268 return Err(LoginRevocationError::InvalidPolicy);
269 }
270 Ok(self)
271 }
272
273 pub fn new(
276 audience: AudienceId,
277 max_admitted: usize,
278 max_unknown: usize,
279 skew_ns: u64,
280 now_ns: u64,
281 ) -> Result<Self, LoginRevocationError> {
282 if max_admitted == 0 || max_unknown == 0 {
283 return Err(LoginRevocationError::InvalidPolicy);
284 }
285 retention(now_ns, skew_ns)?;
286 Ok(Self {
287 audience,
288 records: BTreeMap::new(),
289 expiry: BTreeSet::new(),
290 max_admitted,
291 max_unknown,
292 admitted: 0,
293 unknown: 0,
294 skew_ns,
295 observed_ns: now_ns,
296 })
297 }
298
299 #[cfg(feature = "sessions")]
300 pub(crate) fn audience(&self) -> AudienceId {
301 self.audience
302 }
303
304 #[cfg(feature = "sessions")]
308 pub(crate) fn observe_login(
309 &mut self,
310 caller: Principal,
311 now_ns: u64,
312 ) -> Result<Option<LoginBinding>, LoginRevocationError> {
313 self.observe(caller, now_ns)?;
314 let record = self.records.get(&caller).filter(|r| r.until_ns > now_ns);
315 if record.is_some_and(|r| r.revoked()) {
316 return Err(LoginRevocationError::Revoked);
317 }
318 Ok(record.and_then(|r| r.login()))
319 }
320
321 pub fn occupancy(&self) -> (usize, usize) {
323 (self.admitted, self.unknown)
324 }
325
326 pub fn observed_at_ns(&self) -> u64 {
329 self.observed_ns
330 }
331
332 pub fn check_reservation(
339 &mut self,
340 caller: Principal,
341 claims: &PortableClaims,
342 now_ns: u64,
343 ) -> Result<(), LoginRevocationError> {
344 self.reservation(caller, claims, now_ns).map(|_| ())
345 }
346
347 #[cfg(all(
348 feature = "sessions",
349 any(
350 feature = "portable-signature-verification",
351 feature = "certified-proof-verification"
352 )
353 ))]
354 pub(crate) fn check_prepared_reservation(
355 &mut self,
356 caller: Principal,
357 prepared: &super::ContextualClaims<'_, '_>,
358 now_ns: u64,
359 ) -> Result<(), LoginRevocationError> {
360 self.observe(caller, now_ns)?;
361 self.reservation_checked(
362 caller,
363 &prepared.decoded().claims,
364 prepared.browser_caller(),
365 now_ns,
366 )
367 .map(|_| ())
368 }
369
370 pub fn with_reservation<T, E: From<LoginRevocationError>>(
380 &mut self,
381 caller: Principal,
382 verified: &VerifiedPortableClaims,
383 now_ns: u64,
384 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
385 ) -> Result<T, E> {
386 self.observe(caller, now_ns)?;
387 let record =
388 self.reservation_checked(caller, verified.claims(), verified.browser_caller(), now_ns)?;
389 if verified.verified_at_ns() != now_ns {
390 return Err(LoginRevocationError::InvalidPolicy.into());
391 }
392 self.commit_record(caller, record, commit)
393 }
394
395 pub fn with_revocation<E: From<LoginRevocationError>>(
410 &mut self,
411 unknown_budget: &mut SessionAdmissionBudget,
412 caller: Principal,
413 now_ns: u64,
414 commit: impl FnOnce(&LoginRevocationChange) -> Result<(), E>,
415 ) -> Result<u64, E> {
416 self.observe(caller, now_ns)?;
417 let old = self
418 .records
419 .get(&caller)
420 .copied()
421 .filter(|r| r.until_ns > now_ns);
422 let record = if let Some(old) = old.filter(|r| r.revoked()) {
423 old
424 } else {
425 let until_ns = retention(now_ns, self.skew_ns)?;
426 if old.is_some_and(|r| r.until_ns > until_ns) {
427 return Err(LoginRevocationError::Clock.into());
428 }
429 Record {
430 until_ns,
431 state: LoginRecordState::Revoked(old.and_then(Record::login)),
432 }
433 };
434 self.capacity(caller, record)?;
435 if old.is_none() {
436 unknown_budget
437 .consume(now_ns)
438 .map_err(|error| match error {
439 AdmissionBudgetError::Exhausted => LoginRevocationError::Capacity,
440 AdmissionBudgetError::ClockRollback => LoginRevocationError::Clock,
441 AdmissionBudgetError::InvalidPolicy => LoginRevocationError::InvalidPolicy,
442 })?;
443 }
444 self.commit_record(caller, record, commit)?;
445 Ok(record.until_ns)
446 }
447
448 fn commit_record<T, E>(
451 &mut self,
452 caller: Principal,
453 record: Record,
454 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
455 ) -> Result<T, E> {
456 let value = commit(&LoginRevocationChange {
457 audience: self.audience,
458 caller,
459 record,
460 })?;
461 self.install(caller, record);
462 Ok(value)
463 }
464
465 pub fn is_revoked(
468 &mut self,
469 caller: Principal,
470 now_ns: u64,
471 ) -> Result<bool, LoginRevocationError> {
472 self.observe(caller, now_ns)?;
473 Ok(self
474 .records
475 .get(&caller)
476 .is_some_and(|r| r.revoked() && r.until_ns > now_ns))
477 }
478
479 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
482 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
483 }
484 pub fn prune_with_commit(
489 &mut self,
490 now_ns: u64,
491 budget: usize,
492 commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
493 ) -> Result<usize, LoginRevocationError> {
494 self.clock(now_ns)?;
495 let mut callers = Vec::new();
496 let mut counts = self.occupancy();
497 for (_, caller) in self
498 .expiry
499 .iter()
500 .take_while(|(until, _)| *until <= now_ns)
501 .take(budget.min(128))
502 {
503 if self.records[caller].admitted_pool() {
504 counts.0 -= 1;
505 } else {
506 counts.1 -= 1;
507 }
508 callers.push(*caller);
509 }
510 if callers.is_empty() {
511 return Ok(0);
512 }
513 commit(&callers, counts)?;
514 for caller in &callers {
515 self.remove(*caller);
516 }
517 Ok(callers.len())
518 }
519
520 fn reservation(
521 &mut self,
522 caller: Principal,
523 claims: &PortableClaims,
524 now_ns: u64,
525 ) -> Result<Record, LoginRevocationError> {
526 self.observe(caller, now_ns)?;
527 validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
528 self.reservation_checked(
529 caller,
530 claims,
531 browser_principal(&claims.browser_public_key),
532 now_ns,
533 )
534 }
535
536 fn reservation_checked(
539 &self,
540 caller: Principal,
541 claims: &PortableClaims,
542 browser_caller: Principal,
543 now_ns: u64,
544 ) -> Result<Record, LoginRevocationError> {
545 let login_deadline_ns = claims.login_deadline_ns;
546 if claims.audience != self.audience || browser_caller != caller {
547 return Err(LoginRevocationError::InvalidPolicy);
548 }
549 let until_ns = login_deadline_ns
550 .checked_add(self.skew_ns)
551 .ok_or(LoginRevocationError::Clock)?;
552 let login = LoginBinding {
553 issuer: claims.issuer,
554 subject: claims.account_subject,
555 started_at_ns: claims.login_started_at_ns,
556 deadline_ns: claims.login_deadline_ns,
557 };
558 if let Some(old) = self.records.get(&caller).filter(|r| r.until_ns > now_ns) {
559 if old.revoked() {
560 return Err(LoginRevocationError::Revoked);
561 }
562 if old.login() != Some(login) {
563 return Err(LoginRevocationError::InvalidPolicy);
564 }
565 }
566 let record = Record {
567 until_ns,
568 state: LoginRecordState::Reserved(login),
569 };
570 self.capacity(caller, record)?;
571 Ok(record)
572 }
573
574 fn clock(&mut self, now_ns: u64) -> Result<(), LoginRevocationError> {
575 if now_ns < self.observed_ns {
576 return Err(LoginRevocationError::Clock);
577 }
578 self.observed_ns = now_ns;
579 Ok(())
580 }
581
582 fn observe(&mut self, caller: Principal, now_ns: u64) -> Result<(), LoginRevocationError> {
583 if !is_login_caller(caller) {
584 return Err(LoginRevocationError::InvalidCaller);
585 }
586 self.clock(now_ns)
587 }
588
589 fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
590 let reuses_pool = self
591 .records
592 .get(&caller)
593 .is_some_and(|old| old.admitted_pool() == record.admitted_pool());
594 let (count, limit) = if record.admitted_pool() {
595 (self.admitted, self.max_admitted)
596 } else {
597 (self.unknown, self.max_unknown)
598 };
599 if !reuses_pool && count >= limit {
600 return Err(LoginRevocationError::Capacity);
601 }
602 Ok(())
603 }
604
605 fn remove(&mut self, caller: Principal) {
606 if let Some(old) = self.records.remove(&caller) {
607 self.expiry.remove(&(old.until_ns, caller));
608 if old.admitted_pool() {
609 self.admitted -= 1;
610 } else {
611 self.unknown -= 1;
612 }
613 }
614 }
615
616 fn install(&mut self, caller: Principal, record: Record) {
617 self.remove(caller);
618 if record.admitted_pool() {
619 self.admitted += 1;
620 } else {
621 self.unknown += 1;
622 }
623 self.expiry.insert((record.until_ns, caller));
624 self.records.insert(caller, record);
625 }
626}
627
628fn retention(now_ns: u64, skew_ns: u64) -> Result<u64, LoginRevocationError> {
629 skew_ns
630 .checked_mul(2)
631 .and_then(|skew| crate::limits::MAX_LOGIN_NS.checked_add(skew))
632 .and_then(|ttl| now_ns.checked_add(ttl))
633 .ok_or(LoginRevocationError::Clock)
634}