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