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: LoginState,
37}
38
39#[derive(Clone, Copy)]
42enum LoginState {
43 Reserved(LoginBinding),
44 Revoked(Option<LoginBinding>),
45}
46
47impl Record {
48 fn login(self) -> Option<LoginBinding> {
49 match self.state {
50 LoginState::Reserved(login) => Some(login),
51 LoginState::Revoked(login) => login,
52 }
53 }
54
55 fn revoked(self) -> bool {
56 matches!(self.state, LoginState::Revoked(_))
57 }
58
59 fn admitted_pool(self) -> bool {
60 self.login().is_some()
61 }
62}
63
64#[cfg(feature = "sessions")]
67#[derive(serde::Serialize)]
68#[cfg_attr(feature = "session-restore", derive(serde::Deserialize))]
69#[serde(deny_unknown_fields)]
70struct RecordFields {
71 until_ns: u64,
72 login: Option<LoginBinding>,
73 revoked: bool,
74 admitted_pool: bool,
75}
76
77#[cfg(feature = "sessions")]
78impl From<Record> for RecordFields {
79 fn from(record: Record) -> Self {
80 Self {
81 until_ns: record.until_ns,
82 login: record.login(),
83 revoked: record.revoked(),
84 admitted_pool: record.admitted_pool(),
85 }
86 }
87}
88
89#[derive(Clone, Copy, Eq, PartialEq)]
90#[cfg_attr(feature = "sessions", derive(serde::Serialize))]
91#[cfg_attr(feature = "session-restore", derive(serde::Deserialize))]
92#[cfg_attr(feature = "sessions", serde(deny_unknown_fields))]
93struct LoginBinding {
94 issuer: Principal,
95 subject: Principal,
96 started_at_ns: u64,
97 deadline_ns: u64,
98}
99
100pub struct LoginRevocationChange {
104 audience: AudienceId,
105 caller: Principal,
106 record: Record,
107}
108
109impl LoginRevocationChange {
110 #[cfg(feature = "sessions")]
112 pub fn encode(&self) -> Result<Vec<u8>, LoginRevocationError> {
113 crate::storage_codec::encode(
114 &(self.audience, self.caller, self.record),
115 RECORD_DOMAIN,
116 MAX_RECORD_BYTES,
117 LoginRevocationError::Storage,
118 LoginRevocationError::InvalidPolicy,
119 )
120 }
121
122 #[cfg(feature = "session-restore")]
125 pub fn decode(bytes: &[u8]) -> Result<Self, LoginRevocationError> {
126 let (audience, caller, fields): (_, Principal, RecordFields) =
127 crate::storage_codec::decode(
128 bytes,
129 RECORD_DOMAIN,
130 MAX_RECORD_BYTES,
131 || LoginRevocationError::InvalidPolicy,
132 LoginRevocationError::InvalidPolicy,
133 )?;
134 let caller_bytes = caller.as_slice();
135 if caller_bytes.len() != 29 || caller_bytes.last() != Some(&2) {
136 return Err(LoginRevocationError::InvalidCaller);
137 }
138 let state = match fields.login {
139 Some(login)
140 if fields.admitted_pool
141 && login.issuer != Principal::anonymous()
142 && login.subject != Principal::anonymous()
143 && login.started_at_ns < login.deadline_ns
144 && login.deadline_ns - login.started_at_ns <= crate::limits::MAX_LOGIN_NS =>
145 {
146 if fields.revoked {
147 LoginState::Revoked(Some(login))
148 } else {
149 LoginState::Reserved(login)
150 }
151 }
152 None if !fields.admitted_pool && fields.revoked => LoginState::Revoked(None),
153 _ => return Err(LoginRevocationError::InvalidPolicy),
154 };
155 let change = Self {
156 audience,
157 caller,
158 record: Record {
159 until_ns: fields.until_ns,
160 state,
161 },
162 };
163 Ok(change)
164 }
165
166 pub fn audience(&self) -> AudienceId {
168 self.audience
169 }
170 pub fn caller(&self) -> Principal {
172 self.caller
173 }
174 pub fn until_ns(&self) -> u64 {
176 self.record.until_ns
177 }
178 pub fn revoked(&self) -> bool {
180 self.record.revoked()
181 }
182 pub fn admitted_pool(&self) -> bool {
184 self.record.admitted_pool()
185 }
186 pub fn binding(&self) -> Option<(Principal, Principal, u64, u64)> {
189 self.record.login().map(|login| {
190 (
191 login.issuer,
192 login.subject,
193 login.started_at_ns,
194 login.deadline_ns,
195 )
196 })
197 }
198}
199
200pub struct MemoryLoginRevocations {
220 audience: AudienceId,
221 records: BTreeMap<Principal, Record>,
222 expiry: BTreeSet<(u64, Principal)>,
223 max_admitted: usize,
224 max_unknown: usize,
225 admitted: usize,
226 unknown: usize,
227 skew_ns: u64,
228 observed_ns: u64,
229}
230
231impl MemoryLoginRevocations {
232 #[cfg(feature = "session-restore")]
236 pub fn records(&self) -> impl ExactSizeIterator<Item = LoginRevocationChange> + '_ {
237 self.records
238 .iter()
239 .map(|(&caller, &record)| LoginRevocationChange {
240 audience: self.audience,
241 caller,
242 record,
243 })
244 }
245
246 #[cfg(feature = "session-restore")]
255 pub fn restore_records(
256 mut self,
257 observed_ns: u64,
258 expected_counts: (usize, usize),
259 records: impl IntoIterator<Item = LoginRevocationChange>,
260 ) -> Result<Self, LoginRevocationError> {
261 if !self.records.is_empty()
262 || observed_ns > self.observed_ns
263 || expected_counts.0 > self.max_admitted
264 || expected_counts.1 > self.max_unknown
265 {
266 return Err(LoginRevocationError::InvalidPolicy);
267 }
268 let max_until = retention(observed_ns, self.skew_ns)?;
269 for change in records {
270 if change.audience != self.audience || self.records.contains_key(&change.caller) {
271 return Err(LoginRevocationError::InvalidPolicy);
272 }
273 let record = change.record;
274 if record.until_ns == 0 || record.until_ns > max_until {
275 return Err(LoginRevocationError::InvalidPolicy);
276 }
277 if let Some(login) = record.login() {
278 let minimum = login
279 .deadline_ns
280 .checked_add(self.skew_ns)
281 .ok_or(LoginRevocationError::Clock)?;
282 if login.started_at_ns > observed_ns
283 || record.until_ns < minimum
284 || (!record.revoked() && record.until_ns != minimum)
285 {
286 return Err(LoginRevocationError::InvalidPolicy);
287 }
288 }
289 let (used, expected) = if record.admitted_pool() {
291 (self.admitted, expected_counts.0)
292 } else {
293 (self.unknown, expected_counts.1)
294 };
295 if used >= expected {
296 return Err(LoginRevocationError::Capacity);
297 }
298 self.install(change.caller, record);
299 }
300 if self.occupancy() != expected_counts {
301 return Err(LoginRevocationError::InvalidPolicy);
302 }
303 Ok(self)
304 }
305
306 pub fn new(
309 audience: AudienceId,
310 max_admitted: usize,
311 max_unknown: usize,
312 skew_ns: u64,
313 now_ns: u64,
314 ) -> Result<Self, LoginRevocationError> {
315 if max_admitted == 0 || max_unknown == 0 {
316 return Err(LoginRevocationError::InvalidPolicy);
317 }
318 retention(now_ns, skew_ns)?;
319 Ok(Self {
320 audience,
321 records: BTreeMap::new(),
322 expiry: BTreeSet::new(),
323 max_admitted,
324 max_unknown,
325 admitted: 0,
326 unknown: 0,
327 skew_ns,
328 observed_ns: now_ns,
329 })
330 }
331
332 #[cfg(feature = "sessions")]
333 pub(crate) fn audience(&self) -> AudienceId {
334 self.audience
335 }
336
337 #[cfg(feature = "sessions")]
340 pub(crate) fn check_session(
341 &mut self,
342 caller: Principal,
343 issuer: Principal,
344 subject: Principal,
345 started_at_ns: u64,
346 deadline_ns: u64,
347 now_ns: u64,
348 ) -> Result<(), LoginRevocationError> {
349 self.observe(caller, now_ns)?;
350 let record = self
351 .records
352 .get(&caller)
353 .filter(|record| record.until_ns > now_ns)
354 .ok_or(LoginRevocationError::InvalidPolicy)?;
355 if record.revoked() {
356 return Err(LoginRevocationError::Revoked);
357 }
358 if record.login()
359 != Some(LoginBinding {
360 issuer,
361 subject,
362 started_at_ns,
363 deadline_ns,
364 })
365 {
366 return Err(LoginRevocationError::InvalidPolicy);
367 }
368 Ok(())
369 }
370
371 pub fn occupancy(&self) -> (usize, usize) {
373 (self.admitted, self.unknown)
374 }
375
376 pub fn observed_at_ns(&self) -> u64 {
379 self.observed_ns
380 }
381
382 pub fn check_reservation(
389 &mut self,
390 caller: Principal,
391 claims: &PortableClaims,
392 now_ns: u64,
393 ) -> Result<(), LoginRevocationError> {
394 self.reservation(caller, claims, now_ns).map(|_| ())
395 }
396
397 pub fn with_reservation<T, E: From<LoginRevocationError>>(
407 &mut self,
408 caller: Principal,
409 verified: &VerifiedPortableClaims,
410 now_ns: u64,
411 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, E>,
412 ) -> Result<T, E> {
413 let record = self.reservation(caller, verified.claims(), now_ns)?;
414 if verified.verified_at_ns() != now_ns {
415 return Err(LoginRevocationError::InvalidPolicy.into());
416 }
417 let value = commit(&LoginRevocationChange {
418 audience: self.audience,
419 caller,
420 record,
421 })?;
422 self.install(caller, record);
423 Ok(value)
424 }
425
426 pub fn with_revocation<E: From<LoginRevocationError>>(
441 &mut self,
442 unknown_budget: &mut SessionAdmissionBudget,
443 caller: Principal,
444 now_ns: u64,
445 commit: impl FnOnce(&LoginRevocationChange) -> Result<(), E>,
446 ) -> Result<u64, E> {
447 self.observe(caller, now_ns)?;
448 let old = self
449 .records
450 .get(&caller)
451 .copied()
452 .filter(|r| r.until_ns > now_ns);
453 let record = if let Some(old) = old.filter(|r| r.revoked()) {
454 old
455 } else {
456 let until_ns = retention(now_ns, self.skew_ns)?;
457 if old.is_some_and(|r| r.until_ns > until_ns) {
458 return Err(LoginRevocationError::Clock.into());
459 }
460 Record {
461 until_ns,
462 state: LoginState::Revoked(old.and_then(Record::login)),
463 }
464 };
465 self.capacity(caller, record)?;
466 if old.is_none() {
467 unknown_budget
468 .consume(now_ns)
469 .map_err(|error| match error {
470 AdmissionBudgetError::Exhausted => LoginRevocationError::Capacity,
471 AdmissionBudgetError::ClockRollback => LoginRevocationError::Clock,
472 AdmissionBudgetError::InvalidPolicy => LoginRevocationError::InvalidPolicy,
473 })?;
474 }
475 commit(&LoginRevocationChange {
476 audience: self.audience,
477 caller,
478 record,
479 })?;
480 self.install(caller, record);
481 Ok(record.until_ns)
482 }
483
484 pub fn is_revoked(
487 &mut self,
488 caller: Principal,
489 now_ns: u64,
490 ) -> Result<bool, LoginRevocationError> {
491 self.observe(caller, now_ns)?;
492 Ok(self
493 .records
494 .get(&caller)
495 .is_some_and(|r| r.revoked() && r.until_ns > now_ns))
496 }
497
498 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
501 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
502 }
503 pub fn prune_with_commit(
508 &mut self,
509 now_ns: u64,
510 budget: usize,
511 commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
512 ) -> Result<usize, LoginRevocationError> {
513 self.clock(now_ns)?;
514 let callers: Vec<_> = self
515 .expiry
516 .iter()
517 .take_while(|(until, _)| *until <= now_ns)
518 .take(budget.min(128))
519 .map(|(_, caller)| *caller)
520 .collect();
521 if callers.is_empty() {
522 return Ok(0);
523 }
524 let mut counts = self.occupancy();
525 for caller in &callers {
526 if self.records[caller].admitted_pool() {
527 counts.0 -= 1;
528 } else {
529 counts.1 -= 1;
530 }
531 }
532 commit(&callers, counts)?;
533 for caller in &callers {
534 self.remove(*caller);
535 }
536 Ok(callers.len())
537 }
538
539 fn reservation(
540 &mut self,
541 caller: Principal,
542 claims: &PortableClaims,
543 now_ns: u64,
544 ) -> Result<Record, LoginRevocationError> {
545 self.observe(caller, now_ns)?;
546 validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
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: LoginState::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 let bytes = caller.as_slice();
588 if bytes.len() != 29 || bytes.last() != Some(&2) {
589 return Err(LoginRevocationError::InvalidCaller);
590 }
591 self.clock(now_ns)
592 }
593
594 fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
595 let reuses_pool = self
596 .records
597 .get(&caller)
598 .is_some_and(|old| old.admitted_pool() == record.admitted_pool());
599 let (count, limit) = if record.admitted_pool() {
600 (self.admitted, self.max_admitted)
601 } else {
602 (self.unknown, self.max_unknown)
603 };
604 if !reuses_pool && count >= limit {
605 return Err(LoginRevocationError::Capacity);
606 }
607 Ok(())
608 }
609
610 fn remove(&mut self, caller: Principal) {
611 if let Some(old) = self.records.remove(&caller) {
612 self.expiry.remove(&(old.until_ns, caller));
613 if old.admitted_pool() {
614 self.admitted -= 1;
615 } else {
616 self.unknown -= 1;
617 }
618 }
619 }
620
621 fn install(&mut self, caller: Principal, record: Record) {
622 self.remove(caller);
623 if record.admitted_pool() {
624 self.admitted += 1;
625 } else {
626 self.unknown += 1;
627 }
628 self.expiry.insert((record.until_ns, caller));
629 self.records.insert(caller, record);
630 }
631}
632
633fn retention(now_ns: u64, skew_ns: u64) -> Result<u64, LoginRevocationError> {
634 skew_ns
635 .checked_mul(2)
636 .and_then(|skew| crate::limits::MAX_LOGIN_NS.checked_add(skew))
637 .and_then(|ttl| now_ns.checked_add(ttl))
638 .ok_or(LoginRevocationError::Clock)
639}