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