use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use cheers_core::{
DelegationError, Principal, PrincipalError, PrincipalId, PrincipalKind, PrincipalStatus,
StoreError, UserDelegation,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum UserSigningKeyStatus {
Active,
Revoked,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UserSigningKey {
pub kid: String,
pub user: PrincipalId,
#[serde(with = "ed25519_public_key_serde")]
pub public_key: [u8; 32],
pub status: UserSigningKeyStatus,
pub created_at: i64,
}
impl UserSigningKey {
pub fn new(
kid: impl Into<String>,
user: PrincipalId,
public_key: [u8; 32],
status: UserSigningKeyStatus,
created_at: i64,
) -> Self {
Self {
kid: kid.into(),
user,
public_key,
status,
created_at,
}
}
}
mod ed25519_public_key_serde {
use super::*;
use serde::de::Error as DeError;
pub fn serialize<S: serde::Serializer>(bytes: &[u8; 32], ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(de: D) -> Result<[u8; 32], D::Error> {
let s = String::deserialize(de)?;
let raw = URL_SAFE_NO_PAD
.decode(s.as_bytes())
.map_err(|e| D::Error::custom(format!("invalid base64url public_key: {e}")))?;
raw.try_into().map_err(|v: Vec<u8>| {
D::Error::custom(format!("expected 32 public_key bytes, got {}", v.len()))
})
}
}
#[async_trait]
pub trait UserSigningKeyStore: Send + Sync {
async fn list_active_for_user(
&self,
user: &PrincipalId,
) -> Result<Vec<UserSigningKey>, StoreError>;
}
#[derive(Default, Clone, Debug)]
pub struct MemoryUserSigningKeyStore {
inner: Arc<Mutex<Vec<UserSigningKey>>>,
}
impl MemoryUserSigningKeyStore {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&self, key: UserSigningKey) {
self.inner
.lock()
.expect("user-signing-key store mutex poisoned")
.push(key);
}
}
#[async_trait]
impl UserSigningKeyStore for MemoryUserSigningKeyStore {
async fn list_active_for_user(
&self,
user: &PrincipalId,
) -> Result<Vec<UserSigningKey>, StoreError> {
let g = self.inner.lock().expect("user-signing-key store mutex poisoned");
Ok(g.iter()
.filter(|k| &k.user == user && matches!(k.status, UserSigningKeyStatus::Active))
.cloned()
.collect())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CampBootstrapCredential {
pub token: String,
pub camp_id: PrincipalId,
pub created_at: i64,
pub expires_at: i64,
pub revoked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct NewCampPrincipal {
pub bound_to: PrincipalId,
pub desired_id: String,
}
impl NewCampPrincipal {
pub fn new(bound_to: PrincipalId, desired_id: impl Into<String>) -> Self {
Self {
bound_to,
desired_id: desired_id.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ProvisionedCamp {
pub principal: Principal,
pub credential: CampBootstrapCredential,
}
#[async_trait]
pub trait CampPrincipalStore: Send + Sync {
async fn insert_principal(&self, principal: &Principal) -> Result<(), StoreError>;
async fn get_principal(
&self,
id: &PrincipalId,
) -> Result<Option<Principal>, StoreError>;
async fn insert_credential(&self, cred: &CampBootstrapCredential) -> Result<(), StoreError>;
async fn get_credential(
&self,
token: &str,
) -> Result<Option<CampBootstrapCredential>, StoreError>;
async fn insert_delegation(&self, delegation: &UserDelegation)
-> Result<(), StoreError>;
async fn revoke_camps_bound_to(
&self,
user: &PrincipalId,
now: i64,
) -> Result<u64, StoreError>;
}
#[derive(Default, Clone, Debug)]
pub struct MemoryCampPrincipalStore {
inner: Arc<Mutex<MemoryCampInner>>,
}
#[derive(Default, Debug)]
struct MemoryCampInner {
principals: HashMap<PrincipalId, Principal>,
credentials: HashMap<String, CampBootstrapCredential>,
delegations: Vec<UserDelegation>,
}
impl MemoryCampPrincipalStore {
pub fn new() -> Self {
Self::default()
}
pub fn delegations(&self) -> Vec<UserDelegation> {
self.inner
.lock()
.expect("camp-principal store mutex poisoned")
.delegations
.clone()
}
}
#[async_trait]
impl CampPrincipalStore for MemoryCampPrincipalStore {
async fn insert_principal(&self, principal: &Principal) -> Result<(), StoreError> {
let mut g = self.inner.lock().expect("camp-principal store mutex poisoned");
if g.principals.contains_key(&principal.id) {
return Err(StoreError::Conflict);
}
g.principals.insert(principal.id.clone(), principal.clone());
Ok(())
}
async fn get_principal(
&self,
id: &PrincipalId,
) -> Result<Option<Principal>, StoreError> {
let g = self.inner.lock().expect("camp-principal store mutex poisoned");
Ok(g.principals.get(id).cloned())
}
async fn insert_credential(&self, cred: &CampBootstrapCredential) -> Result<(), StoreError> {
let mut g = self.inner.lock().expect("camp-principal store mutex poisoned");
if g.credentials.contains_key(&cred.token) {
return Err(StoreError::Conflict);
}
g.credentials.insert(cred.token.clone(), cred.clone());
Ok(())
}
async fn get_credential(
&self,
token: &str,
) -> Result<Option<CampBootstrapCredential>, StoreError> {
let g = self.inner.lock().expect("camp-principal store mutex poisoned");
Ok(g.credentials.get(token).cloned())
}
async fn insert_delegation(
&self,
delegation: &UserDelegation,
) -> Result<(), StoreError> {
let mut g = self.inner.lock().expect("camp-principal store mutex poisoned");
g.delegations.push(delegation.clone());
Ok(())
}
async fn revoke_camps_bound_to(
&self,
user: &PrincipalId,
_now: i64,
) -> Result<u64, StoreError> {
let mut g = self.inner.lock().expect("camp-principal store mutex poisoned");
let mut camp_ids: Vec<PrincipalId> = Vec::new();
let mut newly_revoked = 0u64;
for p in g.principals.values_mut() {
if p.bound_to.as_ref() == Some(user) && p.status == PrincipalStatus::Active {
p.status = PrincipalStatus::Revoked;
camp_ids.push(p.id.clone());
newly_revoked += 1;
}
}
for cred in g.credentials.values_mut() {
if camp_ids.contains(&cred.camp_id) {
cred.revoked = true;
}
}
Ok(newly_revoked)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct CampBootstrapPolicy {
pub credential_ttl_seconds: i64,
}
impl CampBootstrapPolicy {
pub const DEFAULT_CREDENTIAL_TTL_SECONDS: i64 = 365 * 24 * 60 * 60;
pub fn new(credential_ttl_seconds: i64) -> Self {
Self {
credential_ttl_seconds,
}
}
pub fn with_credential_ttl(mut self, seconds: i64) -> Self {
self.credential_ttl_seconds = seconds;
self
}
}
impl Default for CampBootstrapPolicy {
fn default() -> Self {
Self {
credential_ttl_seconds: Self::DEFAULT_CREDENTIAL_TTL_SECONDS,
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CampAuthorityError {
#[error("camp principal '{0}' already exists")]
AlreadyExists(PrincipalId),
#[error("camp-authority requires bound_to: kind=user; got {0}")]
WrongPrincipalKind(PrincipalKind),
#[error(transparent)]
InvalidDelegation(#[from] DelegationError),
#[error("delegation mismatch: {0}")]
DelegationMismatch(&'static str),
#[error("delegation expired (expires_at <= now)")]
DelegationExpired,
#[error("untrusted user signing key for {0}")]
UntrustedSigningKey(PrincipalId),
#[error("bad delegation signature")]
BadSignature,
#[error(transparent)]
Principal(#[from] PrincipalError),
#[error(transparent)]
Store(#[from] StoreError),
}
pub struct CampAuthority<S, K> {
store: S,
signing_keys: K,
policy: CampBootstrapPolicy,
}
impl<S, K> std::fmt::Debug for CampAuthority<S, K> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CampAuthority")
.field("policy", &self.policy)
.finish_non_exhaustive()
}
}
impl<S, K> CampAuthority<S, K>
where
S: CampPrincipalStore,
K: UserSigningKeyStore,
{
pub fn new(store: S, signing_keys: K) -> Self {
Self {
store,
signing_keys,
policy: CampBootstrapPolicy::default(),
}
}
pub fn with_policy(mut self, policy: CampBootstrapPolicy) -> Self {
self.policy = policy;
self
}
pub fn policy(&self) -> &CampBootstrapPolicy {
&self.policy
}
pub fn store(&self) -> &S {
&self.store
}
pub fn signing_keys(&self) -> &K {
&self.signing_keys
}
pub async fn provision(
&self,
input: NewCampPrincipal,
delegation: UserDelegation,
now: i64,
) -> Result<ProvisionedCamp, CampAuthorityError> {
if input.bound_to.kind != PrincipalKind::User {
return Err(CampAuthorityError::WrongPrincipalKind(input.bound_to.kind));
}
if delegation.bound_to != input.bound_to {
return Err(CampAuthorityError::DelegationMismatch(
"delegation.bound_to does not match provision.bound_to",
));
}
if delegation.camp_id != input.desired_id {
return Err(CampAuthorityError::DelegationMismatch(
"delegation.camp_id does not match provision.desired_id",
));
}
if delegation.is_expired_at(now) {
return Err(CampAuthorityError::DelegationExpired);
}
let trusted = self
.signing_keys
.list_active_for_user(&delegation.bound_to)
.await?;
if !trusted
.iter()
.any(|k| k.public_key == delegation.user_signing_key)
{
return Err(CampAuthorityError::UntrustedSigningKey(
delegation.bound_to.clone(),
));
}
verify_delegation_signature(&delegation)?;
let camp_id = PrincipalId::camp(input.desired_id.clone());
let principal = Principal::try_new(
camp_id.clone(),
Some(input.bound_to.clone()),
PrincipalStatus::Active,
now,
)?;
match self.store.insert_principal(&principal).await {
Ok(()) => {}
Err(StoreError::Conflict) => {
return Err(CampAuthorityError::AlreadyExists(camp_id));
}
Err(other) => return Err(other.into()),
}
self.store.insert_delegation(&delegation).await?;
let credential = CampBootstrapCredential {
token: mint_credential_token(),
camp_id: camp_id.clone(),
created_at: now,
expires_at: now + self.policy.credential_ttl_seconds,
revoked: false,
};
self.store.insert_credential(&credential).await?;
Ok(ProvisionedCamp {
principal,
credential,
})
}
pub async fn revoke_user_cascade(
&self,
user: &PrincipalId,
now: i64,
) -> Result<u64, CampAuthorityError> {
if user.kind != PrincipalKind::User {
return Err(CampAuthorityError::WrongPrincipalKind(user.kind));
}
Ok(self.store.revoke_camps_bound_to(user, now).await?)
}
}
fn verify_delegation_signature(
delegation: &UserDelegation,
) -> Result<(), CampAuthorityError> {
use ed25519_compact::{PublicKey, Signature};
let pk = PublicKey::from_slice(&delegation.user_signing_key)
.map_err(|_| CampAuthorityError::BadSignature)?;
let sig = Signature::from_slice(&delegation.signature)
.map_err(|_| CampAuthorityError::BadSignature)?;
let payload = delegation.signing_payload();
pk.verify(&payload, &sig)
.map_err(|_| CampAuthorityError::BadSignature)
}
fn mint_credential_token() -> String {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
URL_SAFE_NO_PAD.encode(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_compact::{KeyPair, Seed};
use pollster::block_on;
fn keypair_from_seed(seed: u8) -> KeyPair {
KeyPair::from_seed(Seed::from_slice(&[seed; 32]).unwrap())
}
fn signed_delegation(
kp: &KeyPair,
bound_to: PrincipalId,
camp_id: &str,
issued_at: i64,
expires_at: i64,
) -> UserDelegation {
let unsigned = UserDelegation::new(
bound_to,
camp_id,
issued_at,
expires_at,
*kp.pk,
[0u8; 64],
)
.unwrap();
let payload = unsigned.signing_payload();
let sig = kp.sk.sign(&payload, None);
let mut bytes = [0u8; 64];
bytes.copy_from_slice(sig.as_ref());
UserDelegation::new(
unsigned.bound_to,
unsigned.camp_id,
unsigned.issued_at,
unsigned.expires_at,
unsigned.user_signing_key,
bytes,
)
.unwrap()
}
fn trust_key(store: &MemoryUserSigningKeyStore, user: &PrincipalId, pubkey: [u8; 32]) {
store.insert(UserSigningKey {
kid: "test-key".into(),
user: user.clone(),
public_key: pubkey,
status: UserSigningKeyStatus::Active,
created_at: 0,
});
}
fn rig() -> (
CampAuthority<MemoryCampPrincipalStore, MemoryUserSigningKeyStore>,
MemoryCampPrincipalStore,
MemoryUserSigningKeyStore,
) {
let store = MemoryCampPrincipalStore::new();
let keys = MemoryUserSigningKeyStore::new();
let authority = CampAuthority::new(store.clone(), keys.clone());
(authority, store, keys)
}
#[test]
fn policy_default_is_one_year() {
let p = CampBootstrapPolicy::default();
assert_eq!(p.credential_ttl_seconds, 365 * 24 * 60 * 60);
let p = CampBootstrapPolicy::new(60).with_credential_ttl(120);
assert_eq!(p.credential_ttl_seconds, 120);
}
#[test]
fn provision_happy_path_allocates_camp_and_credential() {
let (authority, store, keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(1);
trust_key(&keys, &user, *kp.pk);
let delegation =
signed_delegation(&kp, user.clone(), "camp-xyz", 1_000, 1_600);
block_on(async {
let prov = authority
.provision(
NewCampPrincipal::new(user.clone(), "camp-xyz"),
delegation.clone(),
1_100,
)
.await
.unwrap();
assert_eq!(prov.principal.id, PrincipalId::camp("camp-xyz"));
assert_eq!(prov.principal.bound_to.as_ref(), Some(&user));
assert_eq!(prov.principal.status, PrincipalStatus::Active);
assert_eq!(prov.principal.created_at, 1_100);
assert_eq!(prov.credential.camp_id, PrincipalId::camp("camp-xyz"));
assert!(!prov.credential.token.is_empty());
assert_eq!(prov.credential.created_at, 1_100);
assert_eq!(
prov.credential.expires_at,
1_100 + CampBootstrapPolicy::DEFAULT_CREDENTIAL_TTL_SECONDS
);
assert!(!prov.credential.revoked);
let stored_principal = store
.get_principal(&PrincipalId::camp("camp-xyz"))
.await
.unwrap()
.unwrap();
assert_eq!(stored_principal, prov.principal);
let stored_cred = store
.get_credential(&prov.credential.token)
.await
.unwrap()
.unwrap();
assert_eq!(stored_cred, prov.credential);
assert_eq!(store.delegations(), vec![delegation]);
});
}
#[test]
fn provision_secret_token_decodes_to_32_bytes() {
let (authority, _store, keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(2);
trust_key(&keys, &user, *kp.pk);
let delegation =
signed_delegation(&kp, user.clone(), "camp-a", 1_000, 1_600);
block_on(async {
let prov = authority
.provision(
NewCampPrincipal::new(user, "camp-a"),
delegation,
1_100,
)
.await
.unwrap();
let raw = URL_SAFE_NO_PAD
.decode(prov.credential.token.as_bytes())
.unwrap();
assert_eq!(raw.len(), 32, "credential token is a 256-bit secret");
});
}
#[test]
fn provision_two_camps_for_same_user_succeed_with_distinct_ids() {
let (authority, _, keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(3);
trust_key(&keys, &user, *kp.pk);
block_on(async {
for camp in ["camp-one", "camp-two"] {
let d = signed_delegation(&kp, user.clone(), camp, 1_000, 1_600);
authority
.provision(
NewCampPrincipal::new(user.clone(), camp),
d,
1_100,
)
.await
.unwrap();
}
});
}
#[test]
fn provision_rejects_non_user_bound_to_at_authority_boundary() {
let (authority, _, _) = rig();
let kp = keypair_from_seed(4);
let user = PrincipalId::user("alice");
let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
block_on(async {
let err = authority
.provision(
NewCampPrincipal::new(PrincipalId::service("svc"), "c"),
d,
1_100,
)
.await
.unwrap_err();
match err {
CampAuthorityError::WrongPrincipalKind(k) => {
assert_eq!(k, PrincipalKind::Service);
}
other => panic!("expected WrongPrincipalKind, got {other:?}"),
}
});
}
#[test]
fn provision_rejects_delegation_bound_to_mismatch() {
let (authority, _, keys) = rig();
let user_a = PrincipalId::user("alice");
let user_b = PrincipalId::user("bob");
let kp = keypair_from_seed(5);
trust_key(&keys, &user_a, *kp.pk);
trust_key(&keys, &user_b, *kp.pk);
let d = signed_delegation(&kp, user_b.clone(), "camp-x", 1_000, 1_600);
block_on(async {
let err = authority
.provision(
NewCampPrincipal::new(user_a.clone(), "camp-x"),
d,
1_100,
)
.await
.unwrap_err();
assert!(
matches!(err, CampAuthorityError::DelegationMismatch(msg) if msg.contains("bound_to")),
"got {err:?}"
);
});
}
#[test]
fn provision_rejects_delegation_camp_id_mismatch() {
let (authority, _, keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(6);
trust_key(&keys, &user, *kp.pk);
let d = signed_delegation(&kp, user.clone(), "camp-x", 1_000, 1_600);
block_on(async {
let err = authority
.provision(
NewCampPrincipal::new(user, "camp-y"),
d,
1_100,
)
.await
.unwrap_err();
assert!(
matches!(err, CampAuthorityError::DelegationMismatch(msg) if msg.contains("camp_id")),
"got {err:?}"
);
});
}
#[test]
fn provision_rejects_expired_delegation() {
let (authority, _, keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(7);
trust_key(&keys, &user, *kp.pk);
let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
block_on(async {
let err = authority
.provision(NewCampPrincipal::new(user, "c"), d, 1_600)
.await
.unwrap_err();
assert!(matches!(err, CampAuthorityError::DelegationExpired), "got {err:?}");
});
}
#[test]
fn provision_rejects_untrusted_signing_key() {
let (authority, _, _keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(8);
let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
block_on(async {
let err = authority
.provision(NewCampPrincipal::new(user.clone(), "c"), d, 1_100)
.await
.unwrap_err();
match err {
CampAuthorityError::UntrustedSigningKey(u) => assert_eq!(u, user),
other => panic!("expected UntrustedSigningKey, got {other:?}"),
}
});
}
#[test]
fn provision_rejects_revoked_signing_key() {
let (authority, _, keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(9);
keys.insert(UserSigningKey {
kid: "stale".into(),
user: user.clone(),
public_key: *kp.pk,
status: UserSigningKeyStatus::Revoked,
created_at: 0,
});
let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
block_on(async {
let err = authority
.provision(NewCampPrincipal::new(user, "c"), d, 1_100)
.await
.unwrap_err();
assert!(matches!(err, CampAuthorityError::UntrustedSigningKey(_)), "got {err:?}");
});
}
#[test]
fn provision_rejects_bad_signature() {
let (authority, _, keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(10);
trust_key(&keys, &user, *kp.pk);
let mut d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
d.signature[0] ^= 0x01;
block_on(async {
let err = authority
.provision(NewCampPrincipal::new(user, "c"), d, 1_100)
.await
.unwrap_err();
assert!(matches!(err, CampAuthorityError::BadSignature), "got {err:?}");
});
}
#[test]
fn provision_rejects_signature_from_other_keypair_in_trusted_set() {
let (authority, _, keys) = rig();
let user = PrincipalId::user("alice");
let kp_trusted = keypair_from_seed(11);
let kp_other = keypair_from_seed(12);
trust_key(&keys, &user, *kp_trusted.pk);
let payload_d = UserDelegation::new(
user.clone(),
"c",
1_000,
1_600,
*kp_trusted.pk,
[0u8; 64],
)
.unwrap();
let sig = kp_other.sk.sign(&payload_d.signing_payload(), None);
let mut sig_bytes = [0u8; 64];
sig_bytes.copy_from_slice(sig.as_ref());
let forged = UserDelegation::new(
payload_d.bound_to,
payload_d.camp_id,
payload_d.issued_at,
payload_d.expires_at,
payload_d.user_signing_key,
sig_bytes,
)
.unwrap();
block_on(async {
let err = authority
.provision(NewCampPrincipal::new(user, "c"), forged, 1_100)
.await
.unwrap_err();
assert!(matches!(err, CampAuthorityError::BadSignature), "got {err:?}");
});
}
#[test]
fn provision_rejects_duplicate_camp_id() {
let (authority, _, keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(13);
trust_key(&keys, &user, *kp.pk);
block_on(async {
let d = signed_delegation(&kp, user.clone(), "dup", 1_000, 1_600);
authority
.provision(
NewCampPrincipal::new(user.clone(), "dup"),
d.clone(),
1_100,
)
.await
.unwrap();
let err = authority
.provision(NewCampPrincipal::new(user, "dup"), d, 1_100)
.await
.unwrap_err();
match err {
CampAuthorityError::AlreadyExists(id) => {
assert_eq!(id, PrincipalId::camp("dup"));
}
other => panic!("expected AlreadyExists, got {other:?}"),
}
});
}
#[test]
fn revoke_user_cascade_flips_only_camps_bound_to_that_user() {
let (authority, store, keys) = rig();
let alice = PrincipalId::user("alice");
let bob = PrincipalId::user("bob");
let kp_a = keypair_from_seed(20);
let kp_b = keypair_from_seed(21);
trust_key(&keys, &alice, *kp_a.pk);
trust_key(&keys, &bob, *kp_b.pk);
block_on(async {
for camp in ["a-1", "a-2"] {
let d = signed_delegation(&kp_a, alice.clone(), camp, 1_000, 1_600);
authority
.provision(NewCampPrincipal::new(alice.clone(), camp), d, 1_100)
.await
.unwrap();
}
let d = signed_delegation(&kp_b, bob.clone(), "b-1", 1_000, 1_600);
let b1 = authority
.provision(NewCampPrincipal::new(bob.clone(), "b-1"), d, 1_100)
.await
.unwrap();
let count = authority.revoke_user_cascade(&alice, 2_000).await.unwrap();
assert_eq!(count, 2);
for camp in ["a-1", "a-2"] {
let p = store
.get_principal(&PrincipalId::camp(camp))
.await
.unwrap()
.unwrap();
assert_eq!(p.status, PrincipalStatus::Revoked);
}
let bob_camp = store
.get_principal(&PrincipalId::camp("b-1"))
.await
.unwrap()
.unwrap();
assert_eq!(bob_camp.status, PrincipalStatus::Active);
let bob_cred = store
.get_credential(&b1.credential.token)
.await
.unwrap()
.unwrap();
assert!(!bob_cred.revoked);
});
}
#[test]
fn revoke_user_cascade_is_idempotent() {
let (authority, _, keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(30);
trust_key(&keys, &user, *kp.pk);
block_on(async {
let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
authority
.provision(NewCampPrincipal::new(user.clone(), "c"), d, 1_100)
.await
.unwrap();
let first = authority.revoke_user_cascade(&user, 2_000).await.unwrap();
let second = authority.revoke_user_cascade(&user, 2_001).await.unwrap();
assert_eq!(first, 1);
assert_eq!(second, 0, "second sweep is a no-op");
});
}
#[test]
fn revoke_user_cascade_revokes_associated_credentials() {
let (authority, store, keys) = rig();
let user = PrincipalId::user("alice");
let kp = keypair_from_seed(31);
trust_key(&keys, &user, *kp.pk);
block_on(async {
let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
let prov = authority
.provision(NewCampPrincipal::new(user.clone(), "c"), d, 1_100)
.await
.unwrap();
assert!(!prov.credential.revoked);
authority.revoke_user_cascade(&user, 2_000).await.unwrap();
let cred = store
.get_credential(&prov.credential.token)
.await
.unwrap()
.unwrap();
assert!(cred.revoked, "cascade must mark the credential revoked too");
});
}
#[test]
fn revoke_user_cascade_rejects_non_user_kind() {
let (authority, _, _) = rig();
block_on(async {
let err = authority
.revoke_user_cascade(&PrincipalId::service("yubaba"), 1_000)
.await
.unwrap_err();
match err {
CampAuthorityError::WrongPrincipalKind(k) => {
assert_eq!(k, PrincipalKind::Service);
}
other => panic!("expected WrongPrincipalKind, got {other:?}"),
}
});
}
#[test]
fn traits_are_dyn_compatible() {
fn _s(_: &dyn CampPrincipalStore) {}
fn _k(_: &dyn UserSigningKeyStore) {}
}
#[test]
fn user_signing_key_roundtrips_through_json_with_base64_pubkey() {
let key = UserSigningKey {
kid: "kid-1".into(),
user: PrincipalId::user("alice"),
public_key: [3u8; 32],
status: UserSigningKeyStatus::Active,
created_at: 100,
};
let json = serde_json::to_string(&key).unwrap();
assert!(json.contains("\"public_key\":\""));
assert!(!json.contains("[3,3,3"), "must NOT be a byte array: {json}");
let back: UserSigningKey = serde_json::from_str(&json).unwrap();
assert_eq!(back, key);
}
#[test]
fn camp_bootstrap_credential_roundtrips_json() {
let cred = CampBootstrapCredential {
token: "tok-1".into(),
camp_id: PrincipalId::camp("c-1"),
created_at: 100,
expires_at: 200,
revoked: false,
};
let json = serde_json::to_string(&cred).unwrap();
let back: CampBootstrapCredential = serde_json::from_str(&json).unwrap();
assert_eq!(back, cred);
}
}