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 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 if !is_login_caller(caller) {
138 return Err(LoginRevocationError::InvalidCaller);
139 }
140 let state = match fields.login {
141 Some(login)
142 if fields.admitted_pool
143 && login.issuer != Principal::anonymous()
144 && login.subject != Principal::anonymous()
145 && login.started_at_ns < login.deadline_ns
146 && login.deadline_ns - login.started_at_ns <= crate::limits::MAX_LOGIN_NS =>
147 {
148 if fields.revoked {
149 LoginRecordState::Revoked(Some(login))
150 } else {
151 LoginRecordState::Reserved(login)
152 }
153 }
154 None if !fields.admitted_pool && fields.revoked => LoginRecordState::Revoked(None),
155 _ => return Err(LoginRevocationError::InvalidPolicy),
156 };
157 let change = Self {
158 audience,
159 caller,
160 record: Record {
161 until_ns: fields.until_ns,
162 state,
163 },
164 };
165 Ok(change)
166 }
167
168 pub fn audience(&self) -> AudienceId {
170 self.audience
171 }
172 pub fn caller(&self) -> Principal {
174 self.caller
175 }
176 pub fn until_ns(&self) -> u64 {
178 self.record.until_ns
179 }
180 pub fn state(&self) -> LoginRecordState {
183 self.record.state
184 }
185}
186
187pub struct MemoryLoginRevocations {
207 audience: AudienceId,
208 records: BTreeMap<Principal, Record>,
209 expiry: BTreeSet<(u64, Principal)>,
210 max_admitted: usize,
211 max_unknown: usize,
212 admitted: usize,
213 unknown: usize,
214 skew_ns: u64,
215 observed_ns: u64,
216}
217
218impl MemoryLoginRevocations {
219 #[cfg(feature = "session-restore")]
223 pub fn records(&self) -> impl ExactSizeIterator<Item = LoginRevocationChange> + '_ {
224 self.records
225 .iter()
226 .map(|(&caller, &record)| LoginRevocationChange {
227 audience: self.audience,
228 caller,
229 record,
230 })
231 }
232
233 #[cfg(feature = "session-restore")]
242 pub fn restore_records(
243 mut self,
244 observed_ns: u64,
245 expected_counts: (usize, usize),
246 records: impl IntoIterator<Item = LoginRevocationChange>,
247 ) -> Result<Self, LoginRevocationError> {
248 if !self.records.is_empty()
249 || observed_ns > self.observed_ns
250 || expected_counts.0 > self.max_admitted
251 || expected_counts.1 > self.max_unknown
252 {
253 return Err(LoginRevocationError::InvalidPolicy);
254 }
255 let max_until = retention(observed_ns, self.skew_ns)?;
256 for change in records {
257 if change.audience != self.audience || self.records.contains_key(&change.caller) {
258 return Err(LoginRevocationError::InvalidPolicy);
259 }
260 let record = change.record;
261 if record.until_ns == 0 || record.until_ns > max_until {
262 return Err(LoginRevocationError::InvalidPolicy);
263 }
264 if let Some(login) = record.login() {
265 let minimum = login
266 .deadline_ns
267 .checked_add(self.skew_ns)
268 .ok_or(LoginRevocationError::Clock)?;
269 if login.started_at_ns > observed_ns
270 || record.until_ns < minimum
271 || (!record.revoked() && record.until_ns != minimum)
272 {
273 return Err(LoginRevocationError::InvalidPolicy);
274 }
275 }
276 let (used, expected) = if record.admitted_pool() {
278 (self.admitted, expected_counts.0)
279 } else {
280 (self.unknown, expected_counts.1)
281 };
282 if used >= expected {
283 return Err(LoginRevocationError::Capacity);
284 }
285 self.install(change.caller, record);
286 }
287 if self.occupancy() != expected_counts {
288 return Err(LoginRevocationError::InvalidPolicy);
289 }
290 Ok(self)
291 }
292
293 pub fn new(
296 audience: AudienceId,
297 max_admitted: usize,
298 max_unknown: usize,
299 skew_ns: u64,
300 now_ns: u64,
301 ) -> Result<Self, LoginRevocationError> {
302 if max_admitted == 0 || max_unknown == 0 {
303 return Err(LoginRevocationError::InvalidPolicy);
304 }
305 retention(now_ns, skew_ns)?;
306 Ok(Self {
307 audience,
308 records: BTreeMap::new(),
309 expiry: BTreeSet::new(),
310 max_admitted,
311 max_unknown,
312 admitted: 0,
313 unknown: 0,
314 skew_ns,
315 observed_ns: now_ns,
316 })
317 }
318
319 #[cfg(feature = "sessions")]
320 pub(crate) fn audience(&self) -> AudienceId {
321 self.audience
322 }
323
324 #[cfg(feature = "sessions")]
328 pub(crate) fn observe_login(
329 &mut self,
330 caller: Principal,
331 now_ns: u64,
332 ) -> Result<Option<LoginBinding>, LoginRevocationError> {
333 self.observe(caller, now_ns)?;
334 let record = self.records.get(&caller).filter(|r| r.until_ns > now_ns);
335 if record.is_some_and(|r| r.revoked()) {
336 return Err(LoginRevocationError::Revoked);
337 }
338 Ok(record.and_then(|r| r.login()))
339 }
340
341 pub fn occupancy(&self) -> (usize, usize) {
343 (self.admitted, self.unknown)
344 }
345
346 pub fn observed_at_ns(&self) -> u64 {
349 self.observed_ns
350 }
351
352 pub fn check_reservation(
359 &mut self,
360 caller: Principal,
361 claims: &PortableClaims,
362 now_ns: u64,
363 ) -> Result<(), LoginRevocationError> {
364 self.reservation(caller, claims, now_ns).map(|_| ())
365 }
366
367 #[cfg(all(
368 feature = "sessions",
369 any(
370 feature = "portable-signature-verification",
371 feature = "certified-proof-verification"
372 )
373 ))]
374 pub(crate) fn check_decoded_reservation(
375 &mut self,
376 caller: Principal,
377 decoded: &super::DecodedPortableClaims,
378 now_ns: u64,
379 ) -> Result<(), LoginRevocationError> {
380 self.observe(caller, now_ns)?;
381 self.reservation_checked(caller, &decoded.claims, now_ns)
382 .map(|_| ())
383 }
384
385 pub fn with_reservation<T, E: From<LoginRevocationError>>(
395 &mut self,
396 caller: Principal,
397 verified: &VerifiedPortableClaims,
398 now_ns: u64,
399 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
400 ) -> Result<T, E> {
401 self.observe(caller, now_ns)?;
402 let record = self.reservation_checked(caller, verified.claims(), now_ns)?;
403 if verified.verified_at_ns() != now_ns {
404 return Err(LoginRevocationError::InvalidPolicy.into());
405 }
406 let value = commit(&LoginRevocationChange {
407 audience: self.audience,
408 caller,
409 record,
410 })?;
411 self.install(caller, record);
412 Ok(value)
413 }
414
415 pub fn with_revocation<E: From<LoginRevocationError>>(
430 &mut self,
431 unknown_budget: &mut SessionAdmissionBudget,
432 caller: Principal,
433 now_ns: u64,
434 commit: impl FnOnce(&LoginRevocationChange) -> Result<(), E>,
435 ) -> Result<u64, E> {
436 self.observe(caller, now_ns)?;
437 let old = self
438 .records
439 .get(&caller)
440 .copied()
441 .filter(|r| r.until_ns > now_ns);
442 let record = if let Some(old) = old.filter(|r| r.revoked()) {
443 old
444 } else {
445 let until_ns = retention(now_ns, self.skew_ns)?;
446 if old.is_some_and(|r| r.until_ns > until_ns) {
447 return Err(LoginRevocationError::Clock.into());
448 }
449 Record {
450 until_ns,
451 state: LoginRecordState::Revoked(old.and_then(Record::login)),
452 }
453 };
454 self.capacity(caller, record)?;
455 if old.is_none() {
456 unknown_budget
457 .consume(now_ns)
458 .map_err(|error| match error {
459 AdmissionBudgetError::Exhausted => LoginRevocationError::Capacity,
460 AdmissionBudgetError::ClockRollback => LoginRevocationError::Clock,
461 AdmissionBudgetError::InvalidPolicy => LoginRevocationError::InvalidPolicy,
462 })?;
463 }
464 commit(&LoginRevocationChange {
465 audience: self.audience,
466 caller,
467 record,
468 })?;
469 self.install(caller, record);
470 Ok(record.until_ns)
471 }
472
473 pub fn is_revoked(
476 &mut self,
477 caller: Principal,
478 now_ns: u64,
479 ) -> Result<bool, LoginRevocationError> {
480 self.observe(caller, now_ns)?;
481 Ok(self
482 .records
483 .get(&caller)
484 .is_some_and(|r| r.revoked() && r.until_ns > now_ns))
485 }
486
487 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
490 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
491 }
492 pub fn prune_with_commit(
497 &mut self,
498 now_ns: u64,
499 budget: usize,
500 commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
501 ) -> Result<usize, LoginRevocationError> {
502 self.clock(now_ns)?;
503 let callers: Vec<_> = self
504 .expiry
505 .iter()
506 .take_while(|(until, _)| *until <= now_ns)
507 .take(budget.min(128))
508 .map(|(_, caller)| *caller)
509 .collect();
510 if callers.is_empty() {
511 return Ok(0);
512 }
513 let mut counts = self.occupancy();
514 for caller in &callers {
515 if self.records[caller].admitted_pool() {
516 counts.0 -= 1;
517 } else {
518 counts.1 -= 1;
519 }
520 }
521 commit(&callers, counts)?;
522 for caller in &callers {
523 self.remove(*caller);
524 }
525 Ok(callers.len())
526 }
527
528 fn reservation(
529 &mut self,
530 caller: Principal,
531 claims: &PortableClaims,
532 now_ns: u64,
533 ) -> Result<Record, LoginRevocationError> {
534 self.observe(caller, now_ns)?;
535 validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
536 self.reservation_checked(caller, claims, now_ns)
537 }
538
539 fn reservation_checked(
542 &self,
543 caller: Principal,
544 claims: &PortableClaims,
545 now_ns: u64,
546 ) -> Result<Record, LoginRevocationError> {
547 let login_deadline_ns = claims.login_deadline_ns;
548 if claims.audience != self.audience
549 || browser_principal(&claims.browser_public_key) != caller
550 {
551 return Err(LoginRevocationError::InvalidPolicy);
552 }
553 let until_ns = login_deadline_ns
554 .checked_add(self.skew_ns)
555 .ok_or(LoginRevocationError::Clock)?;
556 let login = LoginBinding {
557 issuer: claims.issuer,
558 subject: claims.account_subject,
559 started_at_ns: claims.login_started_at_ns,
560 deadline_ns: login_deadline_ns,
561 };
562 if let Some(old) = self.records.get(&caller).filter(|r| r.until_ns > now_ns) {
563 if old.revoked() {
564 return Err(LoginRevocationError::Revoked);
565 }
566 if old.login() != Some(login) {
567 return Err(LoginRevocationError::InvalidPolicy);
568 }
569 }
570 let record = Record {
571 until_ns,
572 state: LoginRecordState::Reserved(login),
573 };
574 self.capacity(caller, record)?;
575 Ok(record)
576 }
577
578 fn clock(&mut self, now_ns: u64) -> Result<(), LoginRevocationError> {
579 if now_ns < self.observed_ns {
580 return Err(LoginRevocationError::Clock);
581 }
582 self.observed_ns = now_ns;
583 Ok(())
584 }
585
586 fn observe(&mut self, caller: Principal, now_ns: u64) -> Result<(), LoginRevocationError> {
587 if !is_login_caller(caller) {
588 return Err(LoginRevocationError::InvalidCaller);
589 }
590 self.clock(now_ns)
591 }
592
593 fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
594 let reuses_pool = self
595 .records
596 .get(&caller)
597 .is_some_and(|old| old.admitted_pool() == record.admitted_pool());
598 let (count, limit) = if record.admitted_pool() {
599 (self.admitted, self.max_admitted)
600 } else {
601 (self.unknown, self.max_unknown)
602 };
603 if !reuses_pool && count >= limit {
604 return Err(LoginRevocationError::Capacity);
605 }
606 Ok(())
607 }
608
609 fn remove(&mut self, caller: Principal) {
610 if let Some(old) = self.records.remove(&caller) {
611 self.expiry.remove(&(old.until_ns, caller));
612 if old.admitted_pool() {
613 self.admitted -= 1;
614 } else {
615 self.unknown -= 1;
616 }
617 }
618 }
619
620 fn install(&mut self, caller: Principal, record: Record) {
621 self.remove(caller);
622 if record.admitted_pool() {
623 self.admitted += 1;
624 } else {
625 self.unknown += 1;
626 }
627 self.expiry.insert((record.until_ns, caller));
628 self.records.insert(caller, record);
629 }
630}
631
632fn retention(now_ns: u64, skew_ns: u64) -> Result<u64, LoginRevocationError> {
633 skew_ns
634 .checked_mul(2)
635 .and_then(|skew| crate::limits::MAX_LOGIN_NS.checked_add(skew))
636 .and_then(|ttl| now_ns.checked_add(ttl))
637 .ok_or(LoginRevocationError::Clock)
638}