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")]
328 pub(crate) fn check_session(
329 &mut self,
330 caller: Principal,
331 issuer: Principal,
332 subject: Principal,
333 started_at_ns: u64,
334 deadline_ns: u64,
335 now_ns: u64,
336 ) -> Result<(), LoginRevocationError> {
337 self.observe(caller, now_ns)?;
338 let record = self
339 .records
340 .get(&caller)
341 .filter(|record| record.until_ns > now_ns)
342 .ok_or(LoginRevocationError::InvalidPolicy)?;
343 if record.revoked() {
344 return Err(LoginRevocationError::Revoked);
345 }
346 if record.login()
347 != Some(LoginBinding {
348 issuer,
349 subject,
350 started_at_ns,
351 deadline_ns,
352 })
353 {
354 return Err(LoginRevocationError::InvalidPolicy);
355 }
356 Ok(())
357 }
358
359 pub fn occupancy(&self) -> (usize, usize) {
361 (self.admitted, self.unknown)
362 }
363
364 pub fn observed_at_ns(&self) -> u64 {
367 self.observed_ns
368 }
369
370 pub fn check_reservation(
377 &mut self,
378 caller: Principal,
379 claims: &PortableClaims,
380 now_ns: u64,
381 ) -> Result<(), LoginRevocationError> {
382 self.reservation(caller, claims, now_ns).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 let record = self.reservation(caller, verified.claims(), now_ns)?;
402 if verified.verified_at_ns() != now_ns {
403 return Err(LoginRevocationError::InvalidPolicy.into());
404 }
405 let value = commit(&LoginRevocationChange {
406 audience: self.audience,
407 caller,
408 record,
409 })?;
410 self.install(caller, record);
411 Ok(value)
412 }
413
414 pub fn with_revocation<E: From<LoginRevocationError>>(
429 &mut self,
430 unknown_budget: &mut SessionAdmissionBudget,
431 caller: Principal,
432 now_ns: u64,
433 commit: impl FnOnce(&LoginRevocationChange) -> Result<(), E>,
434 ) -> Result<u64, E> {
435 self.observe(caller, now_ns)?;
436 let old = self
437 .records
438 .get(&caller)
439 .copied()
440 .filter(|r| r.until_ns > now_ns);
441 let record = if let Some(old) = old.filter(|r| r.revoked()) {
442 old
443 } else {
444 let until_ns = retention(now_ns, self.skew_ns)?;
445 if old.is_some_and(|r| r.until_ns > until_ns) {
446 return Err(LoginRevocationError::Clock.into());
447 }
448 Record {
449 until_ns,
450 state: LoginRecordState::Revoked(old.and_then(Record::login)),
451 }
452 };
453 self.capacity(caller, record)?;
454 if old.is_none() {
455 unknown_budget
456 .consume(now_ns)
457 .map_err(|error| match error {
458 AdmissionBudgetError::Exhausted => LoginRevocationError::Capacity,
459 AdmissionBudgetError::ClockRollback => LoginRevocationError::Clock,
460 AdmissionBudgetError::InvalidPolicy => LoginRevocationError::InvalidPolicy,
461 })?;
462 }
463 commit(&LoginRevocationChange {
464 audience: self.audience,
465 caller,
466 record,
467 })?;
468 self.install(caller, record);
469 Ok(record.until_ns)
470 }
471
472 pub fn is_revoked(
475 &mut self,
476 caller: Principal,
477 now_ns: u64,
478 ) -> Result<bool, LoginRevocationError> {
479 self.observe(caller, now_ns)?;
480 Ok(self
481 .records
482 .get(&caller)
483 .is_some_and(|r| r.revoked() && r.until_ns > now_ns))
484 }
485
486 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
489 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
490 }
491 pub fn prune_with_commit(
496 &mut self,
497 now_ns: u64,
498 budget: usize,
499 commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
500 ) -> Result<usize, LoginRevocationError> {
501 self.clock(now_ns)?;
502 let callers: Vec<_> = self
503 .expiry
504 .iter()
505 .take_while(|(until, _)| *until <= now_ns)
506 .take(budget.min(128))
507 .map(|(_, caller)| *caller)
508 .collect();
509 if callers.is_empty() {
510 return Ok(0);
511 }
512 let mut counts = self.occupancy();
513 for caller in &callers {
514 if self.records[caller].admitted_pool() {
515 counts.0 -= 1;
516 } else {
517 counts.1 -= 1;
518 }
519 }
520 commit(&callers, counts)?;
521 for caller in &callers {
522 self.remove(*caller);
523 }
524 Ok(callers.len())
525 }
526
527 fn reservation(
528 &mut self,
529 caller: Principal,
530 claims: &PortableClaims,
531 now_ns: u64,
532 ) -> Result<Record, LoginRevocationError> {
533 self.observe(caller, now_ns)?;
534 validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
535 let login_deadline_ns = claims.login_deadline_ns;
536 if claims.audience != self.audience
537 || browser_principal(&claims.browser_public_key) != caller
538 {
539 return Err(LoginRevocationError::InvalidPolicy);
540 }
541 let until_ns = login_deadline_ns
542 .checked_add(self.skew_ns)
543 .ok_or(LoginRevocationError::Clock)?;
544 let login = LoginBinding {
545 issuer: claims.issuer,
546 subject: claims.account_subject,
547 started_at_ns: claims.login_started_at_ns,
548 deadline_ns: login_deadline_ns,
549 };
550 if let Some(old) = self.records.get(&caller).filter(|r| r.until_ns > now_ns) {
551 if old.revoked() {
552 return Err(LoginRevocationError::Revoked);
553 }
554 if old.login() != Some(login) {
555 return Err(LoginRevocationError::InvalidPolicy);
556 }
557 }
558 let record = Record {
559 until_ns,
560 state: LoginRecordState::Reserved(login),
561 };
562 self.capacity(caller, record)?;
563 Ok(record)
564 }
565
566 fn clock(&mut self, now_ns: u64) -> Result<(), LoginRevocationError> {
567 if now_ns < self.observed_ns {
568 return Err(LoginRevocationError::Clock);
569 }
570 self.observed_ns = now_ns;
571 Ok(())
572 }
573
574 fn observe(&mut self, caller: Principal, now_ns: u64) -> Result<(), LoginRevocationError> {
575 let bytes = caller.as_slice();
576 if bytes.len() != 29 || bytes.last() != Some(&2) {
577 return Err(LoginRevocationError::InvalidCaller);
578 }
579 self.clock(now_ns)
580 }
581
582 fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
583 let reuses_pool = self
584 .records
585 .get(&caller)
586 .is_some_and(|old| old.admitted_pool() == record.admitted_pool());
587 let (count, limit) = if record.admitted_pool() {
588 (self.admitted, self.max_admitted)
589 } else {
590 (self.unknown, self.max_unknown)
591 };
592 if !reuses_pool && count >= limit {
593 return Err(LoginRevocationError::Capacity);
594 }
595 Ok(())
596 }
597
598 fn remove(&mut self, caller: Principal) {
599 if let Some(old) = self.records.remove(&caller) {
600 self.expiry.remove(&(old.until_ns, caller));
601 if old.admitted_pool() {
602 self.admitted -= 1;
603 } else {
604 self.unknown -= 1;
605 }
606 }
607 }
608
609 fn install(&mut self, caller: Principal, record: Record) {
610 self.remove(caller);
611 if record.admitted_pool() {
612 self.admitted += 1;
613 } else {
614 self.unknown += 1;
615 }
616 self.expiry.insert((record.until_ns, caller));
617 self.records.insert(caller, record);
618 }
619}
620
621fn retention(now_ns: u64, skew_ns: u64) -> Result<u64, LoginRevocationError> {
622 skew_ns
623 .checked_mul(2)
624 .and_then(|skew| crate::limits::MAX_LOGIN_NS.checked_add(skew))
625 .and_then(|ttl| now_ns.checked_add(ttl))
626 .ok_or(LoginRevocationError::Clock)
627}