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