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
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))]
32#[cfg_attr(feature = "sessions", serde(deny_unknown_fields))]
33struct Record {
34 until_ns: u64,
35 login: Option<LoginBinding>,
36 revoked: bool,
37 admitted_pool: bool,
38}
39
40#[derive(Clone, Copy, Eq, PartialEq)]
41#[cfg_attr(feature = "sessions", derive(serde::Serialize))]
42#[cfg_attr(feature = "session-restore", derive(serde::Deserialize))]
43#[cfg_attr(feature = "sessions", serde(deny_unknown_fields))]
44struct LoginBinding {
45 issuer: Principal,
46 subject: Principal,
47 started_at_ns: u64,
48 deadline_ns: u64,
49}
50
51pub struct LoginRevocationChange {
55 audience: AudienceId,
56 caller: Principal,
57 record: Record,
58}
59
60impl LoginRevocationChange {
61 #[cfg(feature = "sessions")]
63 pub fn encode(&self) -> Result<Vec<u8>, LoginRevocationError> {
64 crate::storage_codec::encode(
65 &(self.audience, self.caller, self.record),
66 RECORD_DOMAIN,
67 1024,
68 LoginRevocationError::Storage,
69 LoginRevocationError::InvalidPolicy,
70 )
71 }
72
73 #[cfg(feature = "session-restore")]
76 pub fn decode(bytes: &[u8]) -> Result<Self, LoginRevocationError> {
77 let (audience, caller, record) = crate::storage_codec::decode(
78 bytes,
79 RECORD_DOMAIN,
80 1024,
81 || LoginRevocationError::InvalidPolicy,
82 LoginRevocationError::InvalidPolicy,
83 )?;
84 let change = Self {
85 audience,
86 caller,
87 record,
88 };
89 let caller = change.caller.as_slice();
90 if caller.len() != 29 || caller.last() != Some(&2) {
91 return Err(LoginRevocationError::InvalidCaller);
92 }
93 match change.record.login {
94 Some(login)
95 if change.record.admitted_pool
96 && login.issuer != Principal::anonymous()
97 && login.subject != Principal::anonymous()
98 && login.started_at_ns < login.deadline_ns
99 && login.deadline_ns - login.started_at_ns <= crate::limits::MAX_LOGIN_NS => {}
100 None if !change.record.admitted_pool && change.record.revoked => {}
101 _ => return Err(LoginRevocationError::InvalidPolicy),
102 }
103 Ok(change)
104 }
105
106 pub fn audience(&self) -> AudienceId {
108 self.audience
109 }
110 pub fn caller(&self) -> Principal {
112 self.caller
113 }
114 pub fn until_ns(&self) -> u64 {
116 self.record.until_ns
117 }
118 pub fn revoked(&self) -> bool {
120 self.record.revoked
121 }
122 pub fn admitted_pool(&self) -> bool {
124 self.record.admitted_pool
125 }
126 pub fn binding(&self) -> Option<(Principal, Principal, u64, u64)> {
129 self.record.login.map(|login| {
130 (
131 login.issuer,
132 login.subject,
133 login.started_at_ns,
134 login.deadline_ns,
135 )
136 })
137 }
138}
139
140pub struct MemoryLoginRevocations {
160 audience: AudienceId,
161 records: BTreeMap<Principal, Record>,
162 expiry: BTreeSet<(u64, Principal)>,
163 max_admitted: usize,
164 max_unknown: usize,
165 admitted: usize,
166 unknown: usize,
167 skew_ns: u64,
168 observed_ns: u64,
169}
170
171impl MemoryLoginRevocations {
172 #[cfg(feature = "session-restore")]
176 pub fn records(&self) -> impl ExactSizeIterator<Item = LoginRevocationChange> + '_ {
177 self.records
178 .iter()
179 .map(|(&caller, &record)| LoginRevocationChange {
180 audience: self.audience,
181 caller,
182 record,
183 })
184 }
185
186 #[cfg(feature = "session-restore")]
195 pub fn restore_records(
196 mut self,
197 observed_ns: u64,
198 expected_counts: (usize, usize),
199 records: impl IntoIterator<Item = LoginRevocationChange>,
200 ) -> Result<Self, LoginRevocationError> {
201 if !self.records.is_empty()
202 || observed_ns > self.observed_ns
203 || expected_counts.0 > self.max_admitted
204 || expected_counts.1 > self.max_unknown
205 {
206 return Err(LoginRevocationError::InvalidPolicy);
207 }
208 let max_until = retention(observed_ns, self.skew_ns)?;
209 for change in records {
210 if change.audience != self.audience || self.records.contains_key(&change.caller) {
211 return Err(LoginRevocationError::InvalidPolicy);
212 }
213 let record = change.record;
214 if record.until_ns == 0 || record.until_ns > max_until {
215 return Err(LoginRevocationError::InvalidPolicy);
216 }
217 if let Some(login) = record.login {
218 let minimum = login
219 .deadline_ns
220 .checked_add(self.skew_ns)
221 .ok_or(LoginRevocationError::Clock)?;
222 if login.started_at_ns > observed_ns
223 || record.until_ns < minimum
224 || (!record.revoked && record.until_ns != minimum)
225 {
226 return Err(LoginRevocationError::InvalidPolicy);
227 }
228 }
229 let (used, expected) = if record.admitted_pool {
231 (self.admitted, expected_counts.0)
232 } else {
233 (self.unknown, expected_counts.1)
234 };
235 if used >= expected {
236 return Err(LoginRevocationError::Capacity);
237 }
238 self.install(change.caller, record);
239 }
240 if self.occupancy() != expected_counts {
241 return Err(LoginRevocationError::InvalidPolicy);
242 }
243 Ok(self)
244 }
245
246 pub fn new(
249 audience: AudienceId,
250 max_admitted: usize,
251 max_unknown: usize,
252 skew_ns: u64,
253 now_ns: u64,
254 ) -> Result<Self, LoginRevocationError> {
255 if max_admitted == 0 || max_unknown == 0 {
256 return Err(LoginRevocationError::InvalidPolicy);
257 }
258 retention(now_ns, skew_ns)?;
259 Ok(Self {
260 audience,
261 records: BTreeMap::new(),
262 expiry: BTreeSet::new(),
263 max_admitted,
264 max_unknown,
265 admitted: 0,
266 unknown: 0,
267 skew_ns,
268 observed_ns: now_ns,
269 })
270 }
271
272 #[cfg(feature = "sessions")]
273 pub(crate) fn audience(&self) -> AudienceId {
274 self.audience
275 }
276
277 #[cfg(feature = "sessions")]
280 pub(crate) fn check_session(
281 &mut self,
282 caller: Principal,
283 issuer: Principal,
284 subject: Principal,
285 started_at_ns: u64,
286 deadline_ns: u64,
287 now_ns: u64,
288 ) -> Result<(), LoginRevocationError> {
289 self.observe(caller, now_ns)?;
290 let record = self
291 .records
292 .get(&caller)
293 .filter(|record| record.until_ns > now_ns)
294 .ok_or(LoginRevocationError::InvalidPolicy)?;
295 if record.revoked {
296 return Err(LoginRevocationError::Revoked);
297 }
298 if record.login
299 != Some(LoginBinding {
300 issuer,
301 subject,
302 started_at_ns,
303 deadline_ns,
304 })
305 {
306 return Err(LoginRevocationError::InvalidPolicy);
307 }
308 Ok(())
309 }
310
311 pub fn occupancy(&self) -> (usize, usize) {
313 (self.admitted, self.unknown)
314 }
315
316 pub fn observed_at_ns(&self) -> u64 {
319 self.observed_ns
320 }
321
322 pub fn check_reservation(
329 &mut self,
330 caller: Principal,
331 claims: &PortableClaims,
332 now_ns: u64,
333 ) -> Result<(), LoginRevocationError> {
334 self.reservation(caller, claims, now_ns).map(|_| ())
335 }
336
337 pub fn with_reservation<T>(
345 &mut self,
346 caller: Principal,
347 verified: &VerifiedPortableClaims,
348 now_ns: u64,
349 commit: impl FnOnce(&LoginRevocationChange) -> Result<T, LoginRevocationError>,
350 ) -> Result<T, LoginRevocationError> {
351 let record = self.reservation(caller, verified.claims(), now_ns)?;
352 if verified.verified_at_ns() != now_ns {
353 return Err(LoginRevocationError::InvalidPolicy);
354 }
355 let value = commit(&LoginRevocationChange {
356 audience: self.audience,
357 caller,
358 record,
359 })?;
360 self.install(caller, record);
361 Ok(value)
362 }
363
364 pub fn with_revocation(
377 &mut self,
378 unknown_budget: &mut SessionAdmissionBudget,
379 caller: Principal,
380 now_ns: u64,
381 commit: impl FnOnce(&LoginRevocationChange) -> Result<(), LoginRevocationError>,
382 ) -> Result<u64, LoginRevocationError> {
383 self.observe(caller, now_ns)?;
384 let old = self
385 .records
386 .get(&caller)
387 .copied()
388 .filter(|r| r.until_ns > now_ns);
389 let record = if let Some(old) = old.filter(|r| r.revoked) {
390 old
391 } else {
392 let until_ns = retention(now_ns, self.skew_ns)?;
393 if old.is_some_and(|r| r.until_ns > until_ns) {
394 return Err(LoginRevocationError::Clock);
395 }
396 Record {
397 until_ns,
398 login: old.and_then(|r| r.login),
399 revoked: true,
400 admitted_pool: old.is_some_and(|r| r.admitted_pool),
401 }
402 };
403 self.capacity(caller, record)?;
404 if old.is_none() {
405 unknown_budget
406 .consume(now_ns)
407 .map_err(|error| match error {
408 AdmissionBudgetError::Exhausted => LoginRevocationError::Capacity,
409 AdmissionBudgetError::ClockRollback => LoginRevocationError::Clock,
410 AdmissionBudgetError::InvalidPolicy => LoginRevocationError::InvalidPolicy,
411 })?;
412 }
413 commit(&LoginRevocationChange {
414 audience: self.audience,
415 caller,
416 record,
417 })?;
418 self.install(caller, record);
419 Ok(record.until_ns)
420 }
421
422 pub fn is_revoked(
425 &mut self,
426 caller: Principal,
427 now_ns: u64,
428 ) -> Result<bool, LoginRevocationError> {
429 self.observe(caller, now_ns)?;
430 Ok(self
431 .records
432 .get(&caller)
433 .is_some_and(|r| r.revoked && r.until_ns > now_ns))
434 }
435
436 pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
439 self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
440 }
441 pub fn prune_with_commit(
446 &mut self,
447 now_ns: u64,
448 budget: usize,
449 commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
450 ) -> Result<usize, LoginRevocationError> {
451 self.clock(now_ns)?;
452 let callers: Vec<_> = self
453 .expiry
454 .iter()
455 .take_while(|(until, _)| *until <= now_ns)
456 .take(budget.min(128))
457 .map(|(_, caller)| *caller)
458 .collect();
459 if callers.is_empty() {
460 return Ok(0);
461 }
462 let mut counts = self.occupancy();
463 for caller in &callers {
464 if self.records[caller].admitted_pool {
465 counts.0 -= 1;
466 } else {
467 counts.1 -= 1;
468 }
469 }
470 commit(&callers, counts)?;
471 for caller in &callers {
472 self.remove(*caller);
473 }
474 Ok(callers.len())
475 }
476
477 fn reservation(
478 &mut self,
479 caller: Principal,
480 claims: &PortableClaims,
481 now_ns: u64,
482 ) -> Result<Record, LoginRevocationError> {
483 self.observe(caller, now_ns)?;
484 validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
485 let login_deadline_ns = claims.login_deadline_ns;
486 if claims.audience != self.audience
487 || browser_principal(&claims.browser_public_key) != caller
488 {
489 return Err(LoginRevocationError::InvalidPolicy);
490 }
491 let until_ns = login_deadline_ns
492 .checked_add(self.skew_ns)
493 .ok_or(LoginRevocationError::Clock)?;
494 let login = LoginBinding {
495 issuer: claims.issuer,
496 subject: claims.account_subject,
497 started_at_ns: claims.login_started_at_ns,
498 deadline_ns: login_deadline_ns,
499 };
500 if let Some(old) = self.records.get(&caller).filter(|r| r.until_ns > now_ns) {
501 if old.revoked {
502 return Err(LoginRevocationError::Revoked);
503 }
504 if old.login != Some(login) {
505 return Err(LoginRevocationError::InvalidPolicy);
506 }
507 }
508 let record = Record {
509 until_ns,
510 login: Some(login),
511 revoked: false,
512 admitted_pool: true,
513 };
514 self.capacity(caller, record)?;
515 Ok(record)
516 }
517
518 fn clock(&mut self, now_ns: u64) -> Result<(), LoginRevocationError> {
519 if now_ns < self.observed_ns {
520 return Err(LoginRevocationError::Clock);
521 }
522 self.observed_ns = now_ns;
523 Ok(())
524 }
525
526 fn observe(&mut self, caller: Principal, now_ns: u64) -> Result<(), LoginRevocationError> {
527 let bytes = caller.as_slice();
528 if bytes.len() != 29 || bytes.last() != Some(&2) {
529 return Err(LoginRevocationError::InvalidCaller);
530 }
531 self.clock(now_ns)
532 }
533
534 fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
535 let reuses_pool = self
536 .records
537 .get(&caller)
538 .is_some_and(|old| old.admitted_pool == record.admitted_pool);
539 let (count, limit) = if record.admitted_pool {
540 (self.admitted, self.max_admitted)
541 } else {
542 (self.unknown, self.max_unknown)
543 };
544 if !reuses_pool && count >= limit {
545 return Err(LoginRevocationError::Capacity);
546 }
547 Ok(())
548 }
549
550 fn remove(&mut self, caller: Principal) {
551 if let Some(old) = self.records.remove(&caller) {
552 self.expiry.remove(&(old.until_ns, caller));
553 if old.admitted_pool {
554 self.admitted -= 1;
555 } else {
556 self.unknown -= 1;
557 }
558 }
559 }
560
561 fn install(&mut self, caller: Principal, record: Record) {
562 self.remove(caller);
563 if record.admitted_pool {
564 self.admitted += 1;
565 } else {
566 self.unknown += 1;
567 }
568 self.expiry.insert((record.until_ns, caller));
569 self.records.insert(caller, record);
570 }
571}
572
573fn retention(now_ns: u64, skew_ns: u64) -> Result<u64, LoginRevocationError> {
574 skew_ns
575 .checked_mul(2)
576 .and_then(|skew| crate::limits::MAX_LOGIN_NS.checked_add(skew))
577 .and_then(|ttl| now_ns.checked_add(ttl))
578 .ok_or(LoginRevocationError::Clock)
579}