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_decoded_reservation(
337 &mut self,
338 caller: Principal,
339 decoded: &super::DecodedPortableClaims,
340 now_ns: u64,
341 ) -> Result<(), LoginRevocationError> {
342 self.observe(caller, now_ns)?;
343 self.reservation_checked(caller, &decoded.claims, now_ns)
344 .map(|_| ())
345 }
346
347 pub fn with_reservation<T, E: From<LoginRevocationError>>(
357 &mut self,
358 caller: Principal,
359 verified: &VerifiedPortableClaims,
360 now_ns: u64,
361 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
362 ) -> Result<T, E> {
363 self.observe(caller, now_ns)?;
364 let record = self.reservation_checked(caller, verified.claims(), now_ns)?;
365 if verified.verified_at_ns() != now_ns {
366 return Err(LoginRevocationError::InvalidPolicy.into());
367 }
368 let value = commit(&LoginRevocationChange {
369 audience: self.audience,
370 caller,
371 record,
372 })?;
373 self.install(caller, record);
374 Ok(value)
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 commit(&LoginRevocationChange {
427 audience: self.audience,
428 caller,
429 record,
430 })?;
431 self.install(caller, record);
432 Ok(record.until_ns)
433 }
434
435 pub fn is_revoked(
438 &mut self,
439 caller: Principal,
440 now_ns: u64,
441 ) -> Result<bool, LoginRevocationError> {
442 self.observe(caller, now_ns)?;
443 Ok(self
444 .records
445 .get(&caller)
446 .is_some_and(|r| r.revoked() && r.until_ns > now_ns))
447 }
448
449 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
452 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
453 }
454 pub fn prune_with_commit(
459 &mut self,
460 now_ns: u64,
461 budget: usize,
462 commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
463 ) -> Result<usize, LoginRevocationError> {
464 self.clock(now_ns)?;
465 let callers: Vec<_> = self
466 .expiry
467 .iter()
468 .take_while(|(until, _)| *until <= now_ns)
469 .take(budget.min(128))
470 .map(|(_, caller)| *caller)
471 .collect();
472 if callers.is_empty() {
473 return Ok(0);
474 }
475 let mut counts = self.occupancy();
476 for caller in &callers {
477 if self.records[caller].admitted_pool() {
478 counts.0 -= 1;
479 } else {
480 counts.1 -= 1;
481 }
482 }
483 commit(&callers, counts)?;
484 for caller in &callers {
485 self.remove(*caller);
486 }
487 Ok(callers.len())
488 }
489
490 fn reservation(
491 &mut self,
492 caller: Principal,
493 claims: &PortableClaims,
494 now_ns: u64,
495 ) -> Result<Record, LoginRevocationError> {
496 self.observe(caller, now_ns)?;
497 validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
498 self.reservation_checked(caller, claims, now_ns)
499 }
500
501 fn reservation_checked(
504 &self,
505 caller: Principal,
506 claims: &PortableClaims,
507 now_ns: u64,
508 ) -> Result<Record, LoginRevocationError> {
509 let login_deadline_ns = claims.login_deadline_ns;
510 if claims.audience != self.audience
511 || browser_principal(&claims.browser_public_key) != caller
512 {
513 return Err(LoginRevocationError::InvalidPolicy);
514 }
515 let until_ns = login_deadline_ns
516 .checked_add(self.skew_ns)
517 .ok_or(LoginRevocationError::Clock)?;
518 let login = LoginBinding {
519 issuer: claims.issuer,
520 subject: claims.account_subject,
521 started_at_ns: claims.login_started_at_ns,
522 deadline_ns: claims.login_deadline_ns,
523 };
524 if let Some(old) = self.records.get(&caller).filter(|r| r.until_ns > now_ns) {
525 if old.revoked() {
526 return Err(LoginRevocationError::Revoked);
527 }
528 if old.login() != Some(login) {
529 return Err(LoginRevocationError::InvalidPolicy);
530 }
531 }
532 let record = Record {
533 until_ns,
534 state: LoginRecordState::Reserved(login),
535 };
536 self.capacity(caller, record)?;
537 Ok(record)
538 }
539
540 fn clock(&mut self, now_ns: u64) -> Result<(), LoginRevocationError> {
541 if now_ns < self.observed_ns {
542 return Err(LoginRevocationError::Clock);
543 }
544 self.observed_ns = now_ns;
545 Ok(())
546 }
547
548 fn observe(&mut self, caller: Principal, now_ns: u64) -> Result<(), LoginRevocationError> {
549 if !is_login_caller(caller) {
550 return Err(LoginRevocationError::InvalidCaller);
551 }
552 self.clock(now_ns)
553 }
554
555 fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
556 let reuses_pool = self
557 .records
558 .get(&caller)
559 .is_some_and(|old| old.admitted_pool() == record.admitted_pool());
560 let (count, limit) = if record.admitted_pool() {
561 (self.admitted, self.max_admitted)
562 } else {
563 (self.unknown, self.max_unknown)
564 };
565 if !reuses_pool && count >= limit {
566 return Err(LoginRevocationError::Capacity);
567 }
568 Ok(())
569 }
570
571 fn remove(&mut self, caller: Principal) {
572 if let Some(old) = self.records.remove(&caller) {
573 self.expiry.remove(&(old.until_ns, caller));
574 if old.admitted_pool() {
575 self.admitted -= 1;
576 } else {
577 self.unknown -= 1;
578 }
579 }
580 }
581
582 fn install(&mut self, caller: Principal, record: Record) {
583 self.remove(caller);
584 if record.admitted_pool() {
585 self.admitted += 1;
586 } else {
587 self.unknown += 1;
588 }
589 self.expiry.insert((record.until_ns, caller));
590 self.records.insert(caller, record);
591 }
592}
593
594fn retention(now_ns: u64, skew_ns: u64) -> Result<u64, LoginRevocationError> {
595 skew_ns
596 .checked_mul(2)
597 .and_then(|skew| crate::limits::MAX_LOGIN_NS.checked_add(skew))
598 .and_then(|ttl| now_ns.checked_add(ttl))
599 .ok_or(LoginRevocationError::Clock)
600}