use super::{VerifiedPortableClaims, browser_principal, validate_shape};
use crate::admission_budget::{AdmissionBudgetError, SessionAdmissionBudget};
use ic_auth_protocol_types::{AudienceId, PortableClaims, Principal};
use std::collections::{BTreeMap, BTreeSet};
use thiserror::Error;
#[cfg(feature = "sessions")]
const RECORD_DOMAIN: &[u8] = b"IC-AUTH-LOGIN-RECORD\0";
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum LoginRevocationError {
#[error("logout requires an authenticated self-authenticating caller")]
InvalidCaller,
#[error("invalid revocation limits or authenticated login deadline")]
InvalidPolicy,
#[error("login is locally revoked")]
Revoked,
#[error("revocation capacity is exhausted")]
Capacity,
#[error("revocation clock rolled back or retention arithmetic overflowed")]
Clock,
#[error("host session/replay transaction failed")]
Storage,
}
#[derive(Clone, Copy)]
#[cfg_attr(feature = "sessions", derive(serde::Serialize))]
#[cfg_attr(feature = "session-restore", derive(serde::Deserialize))]
#[cfg_attr(feature = "sessions", serde(deny_unknown_fields))]
struct Record {
until_ns: u64,
login: Option<LoginBinding>,
revoked: bool,
admitted_pool: bool,
}
#[derive(Clone, Copy, Eq, PartialEq)]
#[cfg_attr(feature = "sessions", derive(serde::Serialize))]
#[cfg_attr(feature = "session-restore", derive(serde::Deserialize))]
#[cfg_attr(feature = "sessions", serde(deny_unknown_fields))]
struct LoginBinding {
issuer: Principal,
subject: Principal,
started_at_ns: u64,
deadline_ns: u64,
}
pub struct LoginRevocationChange {
audience: AudienceId,
caller: Principal,
record: Record,
}
impl LoginRevocationChange {
#[cfg(feature = "sessions")]
pub fn encode(&self) -> Result<Vec<u8>, LoginRevocationError> {
crate::storage_codec::encode(
&(self.audience, self.caller, self.record),
RECORD_DOMAIN,
1024,
LoginRevocationError::Storage,
LoginRevocationError::InvalidPolicy,
)
}
#[cfg(feature = "session-restore")]
pub fn decode(bytes: &[u8]) -> Result<Self, LoginRevocationError> {
let (audience, caller, record) = crate::storage_codec::decode(
bytes,
RECORD_DOMAIN,
1024,
|| LoginRevocationError::InvalidPolicy,
LoginRevocationError::InvalidPolicy,
)?;
let change = Self {
audience,
caller,
record,
};
let caller = change.caller.as_slice();
if caller.len() != 29 || caller.last() != Some(&2) {
return Err(LoginRevocationError::InvalidCaller);
}
match change.record.login {
Some(login)
if change.record.admitted_pool
&& login.issuer != Principal::anonymous()
&& login.subject != Principal::anonymous()
&& login.started_at_ns < login.deadline_ns
&& login.deadline_ns - login.started_at_ns <= crate::limits::MAX_LOGIN_NS => {}
None if !change.record.admitted_pool && change.record.revoked => {}
_ => return Err(LoginRevocationError::InvalidPolicy),
}
Ok(change)
}
pub fn audience(&self) -> AudienceId {
self.audience
}
pub fn caller(&self) -> Principal {
self.caller
}
pub fn until_ns(&self) -> u64 {
self.record.until_ns
}
pub fn revoked(&self) -> bool {
self.record.revoked
}
pub fn admitted_pool(&self) -> bool {
self.record.admitted_pool
}
pub fn binding(&self) -> Option<(Principal, Principal, u64, u64)> {
self.record.login.map(|login| {
(
login.issuer,
login.subject,
login.started_at_ns,
login.deadline_ns,
)
})
}
}
pub struct MemoryLoginRevocations {
audience: AudienceId,
records: BTreeMap<Principal, Record>,
expiry: BTreeSet<(u64, Principal)>,
max_admitted: usize,
max_unknown: usize,
admitted: usize,
unknown: usize,
skew_ns: u64,
observed_ns: u64,
}
impl MemoryLoginRevocations {
#[cfg(feature = "session-restore")]
pub fn records(&self) -> impl ExactSizeIterator<Item = LoginRevocationChange> + '_ {
self.records
.iter()
.map(|(&caller, &record)| LoginRevocationChange {
audience: self.audience,
caller,
record,
})
}
#[cfg(feature = "session-restore")]
pub fn restore_records(
mut self,
observed_ns: u64,
expected_counts: (usize, usize),
records: impl IntoIterator<Item = LoginRevocationChange>,
) -> Result<Self, LoginRevocationError> {
if !self.records.is_empty()
|| observed_ns > self.observed_ns
|| expected_counts.0 > self.max_admitted
|| expected_counts.1 > self.max_unknown
{
return Err(LoginRevocationError::InvalidPolicy);
}
let max_until = retention(observed_ns, self.skew_ns)?;
for change in records {
if change.audience != self.audience || self.records.contains_key(&change.caller) {
return Err(LoginRevocationError::InvalidPolicy);
}
let record = change.record;
if record.until_ns == 0 || record.until_ns > max_until {
return Err(LoginRevocationError::InvalidPolicy);
}
if let Some(login) = record.login {
let minimum = login
.deadline_ns
.checked_add(self.skew_ns)
.ok_or(LoginRevocationError::Clock)?;
if login.started_at_ns > observed_ns
|| record.until_ns < minimum
|| (!record.revoked && record.until_ns != minimum)
{
return Err(LoginRevocationError::InvalidPolicy);
}
}
let (used, expected) = if record.admitted_pool {
(self.admitted, expected_counts.0)
} else {
(self.unknown, expected_counts.1)
};
if used >= expected {
return Err(LoginRevocationError::Capacity);
}
self.install(change.caller, record);
}
if self.occupancy() != expected_counts {
return Err(LoginRevocationError::InvalidPolicy);
}
Ok(self)
}
pub fn new(
audience: AudienceId,
max_admitted: usize,
max_unknown: usize,
skew_ns: u64,
now_ns: u64,
) -> Result<Self, LoginRevocationError> {
if max_admitted == 0 || max_unknown == 0 {
return Err(LoginRevocationError::InvalidPolicy);
}
retention(now_ns, skew_ns)?;
Ok(Self {
audience,
records: BTreeMap::new(),
expiry: BTreeSet::new(),
max_admitted,
max_unknown,
admitted: 0,
unknown: 0,
skew_ns,
observed_ns: now_ns,
})
}
#[cfg(feature = "sessions")]
pub(crate) fn audience(&self) -> AudienceId {
self.audience
}
#[cfg(feature = "sessions")]
pub(crate) fn check_session(
&mut self,
caller: Principal,
issuer: Principal,
subject: Principal,
started_at_ns: u64,
deadline_ns: u64,
now_ns: u64,
) -> Result<(), LoginRevocationError> {
self.observe(caller, now_ns)?;
let record = self
.records
.get(&caller)
.filter(|record| record.until_ns > now_ns)
.ok_or(LoginRevocationError::InvalidPolicy)?;
if record.revoked {
return Err(LoginRevocationError::Revoked);
}
if record.login
!= Some(LoginBinding {
issuer,
subject,
started_at_ns,
deadline_ns,
})
{
return Err(LoginRevocationError::InvalidPolicy);
}
Ok(())
}
pub fn occupancy(&self) -> (usize, usize) {
(self.admitted, self.unknown)
}
pub fn observed_at_ns(&self) -> u64 {
self.observed_ns
}
pub fn check_reservation(
&mut self,
caller: Principal,
claims: &PortableClaims,
now_ns: u64,
) -> Result<(), LoginRevocationError> {
self.reservation(caller, claims, now_ns).map(|_| ())
}
pub fn with_reservation<T>(
&mut self,
caller: Principal,
verified: &VerifiedPortableClaims,
now_ns: u64,
commit: impl FnOnce(&LoginRevocationChange) -> Result<T, LoginRevocationError>,
) -> Result<T, LoginRevocationError> {
let record = self.reservation(caller, verified.claims(), now_ns)?;
if verified.verified_at_ns() != now_ns {
return Err(LoginRevocationError::InvalidPolicy);
}
let value = commit(&LoginRevocationChange {
audience: self.audience,
caller,
record,
})?;
self.install(caller, record);
Ok(value)
}
pub fn with_revocation(
&mut self,
unknown_budget: &mut SessionAdmissionBudget,
caller: Principal,
now_ns: u64,
commit: impl FnOnce(&LoginRevocationChange) -> Result<(), LoginRevocationError>,
) -> Result<u64, LoginRevocationError> {
self.observe(caller, now_ns)?;
let old = self
.records
.get(&caller)
.copied()
.filter(|r| r.until_ns > now_ns);
let record = if let Some(old) = old.filter(|r| r.revoked) {
old
} else {
let until_ns = retention(now_ns, self.skew_ns)?;
if old.is_some_and(|r| r.until_ns > until_ns) {
return Err(LoginRevocationError::Clock);
}
Record {
until_ns,
login: old.and_then(|r| r.login),
revoked: true,
admitted_pool: old.is_some_and(|r| r.admitted_pool),
}
};
self.capacity(caller, record)?;
if old.is_none() {
unknown_budget
.consume(now_ns)
.map_err(|error| match error {
AdmissionBudgetError::Exhausted => LoginRevocationError::Capacity,
AdmissionBudgetError::ClockRollback => LoginRevocationError::Clock,
AdmissionBudgetError::InvalidPolicy => LoginRevocationError::InvalidPolicy,
})?;
}
commit(&LoginRevocationChange {
audience: self.audience,
caller,
record,
})?;
self.install(caller, record);
Ok(record.until_ns)
}
pub fn is_revoked(
&mut self,
caller: Principal,
now_ns: u64,
) -> Result<bool, LoginRevocationError> {
self.observe(caller, now_ns)?;
Ok(self
.records
.get(&caller)
.is_some_and(|r| r.revoked && r.until_ns > now_ns))
}
pub fn prune(&mut self, now_ns: u64, budget: usize) -> Result<usize, LoginRevocationError> {
self.prune_with_commit(now_ns, budget, |_, _| Ok(()))
}
pub fn prune_with_commit(
&mut self,
now_ns: u64,
budget: usize,
commit: impl FnOnce(&[Principal], (usize, usize)) -> Result<(), LoginRevocationError>,
) -> Result<usize, LoginRevocationError> {
self.clock(now_ns)?;
let callers: Vec<_> = self
.expiry
.iter()
.take_while(|(until, _)| *until <= now_ns)
.take(budget.min(128))
.map(|(_, caller)| *caller)
.collect();
if callers.is_empty() {
return Ok(0);
}
let mut counts = self.occupancy();
for caller in &callers {
if self.records[caller].admitted_pool {
counts.0 -= 1;
} else {
counts.1 -= 1;
}
}
commit(&callers, counts)?;
for caller in &callers {
self.remove(*caller);
}
Ok(callers.len())
}
fn reservation(
&mut self,
caller: Principal,
claims: &PortableClaims,
now_ns: u64,
) -> Result<Record, LoginRevocationError> {
self.observe(caller, now_ns)?;
validate_shape(claims).map_err(|_| LoginRevocationError::InvalidPolicy)?;
let login_deadline_ns = claims.login_deadline_ns;
if claims.audience != self.audience
|| browser_principal(&claims.browser_public_key) != caller
{
return Err(LoginRevocationError::InvalidPolicy);
}
let until_ns = login_deadline_ns
.checked_add(self.skew_ns)
.ok_or(LoginRevocationError::Clock)?;
let login = LoginBinding {
issuer: claims.issuer,
subject: claims.account_subject,
started_at_ns: claims.login_started_at_ns,
deadline_ns: login_deadline_ns,
};
if let Some(old) = self.records.get(&caller).filter(|r| r.until_ns > now_ns) {
if old.revoked {
return Err(LoginRevocationError::Revoked);
}
if old.login != Some(login) {
return Err(LoginRevocationError::InvalidPolicy);
}
}
let record = Record {
until_ns,
login: Some(login),
revoked: false,
admitted_pool: true,
};
self.capacity(caller, record)?;
Ok(record)
}
fn clock(&mut self, now_ns: u64) -> Result<(), LoginRevocationError> {
if now_ns < self.observed_ns {
return Err(LoginRevocationError::Clock);
}
self.observed_ns = now_ns;
Ok(())
}
fn observe(&mut self, caller: Principal, now_ns: u64) -> Result<(), LoginRevocationError> {
let bytes = caller.as_slice();
if bytes.len() != 29 || bytes.last() != Some(&2) {
return Err(LoginRevocationError::InvalidCaller);
}
self.clock(now_ns)
}
fn capacity(&self, caller: Principal, record: Record) -> Result<(), LoginRevocationError> {
let reuses_pool = self
.records
.get(&caller)
.is_some_and(|old| old.admitted_pool == record.admitted_pool);
let (count, limit) = if record.admitted_pool {
(self.admitted, self.max_admitted)
} else {
(self.unknown, self.max_unknown)
};
if !reuses_pool && count >= limit {
return Err(LoginRevocationError::Capacity);
}
Ok(())
}
fn remove(&mut self, caller: Principal) {
if let Some(old) = self.records.remove(&caller) {
self.expiry.remove(&(old.until_ns, caller));
if old.admitted_pool {
self.admitted -= 1;
} else {
self.unknown -= 1;
}
}
}
fn install(&mut self, caller: Principal, record: Record) {
self.remove(caller);
if record.admitted_pool {
self.admitted += 1;
} else {
self.unknown += 1;
}
self.expiry.insert((record.until_ns, caller));
self.records.insert(caller, record);
}
}
fn retention(now_ns: u64, skew_ns: u64) -> Result<u64, LoginRevocationError> {
skew_ns
.checked_mul(2)
.and_then(|skew| crate::limits::MAX_LOGIN_NS.checked_add(skew))
.and_then(|ttl| now_ns.checked_add(ttl))
.ok_or(LoginRevocationError::Clock)
}