1use super::{VerifiedPortableClaims, browser_principal, 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 RECORD_DOMAIN: &[u8] = b"IC-AUTH-LOGIN-RECORD\0";
11#[cfg(feature = "sessions")]
12const MAX_RECORD_BYTES: usize = 1024;
13
14#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
16pub enum LoginRevocationError {
17 #[error("logout requires an authenticated self-authenticating caller")]
18 InvalidCaller,
19 #[error("invalid revocation limits or authenticated login deadline")]
20 InvalidPolicy,
21 #[error("login is locally revoked")]
22 Revoked,
23 #[error("revocation capacity is exhausted")]
24 Capacity,
25 #[error("revocation clock rolled back or retention arithmetic overflowed")]
26 Clock,
27 #[error("host session/replay transaction failed")]
28 Storage,
29}
30
31#[derive(Clone, Copy)]
32#[cfg_attr(feature = "sessions", derive(serde::Serialize))]
33#[cfg_attr(feature = "sessions", serde(into = "RecordFields"))]
34struct Record {
35 until_ns: u64,
36 state: LoginRecordState,
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
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#[cfg(feature = "sessions")]
68#[derive(serde::Serialize)]
69#[cfg_attr(feature = "session-restore", derive(serde::Deserialize))]
70#[serde(deny_unknown_fields)]
71struct RecordFields {
72 until_ns: u64,
73 login: Option<LoginBinding>,
74 revoked: bool,
75 admitted_pool: bool,
76}
77
78#[cfg(feature = "sessions")]
79impl From<Record> for RecordFields {
80 fn from(record: Record) -> Self {
81 Self {
82 until_ns: record.until_ns,
83 login: record.login(),
84 revoked: record.revoked(),
85 admitted_pool: record.admitted_pool(),
86 }
87 }
88}
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91#[cfg_attr(feature = "sessions", derive(serde::Serialize))]
92#[cfg_attr(feature = "session-restore", derive(serde::Deserialize))]
93#[cfg_attr(feature = "sessions", serde(deny_unknown_fields))]
94pub struct LoginBinding {
97 pub issuer: Principal,
98 pub subject: Principal,
99 pub started_at_ns: u64,
100 pub deadline_ns: u64,
101}
102
103pub struct LoginRevocationChange {
107 audience: AudienceId,
108 caller: Principal,
109 record: Record,
110}
111
112impl LoginRevocationChange {
113 #[cfg(feature = "sessions")]
115 pub fn encode(&self) -> Result<Vec<u8>, LoginRevocationError> {
116 crate::storage_codec::encode(
117 &(self.audience, self.caller, self.record),
118 RECORD_DOMAIN,
119 MAX_RECORD_BYTES,
120 LoginRevocationError::Storage,
121 LoginRevocationError::InvalidPolicy,
122 )
123 }
124
125 #[cfg(feature = "session-restore")]
128 pub fn decode(bytes: &[u8]) -> Result<Self, LoginRevocationError> {
129 let (audience, caller, fields): (_, Principal, RecordFields) =
130 crate::storage_codec::decode(
131 bytes,
132 RECORD_DOMAIN,
133 MAX_RECORD_BYTES,
134 || LoginRevocationError::InvalidPolicy,
135 LoginRevocationError::InvalidPolicy,
136 )?;
137 let caller_bytes = caller.as_slice();
138 if caller_bytes.len() != 29 || caller_bytes.last() != Some(&2) {
139 return Err(LoginRevocationError::InvalidCaller);
140 }
141 let state = match fields.login {
142 Some(login)
143 if fields.admitted_pool
144 && login.issuer != Principal::anonymous()
145 && login.subject != Principal::anonymous()
146 && login.started_at_ns < login.deadline_ns
147 && login.deadline_ns - login.started_at_ns <= crate::limits::MAX_LOGIN_NS =>
148 {
149 if fields.revoked {
150 LoginRecordState::Revoked(Some(login))
151 } else {
152 LoginRecordState::Reserved(login)
153 }
154 }
155 None if !fields.admitted_pool && fields.revoked => LoginRecordState::Revoked(None),
156 _ => return Err(LoginRevocationError::InvalidPolicy),
157 };
158 let change = Self {
159 audience,
160 caller,
161 record: Record {
162 until_ns: fields.until_ns,
163 state,
164 },
165 };
166 Ok(change)
167 }
168
169 pub fn audience(&self) -> AudienceId {
171 self.audience
172 }
173 pub fn caller(&self) -> Principal {
175 self.caller
176 }
177 pub fn until_ns(&self) -> u64 {
179 self.record.until_ns
180 }
181 pub fn state(&self) -> LoginRecordState {
184 self.record.state
185 }
186}
187
188pub struct MemoryLoginRevocations {
208 audience: AudienceId,
209 records: BTreeMap<Principal, Record>,
210 expiry: BTreeSet<(u64, Principal)>,
211 max_admitted: usize,
212 max_unknown: usize,
213 admitted: usize,
214 unknown: usize,
215 skew_ns: u64,
216 observed_ns: u64,
217}
218
219impl MemoryLoginRevocations {
220 #[cfg(feature = "session-restore")]
224 pub fn records(&self) -> impl ExactSizeIterator<Item = LoginRevocationChange> + '_ {
225 self.records
226 .iter()
227 .map(|(&caller, &record)| LoginRevocationChange {
228 audience: self.audience,
229 caller,
230 record,
231 })
232 }
233
234 #[cfg(feature = "session-restore")]
243 pub fn restore_records(
244 mut self,
245 observed_ns: u64,
246 expected_counts: (usize, usize),
247 records: impl IntoIterator<Item = LoginRevocationChange>,
248 ) -> Result<Self, LoginRevocationError> {
249 if !self.records.is_empty()
250 || observed_ns > self.observed_ns
251 || expected_counts.0 > self.max_admitted
252 || expected_counts.1 > self.max_unknown
253 {
254 return Err(LoginRevocationError::InvalidPolicy);
255 }
256 let max_until = retention(observed_ns, self.skew_ns)?;
257 for change in records {
258 if change.audience != self.audience || self.records.contains_key(&change.caller) {
259 return Err(LoginRevocationError::InvalidPolicy);
260 }
261 let record = change.record;
262 if record.until_ns == 0 || record.until_ns > max_until {
263 return Err(LoginRevocationError::InvalidPolicy);
264 }
265 if let Some(login) = record.login() {
266 let minimum = login
267 .deadline_ns
268 .checked_add(self.skew_ns)
269 .ok_or(LoginRevocationError::Clock)?;
270 if login.started_at_ns > observed_ns
271 || record.until_ns < minimum
272 || (!record.revoked() && record.until_ns != minimum)
273 {
274 return Err(LoginRevocationError::InvalidPolicy);
275 }
276 }
277 let (used, expected) = if record.admitted_pool() {
279 (self.admitted, expected_counts.0)
280 } else {
281 (self.unknown, expected_counts.1)
282 };
283 if used >= expected {
284 return Err(LoginRevocationError::Capacity);
285 }
286 self.install(change.caller, record);
287 }
288 if self.occupancy() != expected_counts {
289 return Err(LoginRevocationError::InvalidPolicy);
290 }
291 Ok(self)
292 }
293
294 pub fn new(
297 audience: AudienceId,
298 max_admitted: usize,
299 max_unknown: usize,
300 skew_ns: u64,
301 now_ns: u64,
302 ) -> Result<Self, LoginRevocationError> {
303 if max_admitted == 0 || max_unknown == 0 {
304 return Err(LoginRevocationError::InvalidPolicy);
305 }
306 retention(now_ns, skew_ns)?;
307 Ok(Self {
308 audience,
309 records: BTreeMap::new(),
310 expiry: BTreeSet::new(),
311 max_admitted,
312 max_unknown,
313 admitted: 0,
314 unknown: 0,
315 skew_ns,
316 observed_ns: now_ns,
317 })
318 }
319
320 #[cfg(feature = "sessions")]
321 pub(crate) fn audience(&self) -> AudienceId {
322 self.audience
323 }
324
325 #[cfg(feature = "sessions")]
329 pub(crate) fn observe_login(
330 &mut self,
331 caller: Principal,
332 now_ns: u64,
333 ) -> Result<Option<LoginBinding>, LoginRevocationError> {
334 self.observe(caller, now_ns)?;
335 let record = self.records.get(&caller).filter(|r| r.until_ns > now_ns);
336 if record.is_some_and(|r| r.revoked()) {
337 return Err(LoginRevocationError::Revoked);
338 }
339 Ok(record.and_then(|r| r.login()))
340 }
341
342 pub fn occupancy(&self) -> (usize, usize) {
344 (self.admitted, self.unknown)
345 }
346
347 pub fn observed_at_ns(&self) -> u64 {
350 self.observed_ns
351 }
352
353 pub fn check_reservation(
360 &mut self,
361 caller: Principal,
362 claims: &PortableClaims,
363 now_ns: u64,
364 ) -> Result<(), LoginRevocationError> {
365 self.reservation(caller, claims, now_ns).map(|_| ())
366 }
367
368 #[cfg(all(
369 feature = "sessions",
370 any(
371 feature = "portable-signature-verification",
372 feature = "certified-proof-verification"
373 )
374 ))]
375 pub(crate) fn check_decoded_reservation(
376 &mut self,
377 caller: Principal,
378 decoded: &super::DecodedPortableClaims,
379 now_ns: u64,
380 ) -> Result<(), LoginRevocationError> {
381 self.observe(caller, now_ns)?;
382 self.reservation_checked(caller, &decoded.claims, now_ns)
383 .map(|_| ())
384 }
385
386 pub fn with_reservation<T, E: From<LoginRevocationError>>(
396 &mut self,
397 caller: Principal,
398 verified: &VerifiedPortableClaims,
399 now_ns: u64,
400 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
401 ) -> Result<T, E> {
402 self.observe(caller, now_ns)?;
403 let record = self.reservation_checked(caller, verified.claims(), now_ns)?;
404 if verified.verified_at_ns() != now_ns {
405 return Err(LoginRevocationError::InvalidPolicy.into());
406 }
407 let value = commit(&LoginRevocationChange {
408 audience: self.audience,
409 caller,
410 record,
411 })?;
412 self.install(caller, record);
413 Ok(value)
414 }
415
416 pub fn with_revocation<E: From<LoginRevocationError>>(
431 &mut self,
432 unknown_budget: &mut SessionAdmissionBudget,
433 caller: Principal,
434 now_ns: u64,
435 commit: impl FnOnce(&LoginRevocationChange) -> Result<(), E>,
436 ) -> Result<u64, E> {
437 self.observe(caller, now_ns)?;
438 let old = self
439 .records
440 .get(&caller)
441 .copied()
442 .filter(|r| r.until_ns > now_ns);
443 let record = if let Some(old) = old.filter(|r| r.revoked()) {
444 old
445 } else {
446 let until_ns = retention(now_ns, self.skew_ns)?;
447 if old.is_some_and(|r| r.until_ns > until_ns) {
448 return Err(LoginRevocationError::Clock.into());
449 }
450 Record {
451 until_ns,
452 state: LoginRecordState::Revoked(old.and_then(Record::login)),
453 }
454 };
455 self.capacity(caller, record)?;
456 if old.is_none() {
457 unknown_budget
458 .consume(now_ns)
459 .map_err(|error| match error {
460 AdmissionBudgetError::Exhausted => LoginRevocationError::Capacity,
461 AdmissionBudgetError::ClockRollback => LoginRevocationError::Clock,
462 AdmissionBudgetError::InvalidPolicy => LoginRevocationError::InvalidPolicy,
463 })?;
464 }
465 commit(&LoginRevocationChange {
466 audience: self.audience,
467 caller,
468 record,
469 })?;
470 self.install(caller, record);
471 Ok(record.until_ns)
472 }
473
474 pub fn is_revoked(
477 &mut self,
478 caller: Principal,
479 now_ns: u64,
480 ) -> Result<bool, LoginRevocationError> {
481 self.observe(caller, now_ns)?;
482 Ok(self
483 .records
484 .get(&caller)
485 .is_some_and(|r| r.revoked() && r.until_ns > now_ns))
486 }
487
488 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
491 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
492 }
493 pub fn prune_with_commit(
498 &mut self,
499 now_ns: u64,
500 budget: usize,
501 commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
502 ) -> Result<usize, LoginRevocationError> {
503 self.clock(now_ns)?;
504 let callers: Vec<_> = self
505 .expiry
506 .iter()
507 .take_while(|(until, _)| *until <= now_ns)
508 .take(budget.min(128))
509 .map(|(_, caller)| *caller)
510 .collect();
511 if callers.is_empty() {
512 return Ok(0);
513 }
514 let mut counts = self.occupancy();
515 for caller in &callers {
516 if self.records[caller].admitted_pool() {
517 counts.0 -= 1;
518 } else {
519 counts.1 -= 1;
520 }
521 }
522 commit(&callers, counts)?;
523 for caller in &callers {
524 self.remove(*caller);
525 }
526 Ok(callers.len())
527 }
528
529 fn reservation(
530 &mut self,
531 caller: Principal,
532 claims: &PortableClaims,
533 now_ns: u64,
534 ) -> Result<Record, LoginRevocationError> {
535 self.observe(caller, now_ns)?;
536 validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
537 self.reservation_checked(caller, claims, now_ns)
538 }
539
540 fn reservation_checked(
543 &self,
544 caller: Principal,
545 claims: &PortableClaims,
546 now_ns: u64,
547 ) -> Result<Record, LoginRevocationError> {
548 let login_deadline_ns = claims.login_deadline_ns;
549 if claims.audience != self.audience
550 || browser_principal(&claims.browser_public_key) != caller
551 {
552 return Err(LoginRevocationError::InvalidPolicy);
553 }
554 let until_ns = login_deadline_ns
555 .checked_add(self.skew_ns)
556 .ok_or(LoginRevocationError::Clock)?;
557 let login = LoginBinding {
558 issuer: claims.issuer,
559 subject: claims.account_subject,
560 started_at_ns: claims.login_started_at_ns,
561 deadline_ns: login_deadline_ns,
562 };
563 if let Some(old) = self.records.get(&caller).filter(|r| r.until_ns > now_ns) {
564 if old.revoked() {
565 return Err(LoginRevocationError::Revoked);
566 }
567 if old.login() != Some(login) {
568 return Err(LoginRevocationError::InvalidPolicy);
569 }
570 }
571 let record = Record {
572 until_ns,
573 state: LoginRecordState::Reserved(login),
574 };
575 self.capacity(caller, record)?;
576 Ok(record)
577 }
578
579 fn clock(&mut self, now_ns: u64) -> Result<(), LoginRevocationError> {
580 if now_ns < self.observed_ns {
581 return Err(LoginRevocationError::Clock);
582 }
583 self.observed_ns = now_ns;
584 Ok(())
585 }
586
587 fn observe(&mut self, caller: Principal, now_ns: u64) -> Result<(), LoginRevocationError> {
588 let bytes = caller.as_slice();
589 if bytes.len() != 29 || bytes.last() != Some(&2) {
590 return Err(LoginRevocationError::InvalidCaller);
591 }
592 self.clock(now_ns)
593 }
594
595 fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
596 let reuses_pool = self
597 .records
598 .get(&caller)
599 .is_some_and(|old| old.admitted_pool() == record.admitted_pool());
600 let (count, limit) = if record.admitted_pool() {
601 (self.admitted, self.max_admitted)
602 } else {
603 (self.unknown, self.max_unknown)
604 };
605 if !reuses_pool && count >= limit {
606 return Err(LoginRevocationError::Capacity);
607 }
608 Ok(())
609 }
610
611 fn remove(&mut self, caller: Principal) {
612 if let Some(old) = self.records.remove(&caller) {
613 self.expiry.remove(&(old.until_ns, caller));
614 if old.admitted_pool() {
615 self.admitted -= 1;
616 } else {
617 self.unknown -= 1;
618 }
619 }
620 }
621
622 fn install(&mut self, caller: Principal, record: Record) {
623 self.remove(caller);
624 if record.admitted_pool() {
625 self.admitted += 1;
626 } else {
627 self.unknown += 1;
628 }
629 self.expiry.insert((record.until_ns, caller));
630 self.records.insert(caller, record);
631 }
632}
633
634fn retention(now_ns: u64, skew_ns: u64) -> Result<u64, LoginRevocationError> {
635 skew_ns
636 .checked_mul(2)
637 .and_then(|skew| crate::limits::MAX_LOGIN_NS.checked_add(skew))
638 .and_then(|ttl| now_ns.checked_add(ttl))
639 .ok_or(LoginRevocationError::Clock)
640}