Skip to main content

cheers_server/
camp.rs

1//! Camp-principal bootstrap — provision a camp on behalf of a user U.
2//!
3//! See `.yah/docs/working/mcp-auth-and-ownership.md` §Camp bootstrap. This
4//! module is the origin-side implementation of the doc's mint flow #2
5//! provisioning step:
6//!
7//! 1. Yubaba (authenticated as its service principal) calls
8//!    `POST /admin/camps/bootstrap` with a [`NewCampPrincipal`] + a
9//!    [`UserDelegation`] (a payload signed by user U via the yah-side W122
10//!    flow). The HTTP layer lives in `cheers-axum`.
11//! 2. [`CampAuthority::provision`] verifies the delegation's Ed25519
12//!    signature against a key trusted for U (per [`UserSigningKeyStore`]),
13//!    allocates a [`Principal`] of [`kind = Camp`](PrincipalKind::Camp) with
14//!    `bound_to = Some(user:U)`, persists the delegation as the auditable
15//!    "U authorised camp C" record, and issues a [`CampBootstrapCredential`]
16//!    that the camp later exchanges for a short-TTL access token via
17//!    [`McpAuthority::mint_bootstrap`](crate::mcp_authority::McpAuthority::mint_bootstrap).
18//! 3. Cascading revocation: [`CampAuthority::revoke_user_cascade`] flips
19//!    every camp principal `bound_to: U` to [`Revoked`](PrincipalStatus::Revoked)
20//!    in one sweep. Revoking U revokes every camp bound to U; revoking the
21//!    camp alone does NOT touch U.
22//!
23//! The crate-direction invariant from R019 still holds: this module is
24//! origin-only (it verifies a signed delegation and persists a credential).
25//! `cheers-verify` consumers (kamaji, CF Worker) never depend on this
26//! crate — they verify the MCP-call tokens minted via path #2, not the
27//! bootstrap credential itself.
28
29use std::collections::HashMap;
30use std::sync::{Arc, Mutex};
31
32use async_trait::async_trait;
33use base64::Engine as _;
34use base64::engine::general_purpose::URL_SAFE_NO_PAD;
35use serde::{Deserialize, Serialize};
36
37use cheers_core::{
38    DelegationError, Principal, PrincipalError, PrincipalId, PrincipalKind, PrincipalStatus,
39    StoreError, UserDelegation,
40};
41
42// ----------------------------------------------------------------------------
43// User signing keys — trusted Ed25519 pubkeys for delegation verification.
44// ----------------------------------------------------------------------------
45
46/// Lifecycle state of a user's signing key — mirrors
47/// [`SigningKeyStatus`](crate::service_principal::SigningKeyStatus) on the
48/// service-principal side. Only `Active` keys are accepted for delegation
49/// verification.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[non_exhaustive]
52#[serde(rename_all = "snake_case")]
53pub enum UserSigningKeyStatus {
54    Active,
55    Revoked,
56}
57
58/// One Ed25519 pubkey trusted for delegations signed by a user.
59///
60/// `public_key` is the raw 32-byte Ed25519 key — wire encoding matches
61/// [`UserDelegation::user_signing_key`](cheers_core::UserDelegation): base64url
62/// no-pad.
63///
64/// The (cheers-side) **enrollment flow** that registers these keys is W122 on
65/// the yah side — out of scope for R020-F10. This struct + the trait below
66/// are the surface that flow eventually wires into; the in-memory impl + the
67/// trait shape land here so the camp-bootstrap path can be tested today.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[non_exhaustive]
70pub struct UserSigningKey {
71    pub kid: String,
72    /// The user this key signs for. `kind` MUST be [`PrincipalKind::User`].
73    pub user: PrincipalId,
74    #[serde(with = "ed25519_public_key_serde")]
75    pub public_key: [u8; 32],
76    pub status: UserSigningKeyStatus,
77    pub created_at: i64,
78}
79
80impl UserSigningKey {
81    /// Build a key record. Cross-crate construction needs a constructor
82    /// because the struct is `#[non_exhaustive]` (matches the pattern
83    /// `OwnershipRow::new` / `RefreshTokenRecord::new` use).
84    pub fn new(
85        kid: impl Into<String>,
86        user: PrincipalId,
87        public_key: [u8; 32],
88        status: UserSigningKeyStatus,
89        created_at: i64,
90    ) -> Self {
91        Self {
92            kid: kid.into(),
93            user,
94            public_key,
95            status,
96            created_at,
97        }
98    }
99}
100
101mod ed25519_public_key_serde {
102    use super::*;
103    use serde::de::Error as DeError;
104
105    pub fn serialize<S: serde::Serializer>(bytes: &[u8; 32], ser: S) -> Result<S::Ok, S::Error> {
106        ser.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
107    }
108
109    pub fn deserialize<'de, D: serde::Deserializer<'de>>(de: D) -> Result<[u8; 32], D::Error> {
110        let s = String::deserialize(de)?;
111        let raw = URL_SAFE_NO_PAD
112            .decode(s.as_bytes())
113            .map_err(|e| D::Error::custom(format!("invalid base64url public_key: {e}")))?;
114        raw.try_into().map_err(|v: Vec<u8>| {
115            D::Error::custom(format!("expected 32 public_key bytes, got {}", v.len()))
116        })
117    }
118}
119
120/// Persistence for [`UserSigningKey`] records.
121///
122/// The single hot-path query is "what Ed25519 keys are trusted for user
123/// `<U>`?" — the authority loads them at provision time and verifies the
124/// supplied delegation against each `Active` key until one matches.
125#[async_trait]
126pub trait UserSigningKeyStore: Send + Sync {
127    /// Every `Active` key trusted for `user`. Order is unspecified.
128    async fn list_active_for_user(
129        &self,
130        user: &PrincipalId,
131    ) -> Result<Vec<UserSigningKey>, StoreError>;
132}
133
134/// In-memory [`UserSigningKeyStore`] for tests and single-node bootstrapping.
135/// Cheap to `clone` — shares one backing map.
136#[derive(Default, Clone, Debug)]
137pub struct MemoryUserSigningKeyStore {
138    inner: Arc<Mutex<Vec<UserSigningKey>>>,
139}
140
141impl MemoryUserSigningKeyStore {
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Test/bootstrapping helper — push a key into the trusted set.
147    pub fn insert(&self, key: UserSigningKey) {
148        self.inner
149            .lock()
150            .expect("user-signing-key store mutex poisoned")
151            .push(key);
152    }
153}
154
155#[async_trait]
156impl UserSigningKeyStore for MemoryUserSigningKeyStore {
157    async fn list_active_for_user(
158        &self,
159        user: &PrincipalId,
160    ) -> Result<Vec<UserSigningKey>, StoreError> {
161        let g = self.inner.lock().expect("user-signing-key store mutex poisoned");
162        Ok(g.iter()
163            .filter(|k| &k.user == user && matches!(k.status, UserSigningKeyStatus::Active))
164            .cloned()
165            .collect())
166    }
167}
168
169// ----------------------------------------------------------------------------
170// Camp principal persistence.
171// ----------------------------------------------------------------------------
172
173/// One row in the camp-bootstrap-credential table.
174///
175/// The opaque `token` is a 32-byte CSPRNG secret encoded base64url-no-pad —
176/// the camp presents this string to the future `/token` endpoint to mint
177/// short-TTL access tokens. Stored verbatim (cheers retains the secret
178/// because the camp must present it back; this is in contrast to the
179/// service-principal Ed25519 secret which leaves cheers exactly once).
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[non_exhaustive]
182pub struct CampBootstrapCredential {
183    pub token: String,
184    /// The camp principal this credential authenticates for. `kind` is
185    /// always [`PrincipalKind::Camp`]; the authority refuses to construct
186    /// otherwise.
187    pub camp_id: PrincipalId,
188    pub created_at: i64,
189    pub expires_at: i64,
190    /// Set once the camp principal was revoked (cascade or direct).
191    pub revoked: bool,
192}
193
194/// Provision input — what callers hand to [`CampAuthority::provision`].
195///
196/// `desired_id` becomes the bare half of `camp:<desired_id>`. The authority
197/// refuses if a camp with that id already exists.
198#[derive(Debug, Clone, PartialEq, Eq)]
199#[non_exhaustive]
200pub struct NewCampPrincipal {
201    pub bound_to: PrincipalId,
202    pub desired_id: String,
203}
204
205impl NewCampPrincipal {
206    pub fn new(bound_to: PrincipalId, desired_id: impl Into<String>) -> Self {
207        Self {
208            bound_to,
209            desired_id: desired_id.into(),
210        }
211    }
212}
213
214/// The output of a successful [`CampAuthority::provision`] call — the camp
215/// principal record and the long-lived bootstrap credential the caller hands
216/// to yubaba.
217#[derive(Debug, Clone, PartialEq, Eq)]
218#[non_exhaustive]
219pub struct ProvisionedCamp {
220    pub principal: Principal,
221    pub credential: CampBootstrapCredential,
222}
223
224/// Persistence for the camp-principal surface — principal records,
225/// bootstrap credentials, and the retained delegation audit trail.
226///
227/// The trait is the **principal/credential/delegation** triple as one store
228/// (one transactional surface): every provision inserts exactly one
229/// principal row, one credential row, and one delegation row. Cascade
230/// revocation is the only non-insert operation — a single sweep over the
231/// principal table.
232///
233/// Time arguments are unix-seconds, signed — same shape as
234/// [`OwnershipStore`](crate::OwnershipStore).
235#[async_trait]
236pub trait CampPrincipalStore: Send + Sync {
237    /// Insert a fresh camp principal record. Returns [`StoreError::Conflict`]
238    /// if the id already exists; the authority surfaces that as
239    /// [`CampAuthorityError::AlreadyExists`].
240    async fn insert_principal(&self, principal: &Principal) -> Result<(), StoreError>;
241
242    /// Look up the camp principal record. `None` if no such principal
243    /// exists.
244    async fn get_principal(
245        &self,
246        id: &PrincipalId,
247    ) -> Result<Option<Principal>, StoreError>;
248
249    /// Insert a freshly-issued bootstrap credential. Returns
250    /// [`StoreError::Conflict`] if `token` is already taken (the authority
251    /// generates from a 256-bit CSPRNG, so collisions are
252    /// cryptographically negligible).
253    async fn insert_credential(&self, cred: &CampBootstrapCredential) -> Result<(), StoreError>;
254
255    /// Look up a bootstrap credential by its opaque token. `None` if no
256    /// such credential exists.
257    async fn get_credential(
258        &self,
259        token: &str,
260    ) -> Result<Option<CampBootstrapCredential>, StoreError>;
261
262    /// Persist the user-signed delegation as the auditable "user U
263    /// authorised camp C" record. The store is append-only for delegations:
264    /// once written, the row is never modified (revocation rides on the
265    /// principal, not the delegation).
266    async fn insert_delegation(&self, delegation: &UserDelegation)
267        -> Result<(), StoreError>;
268
269    /// Cascade revoke — flip every camp principal whose `bound_to` matches
270    /// `user` to [`Revoked`](PrincipalStatus::Revoked), and mark every
271    /// associated bootstrap credential as `revoked = true`. Returns the
272    /// count of camp principals newly revoked.
273    ///
274    /// Soundness rests on the column invariant: `bound_to` on a camp
275    /// principal is always a user principal. A non-user input matches
276    /// nothing.
277    async fn revoke_camps_bound_to(
278        &self,
279        user: &PrincipalId,
280        now: i64,
281    ) -> Result<u64, StoreError>;
282}
283
284// ----------------------------------------------------------------------------
285// MemoryCampPrincipalStore — test-only impl.
286// ----------------------------------------------------------------------------
287
288#[derive(Default, Clone, Debug)]
289pub struct MemoryCampPrincipalStore {
290    inner: Arc<Mutex<MemoryCampInner>>,
291}
292
293#[derive(Default, Debug)]
294struct MemoryCampInner {
295    principals: HashMap<PrincipalId, Principal>,
296    credentials: HashMap<String, CampBootstrapCredential>,
297    delegations: Vec<UserDelegation>,
298}
299
300impl MemoryCampPrincipalStore {
301    pub fn new() -> Self {
302        Self::default()
303    }
304
305    /// Test affordance — return every retained delegation in insertion order.
306    /// The persistent impl exposes this through an audit-read endpoint
307    /// (R020-F14); the memory impl just hands them back.
308    pub fn delegations(&self) -> Vec<UserDelegation> {
309        self.inner
310            .lock()
311            .expect("camp-principal store mutex poisoned")
312            .delegations
313            .clone()
314    }
315}
316
317#[async_trait]
318impl CampPrincipalStore for MemoryCampPrincipalStore {
319    async fn insert_principal(&self, principal: &Principal) -> Result<(), StoreError> {
320        let mut g = self.inner.lock().expect("camp-principal store mutex poisoned");
321        if g.principals.contains_key(&principal.id) {
322            return Err(StoreError::Conflict);
323        }
324        g.principals.insert(principal.id.clone(), principal.clone());
325        Ok(())
326    }
327
328    async fn get_principal(
329        &self,
330        id: &PrincipalId,
331    ) -> Result<Option<Principal>, StoreError> {
332        let g = self.inner.lock().expect("camp-principal store mutex poisoned");
333        Ok(g.principals.get(id).cloned())
334    }
335
336    async fn insert_credential(&self, cred: &CampBootstrapCredential) -> Result<(), StoreError> {
337        let mut g = self.inner.lock().expect("camp-principal store mutex poisoned");
338        if g.credentials.contains_key(&cred.token) {
339            return Err(StoreError::Conflict);
340        }
341        g.credentials.insert(cred.token.clone(), cred.clone());
342        Ok(())
343    }
344
345    async fn get_credential(
346        &self,
347        token: &str,
348    ) -> Result<Option<CampBootstrapCredential>, StoreError> {
349        let g = self.inner.lock().expect("camp-principal store mutex poisoned");
350        Ok(g.credentials.get(token).cloned())
351    }
352
353    async fn insert_delegation(
354        &self,
355        delegation: &UserDelegation,
356    ) -> Result<(), StoreError> {
357        let mut g = self.inner.lock().expect("camp-principal store mutex poisoned");
358        g.delegations.push(delegation.clone());
359        Ok(())
360    }
361
362    async fn revoke_camps_bound_to(
363        &self,
364        user: &PrincipalId,
365        _now: i64,
366    ) -> Result<u64, StoreError> {
367        let mut g = self.inner.lock().expect("camp-principal store mutex poisoned");
368        let mut camp_ids: Vec<PrincipalId> = Vec::new();
369        let mut newly_revoked = 0u64;
370        for p in g.principals.values_mut() {
371            if p.bound_to.as_ref() == Some(user) && p.status == PrincipalStatus::Active {
372                p.status = PrincipalStatus::Revoked;
373                camp_ids.push(p.id.clone());
374                newly_revoked += 1;
375            }
376        }
377        for cred in g.credentials.values_mut() {
378            if camp_ids.contains(&cred.camp_id) {
379                cred.revoked = true;
380            }
381        }
382        Ok(newly_revoked)
383    }
384}
385
386// ----------------------------------------------------------------------------
387// CampAuthority — origin facade for provisioning + cascade revocation.
388// ----------------------------------------------------------------------------
389
390/// TTL defaults for a bootstrap credential.
391///
392/// Long-lived per `.yah/docs/working/mcp-auth-and-ownership.md` §TTLs — the
393/// camp uses this credential to mint short-TTL access tokens for months/years,
394/// and rotation (when added) registers a fresh credential alongside the old
395/// one.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397#[non_exhaustive]
398pub struct CampBootstrapPolicy {
399    pub credential_ttl_seconds: i64,
400}
401
402impl CampBootstrapPolicy {
403    /// 1 year (365 days). The doc only commits to "long-lived"; one year is
404    /// a defensible split between operator-rotation cadence and "rare enough
405    /// to not be a hot path".
406    pub const DEFAULT_CREDENTIAL_TTL_SECONDS: i64 = 365 * 24 * 60 * 60;
407
408    pub fn new(credential_ttl_seconds: i64) -> Self {
409        Self {
410            credential_ttl_seconds,
411        }
412    }
413
414    pub fn with_credential_ttl(mut self, seconds: i64) -> Self {
415        self.credential_ttl_seconds = seconds;
416        self
417    }
418}
419
420impl Default for CampBootstrapPolicy {
421    fn default() -> Self {
422        Self {
423            credential_ttl_seconds: Self::DEFAULT_CREDENTIAL_TTL_SECONDS,
424        }
425    }
426}
427
428/// Typed failure modes for [`CampAuthority`] operations.
429///
430/// HTTP layer maps these to status codes (camps route in `cheers-axum`):
431/// `AlreadyExists` → 409, `InvalidDelegation` → 400, `UntrustedSigningKey` →
432/// 401, `DelegationMismatch` / `DelegationExpired` → 400,
433/// `BadSignature` → 401, `WrongPrincipalKind` → 500, `Store` → 500.
434#[derive(Debug, thiserror::Error)]
435#[non_exhaustive]
436pub enum CampAuthorityError {
437    /// A camp principal with the requested `desired_id` already exists.
438    #[error("camp principal '{0}' already exists")]
439    AlreadyExists(PrincipalId),
440    /// `bound_to` (in [`NewCampPrincipal`]) is not a user principal — a
441    /// programmer error, since the HTTP layer's parser would have caught it.
442    #[error("camp-authority requires bound_to: kind=user; got {0}")]
443    WrongPrincipalKind(PrincipalKind),
444    /// The presented [`UserDelegation`] violates its own invariants
445    /// (non-user `bound_to`, empty `camp_id`, …). Surfaces only when the
446    /// caller pre-built a `UserDelegation` value through a path that
447    /// bypassed [`UserDelegation::new`] — over the wire,
448    /// [`UserDelegation`] deserialization runs the same check.
449    #[error(transparent)]
450    InvalidDelegation(#[from] DelegationError),
451    /// The delegation's `bound_to` doesn't match the [`NewCampPrincipal::bound_to`],
452    /// or its `camp_id` doesn't match `desired_id`. The delegation says "U
453    /// authorises camp C"; if the provision request doesn't, the binding is
454    /// not what was signed.
455    #[error("delegation mismatch: {0}")]
456    DelegationMismatch(&'static str),
457    /// `delegation.expires_at <= now`.
458    #[error("delegation expired (expires_at <= now)")]
459    DelegationExpired,
460    /// The delegation's `user_signing_key` is not in the trusted set for
461    /// `bound_to` (no [`UserSigningKey`] row, or the matching row is
462    /// `Revoked`).
463    #[error("untrusted user signing key for {0}")]
464    UntrustedSigningKey(PrincipalId),
465    /// Ed25519 signature failed to verify under the supplied
466    /// `user_signing_key`.
467    #[error("bad delegation signature")]
468    BadSignature,
469    /// Programmer error reaching the authority with a non-camp principal id
470    /// for cascade revoke / similar — bubbles
471    /// [`PrincipalError`](cheers_core::PrincipalError).
472    #[error(transparent)]
473    Principal(#[from] PrincipalError),
474    /// Underlying store failure.
475    #[error(transparent)]
476    Store(#[from] StoreError),
477}
478
479/// Origin-side facade for camp-principal provisioning + cascade revocation.
480///
481/// Generic over the [`CampPrincipalStore`] and [`UserSigningKeyStore`] impls
482/// (not `dyn`) so the assembled deployment surfaces in the type. Holds a
483/// [`CampBootstrapPolicy`] so the credential TTL is set at construction.
484pub struct CampAuthority<S, K> {
485    store: S,
486    signing_keys: K,
487    policy: CampBootstrapPolicy,
488}
489
490impl<S, K> std::fmt::Debug for CampAuthority<S, K> {
491    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492        f.debug_struct("CampAuthority")
493            .field("policy", &self.policy)
494            .finish_non_exhaustive()
495    }
496}
497
498impl<S, K> CampAuthority<S, K>
499where
500    S: CampPrincipalStore,
501    K: UserSigningKeyStore,
502{
503    pub fn new(store: S, signing_keys: K) -> Self {
504        Self {
505            store,
506            signing_keys,
507            policy: CampBootstrapPolicy::default(),
508        }
509    }
510
511    pub fn with_policy(mut self, policy: CampBootstrapPolicy) -> Self {
512        self.policy = policy;
513        self
514    }
515
516    pub fn policy(&self) -> &CampBootstrapPolicy {
517        &self.policy
518    }
519
520    pub fn store(&self) -> &S {
521        &self.store
522    }
523
524    pub fn signing_keys(&self) -> &K {
525        &self.signing_keys
526    }
527
528    /// Provision a camp principal on behalf of a user.
529    ///
530    /// Steps in order — any failure aborts before the next side-effect:
531    ///
532    /// 1. Validate `input.bound_to.kind == User`.
533    /// 2. Validate the delegation matches the provision request
534    ///    (`bound_to`, `camp_id`).
535    /// 3. Reject an expired delegation (`expires_at <= now`).
536    /// 4. Look up the trusted Ed25519 keys for `bound_to`. Reject if
537    ///    `delegation.user_signing_key` is not in that set.
538    /// 5. Verify the Ed25519 signature over [`UserDelegation::signing_payload`].
539    /// 6. Build the [`Principal`] (kind=Camp, bound_to=Some(user)) via
540    ///    [`Principal::try_new`].
541    /// 7. Insert the principal, retain the delegation as audit, generate a
542    ///    fresh CSPRNG bootstrap credential, persist + return it.
543    pub async fn provision(
544        &self,
545        input: NewCampPrincipal,
546        delegation: UserDelegation,
547        now: i64,
548    ) -> Result<ProvisionedCamp, CampAuthorityError> {
549        if input.bound_to.kind != PrincipalKind::User {
550            return Err(CampAuthorityError::WrongPrincipalKind(input.bound_to.kind));
551        }
552        if delegation.bound_to != input.bound_to {
553            return Err(CampAuthorityError::DelegationMismatch(
554                "delegation.bound_to does not match provision.bound_to",
555            ));
556        }
557        if delegation.camp_id != input.desired_id {
558            return Err(CampAuthorityError::DelegationMismatch(
559                "delegation.camp_id does not match provision.desired_id",
560            ));
561        }
562        if delegation.is_expired_at(now) {
563            return Err(CampAuthorityError::DelegationExpired);
564        }
565
566        let trusted = self
567            .signing_keys
568            .list_active_for_user(&delegation.bound_to)
569            .await?;
570        if !trusted
571            .iter()
572            .any(|k| k.public_key == delegation.user_signing_key)
573        {
574            return Err(CampAuthorityError::UntrustedSigningKey(
575                delegation.bound_to.clone(),
576            ));
577        }
578
579        verify_delegation_signature(&delegation)?;
580
581        let camp_id = PrincipalId::camp(input.desired_id.clone());
582        let principal = Principal::try_new(
583            camp_id.clone(),
584            Some(input.bound_to.clone()),
585            PrincipalStatus::Active,
586            now,
587        )?;
588        match self.store.insert_principal(&principal).await {
589            Ok(()) => {}
590            Err(StoreError::Conflict) => {
591                return Err(CampAuthorityError::AlreadyExists(camp_id));
592            }
593            Err(other) => return Err(other.into()),
594        }
595        self.store.insert_delegation(&delegation).await?;
596
597        let credential = CampBootstrapCredential {
598            token: mint_credential_token(),
599            camp_id: camp_id.clone(),
600            created_at: now,
601            expires_at: now + self.policy.credential_ttl_seconds,
602            revoked: false,
603        };
604        self.store.insert_credential(&credential).await?;
605
606        Ok(ProvisionedCamp {
607            principal,
608            credential,
609        })
610    }
611
612    /// Cascade revoke — flip every camp principal `bound_to = user` to
613    /// `Revoked` and mark their bootstrap credentials revoked. Idempotent:
614    /// calling repeatedly returns 0 after the first sweep.
615    ///
616    /// Returns [`CampAuthorityError::WrongPrincipalKind`] if `user.kind` is
617    /// not `User` — protects against accidentally cascading a service /
618    /// camp principal (which the store query would silently no-op on).
619    pub async fn revoke_user_cascade(
620        &self,
621        user: &PrincipalId,
622        now: i64,
623    ) -> Result<u64, CampAuthorityError> {
624        if user.kind != PrincipalKind::User {
625            return Err(CampAuthorityError::WrongPrincipalKind(user.kind));
626        }
627        Ok(self.store.revoke_camps_bound_to(user, now).await?)
628    }
629}
630
631/// Verify the Ed25519 signature carried in `delegation`.
632///
633/// The delegation embeds the pubkey the signature should verify under; the
634/// CALLER (the authority) is responsible for proving that pubkey is trusted
635/// for `delegation.bound_to` before invoking this — this function is the
636/// pure crypto step.
637fn verify_delegation_signature(
638    delegation: &UserDelegation,
639) -> Result<(), CampAuthorityError> {
640    use ed25519_compact::{PublicKey, Signature};
641
642    let pk = PublicKey::from_slice(&delegation.user_signing_key)
643        .map_err(|_| CampAuthorityError::BadSignature)?;
644    let sig = Signature::from_slice(&delegation.signature)
645        .map_err(|_| CampAuthorityError::BadSignature)?;
646    let payload = delegation.signing_payload();
647    pk.verify(&payload, &sig)
648        .map_err(|_| CampAuthorityError::BadSignature)
649}
650
651/// 256-bit base64url-no-pad CSPRNG secret. Matches the entropy of the
652/// refresh-token primitive in `refresh.rs`.
653fn mint_credential_token() -> String {
654    let mut bytes = [0u8; 32];
655    getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
656    URL_SAFE_NO_PAD.encode(bytes)
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use ed25519_compact::{KeyPair, Seed};
663    use pollster::block_on;
664
665    fn keypair_from_seed(seed: u8) -> KeyPair {
666        KeyPair::from_seed(Seed::from_slice(&[seed; 32]).unwrap())
667    }
668
669    fn signed_delegation(
670        kp: &KeyPair,
671        bound_to: PrincipalId,
672        camp_id: &str,
673        issued_at: i64,
674        expires_at: i64,
675    ) -> UserDelegation {
676        // Build with a placeholder signature, then re-sign over the canonical
677        // payload — same flow a real signer (W122 client) would follow.
678        let unsigned = UserDelegation::new(
679            bound_to,
680            camp_id,
681            issued_at,
682            expires_at,
683            *kp.pk,
684            [0u8; 64],
685        )
686        .unwrap();
687        let payload = unsigned.signing_payload();
688        let sig = kp.sk.sign(&payload, None);
689        let mut bytes = [0u8; 64];
690        bytes.copy_from_slice(sig.as_ref());
691        UserDelegation::new(
692            unsigned.bound_to,
693            unsigned.camp_id,
694            unsigned.issued_at,
695            unsigned.expires_at,
696            unsigned.user_signing_key,
697            bytes,
698        )
699        .unwrap()
700    }
701
702    fn trust_key(store: &MemoryUserSigningKeyStore, user: &PrincipalId, pubkey: [u8; 32]) {
703        store.insert(UserSigningKey {
704            kid: "test-key".into(),
705            user: user.clone(),
706            public_key: pubkey,
707            status: UserSigningKeyStatus::Active,
708            created_at: 0,
709        });
710    }
711
712    fn rig() -> (
713        CampAuthority<MemoryCampPrincipalStore, MemoryUserSigningKeyStore>,
714        MemoryCampPrincipalStore,
715        MemoryUserSigningKeyStore,
716    ) {
717        let store = MemoryCampPrincipalStore::new();
718        let keys = MemoryUserSigningKeyStore::new();
719        let authority = CampAuthority::new(store.clone(), keys.clone());
720        (authority, store, keys)
721    }
722
723    // ---- policy -----------------------------------------------------------
724
725    #[test]
726    fn policy_default_is_one_year() {
727        let p = CampBootstrapPolicy::default();
728        assert_eq!(p.credential_ttl_seconds, 365 * 24 * 60 * 60);
729        let p = CampBootstrapPolicy::new(60).with_credential_ttl(120);
730        assert_eq!(p.credential_ttl_seconds, 120);
731    }
732
733    // ---- provision: happy path --------------------------------------------
734
735    #[test]
736    fn provision_happy_path_allocates_camp_and_credential() {
737        let (authority, store, keys) = rig();
738        let user = PrincipalId::user("alice");
739        let kp = keypair_from_seed(1);
740        trust_key(&keys, &user, *kp.pk);
741
742        let delegation =
743            signed_delegation(&kp, user.clone(), "camp-xyz", 1_000, 1_600);
744
745        block_on(async {
746            let prov = authority
747                .provision(
748                    NewCampPrincipal::new(user.clone(), "camp-xyz"),
749                    delegation.clone(),
750                    1_100,
751                )
752                .await
753                .unwrap();
754
755            // Camp principal carries the right shape.
756            assert_eq!(prov.principal.id, PrincipalId::camp("camp-xyz"));
757            assert_eq!(prov.principal.bound_to.as_ref(), Some(&user));
758            assert_eq!(prov.principal.status, PrincipalStatus::Active);
759            assert_eq!(prov.principal.created_at, 1_100);
760
761            // Bootstrap credential carries a non-empty token + correct expiry.
762            assert_eq!(prov.credential.camp_id, PrincipalId::camp("camp-xyz"));
763            assert!(!prov.credential.token.is_empty());
764            assert_eq!(prov.credential.created_at, 1_100);
765            assert_eq!(
766                prov.credential.expires_at,
767                1_100 + CampBootstrapPolicy::DEFAULT_CREDENTIAL_TTL_SECONDS
768            );
769            assert!(!prov.credential.revoked);
770
771            // Store side-effects.
772            let stored_principal = store
773                .get_principal(&PrincipalId::camp("camp-xyz"))
774                .await
775                .unwrap()
776                .unwrap();
777            assert_eq!(stored_principal, prov.principal);
778
779            let stored_cred = store
780                .get_credential(&prov.credential.token)
781                .await
782                .unwrap()
783                .unwrap();
784            assert_eq!(stored_cred, prov.credential);
785
786            // Delegation retained for audit.
787            assert_eq!(store.delegations(), vec![delegation]);
788        });
789    }
790
791    #[test]
792    fn provision_secret_token_decodes_to_32_bytes() {
793        let (authority, _store, keys) = rig();
794        let user = PrincipalId::user("alice");
795        let kp = keypair_from_seed(2);
796        trust_key(&keys, &user, *kp.pk);
797        let delegation =
798            signed_delegation(&kp, user.clone(), "camp-a", 1_000, 1_600);
799
800        block_on(async {
801            let prov = authority
802                .provision(
803                    NewCampPrincipal::new(user, "camp-a"),
804                    delegation,
805                    1_100,
806                )
807                .await
808                .unwrap();
809            let raw = URL_SAFE_NO_PAD
810                .decode(prov.credential.token.as_bytes())
811                .unwrap();
812            assert_eq!(raw.len(), 32, "credential token is a 256-bit secret");
813        });
814    }
815
816    #[test]
817    fn provision_two_camps_for_same_user_succeed_with_distinct_ids() {
818        let (authority, _, keys) = rig();
819        let user = PrincipalId::user("alice");
820        let kp = keypair_from_seed(3);
821        trust_key(&keys, &user, *kp.pk);
822
823        block_on(async {
824            for camp in ["camp-one", "camp-two"] {
825                let d = signed_delegation(&kp, user.clone(), camp, 1_000, 1_600);
826                authority
827                    .provision(
828                        NewCampPrincipal::new(user.clone(), camp),
829                        d,
830                        1_100,
831                    )
832                    .await
833                    .unwrap();
834            }
835        });
836    }
837
838    // ---- provision: rejection paths ---------------------------------------
839
840    #[test]
841    fn provision_rejects_non_user_bound_to_at_authority_boundary() {
842        // Wrong-kind bound_to is a programmer error — the HTTP layer would
843        // catch it earlier, but the authority is the last line of defense.
844        let (authority, _, _) = rig();
845        let kp = keypair_from_seed(4);
846        // Build a delegation that bypasses UserDelegation::new (we go through
847        // it but the authority sees a mismatched NewCampPrincipal.bound_to —
848        // the test below covers that). Here the authority itself runs the
849        // kind check first.
850        let user = PrincipalId::user("alice");
851        let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
852
853        block_on(async {
854            let err = authority
855                .provision(
856                    NewCampPrincipal::new(PrincipalId::service("svc"), "c"),
857                    d,
858                    1_100,
859                )
860                .await
861                .unwrap_err();
862            match err {
863                CampAuthorityError::WrongPrincipalKind(k) => {
864                    assert_eq!(k, PrincipalKind::Service);
865                }
866                other => panic!("expected WrongPrincipalKind, got {other:?}"),
867            }
868        });
869    }
870
871    #[test]
872    fn provision_rejects_delegation_bound_to_mismatch() {
873        let (authority, _, keys) = rig();
874        let user_a = PrincipalId::user("alice");
875        let user_b = PrincipalId::user("bob");
876        let kp = keypair_from_seed(5);
877        // Trust kp for BOTH users — so the rejection is the bound_to mismatch
878        // alone, not a missing trust binding.
879        trust_key(&keys, &user_a, *kp.pk);
880        trust_key(&keys, &user_b, *kp.pk);
881        // Delegation says bob authorises camp-x.
882        let d = signed_delegation(&kp, user_b.clone(), "camp-x", 1_000, 1_600);
883
884        block_on(async {
885            // Provision request says alice authorises camp-x.
886            let err = authority
887                .provision(
888                    NewCampPrincipal::new(user_a.clone(), "camp-x"),
889                    d,
890                    1_100,
891                )
892                .await
893                .unwrap_err();
894            assert!(
895                matches!(err, CampAuthorityError::DelegationMismatch(msg) if msg.contains("bound_to")),
896                "got {err:?}"
897            );
898        });
899    }
900
901    #[test]
902    fn provision_rejects_delegation_camp_id_mismatch() {
903        let (authority, _, keys) = rig();
904        let user = PrincipalId::user("alice");
905        let kp = keypair_from_seed(6);
906        trust_key(&keys, &user, *kp.pk);
907        // Delegation says alice authorises camp-x …
908        let d = signed_delegation(&kp, user.clone(), "camp-x", 1_000, 1_600);
909
910        block_on(async {
911            // … but the provision request asks for camp-y.
912            let err = authority
913                .provision(
914                    NewCampPrincipal::new(user, "camp-y"),
915                    d,
916                    1_100,
917                )
918                .await
919                .unwrap_err();
920            assert!(
921                matches!(err, CampAuthorityError::DelegationMismatch(msg) if msg.contains("camp_id")),
922                "got {err:?}"
923            );
924        });
925    }
926
927    #[test]
928    fn provision_rejects_expired_delegation() {
929        let (authority, _, keys) = rig();
930        let user = PrincipalId::user("alice");
931        let kp = keypair_from_seed(7);
932        trust_key(&keys, &user, *kp.pk);
933        let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
934
935        block_on(async {
936            let err = authority
937                .provision(NewCampPrincipal::new(user, "c"), d, 1_600)
938                .await
939                .unwrap_err();
940            assert!(matches!(err, CampAuthorityError::DelegationExpired), "got {err:?}");
941        });
942    }
943
944    #[test]
945    fn provision_rejects_untrusted_signing_key() {
946        let (authority, _, _keys) = rig();
947        let user = PrincipalId::user("alice");
948        let kp = keypair_from_seed(8);
949        // Deliberately do NOT register kp.pk as trusted for alice.
950        let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
951
952        block_on(async {
953            let err = authority
954                .provision(NewCampPrincipal::new(user.clone(), "c"), d, 1_100)
955                .await
956                .unwrap_err();
957            match err {
958                CampAuthorityError::UntrustedSigningKey(u) => assert_eq!(u, user),
959                other => panic!("expected UntrustedSigningKey, got {other:?}"),
960            }
961        });
962    }
963
964    #[test]
965    fn provision_rejects_revoked_signing_key() {
966        // A trust-key row exists but with status=Revoked — must NOT verify.
967        let (authority, _, keys) = rig();
968        let user = PrincipalId::user("alice");
969        let kp = keypair_from_seed(9);
970        keys.insert(UserSigningKey {
971            kid: "stale".into(),
972            user: user.clone(),
973            public_key: *kp.pk,
974            status: UserSigningKeyStatus::Revoked,
975            created_at: 0,
976        });
977        let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
978
979        block_on(async {
980            let err = authority
981                .provision(NewCampPrincipal::new(user, "c"), d, 1_100)
982                .await
983                .unwrap_err();
984            assert!(matches!(err, CampAuthorityError::UntrustedSigningKey(_)), "got {err:?}");
985        });
986    }
987
988    #[test]
989    fn provision_rejects_bad_signature() {
990        let (authority, _, keys) = rig();
991        let user = PrincipalId::user("alice");
992        let kp = keypair_from_seed(10);
993        trust_key(&keys, &user, *kp.pk);
994
995        // Build a properly-signed delegation, then mutate the signed bytes —
996        // pubkey trust check still passes, signature verify must fail.
997        let mut d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
998        d.signature[0] ^= 0x01;
999
1000        block_on(async {
1001            let err = authority
1002                .provision(NewCampPrincipal::new(user, "c"), d, 1_100)
1003                .await
1004                .unwrap_err();
1005            assert!(matches!(err, CampAuthorityError::BadSignature), "got {err:?}");
1006        });
1007    }
1008
1009    #[test]
1010    fn provision_rejects_signature_from_other_keypair_in_trusted_set() {
1011        // The supplied pubkey IS trusted, but the signature was made by a
1012        // DIFFERENT key. Subtle attack: smuggle a trusted pubkey alongside a
1013        // forged signature. verify_delegation_signature catches it because
1014        // the signature doesn't validate under the embedded pubkey.
1015        let (authority, _, keys) = rig();
1016        let user = PrincipalId::user("alice");
1017        let kp_trusted = keypair_from_seed(11);
1018        let kp_other = keypair_from_seed(12);
1019        trust_key(&keys, &user, *kp_trusted.pk);
1020
1021        // Sign with kp_other, claim the pubkey is kp_trusted's.
1022        let payload_d = UserDelegation::new(
1023            user.clone(),
1024            "c",
1025            1_000,
1026            1_600,
1027            *kp_trusted.pk,
1028            [0u8; 64],
1029        )
1030        .unwrap();
1031        let sig = kp_other.sk.sign(&payload_d.signing_payload(), None);
1032        let mut sig_bytes = [0u8; 64];
1033        sig_bytes.copy_from_slice(sig.as_ref());
1034        let forged = UserDelegation::new(
1035            payload_d.bound_to,
1036            payload_d.camp_id,
1037            payload_d.issued_at,
1038            payload_d.expires_at,
1039            payload_d.user_signing_key,
1040            sig_bytes,
1041        )
1042        .unwrap();
1043
1044        block_on(async {
1045            let err = authority
1046                .provision(NewCampPrincipal::new(user, "c"), forged, 1_100)
1047                .await
1048                .unwrap_err();
1049            assert!(matches!(err, CampAuthorityError::BadSignature), "got {err:?}");
1050        });
1051    }
1052
1053    #[test]
1054    fn provision_rejects_duplicate_camp_id() {
1055        let (authority, _, keys) = rig();
1056        let user = PrincipalId::user("alice");
1057        let kp = keypair_from_seed(13);
1058        trust_key(&keys, &user, *kp.pk);
1059
1060        block_on(async {
1061            let d = signed_delegation(&kp, user.clone(), "dup", 1_000, 1_600);
1062            authority
1063                .provision(
1064                    NewCampPrincipal::new(user.clone(), "dup"),
1065                    d.clone(),
1066                    1_100,
1067                )
1068                .await
1069                .unwrap();
1070            let err = authority
1071                .provision(NewCampPrincipal::new(user, "dup"), d, 1_100)
1072                .await
1073                .unwrap_err();
1074            match err {
1075                CampAuthorityError::AlreadyExists(id) => {
1076                    assert_eq!(id, PrincipalId::camp("dup"));
1077                }
1078                other => panic!("expected AlreadyExists, got {other:?}"),
1079            }
1080        });
1081    }
1082
1083    // ---- cascade revoke ---------------------------------------------------
1084
1085    #[test]
1086    fn revoke_user_cascade_flips_only_camps_bound_to_that_user() {
1087        let (authority, store, keys) = rig();
1088        let alice = PrincipalId::user("alice");
1089        let bob = PrincipalId::user("bob");
1090        let kp_a = keypair_from_seed(20);
1091        let kp_b = keypair_from_seed(21);
1092        trust_key(&keys, &alice, *kp_a.pk);
1093        trust_key(&keys, &bob, *kp_b.pk);
1094
1095        block_on(async {
1096            // Two camps for alice, one for bob.
1097            for camp in ["a-1", "a-2"] {
1098                let d = signed_delegation(&kp_a, alice.clone(), camp, 1_000, 1_600);
1099                authority
1100                    .provision(NewCampPrincipal::new(alice.clone(), camp), d, 1_100)
1101                    .await
1102                    .unwrap();
1103            }
1104            let d = signed_delegation(&kp_b, bob.clone(), "b-1", 1_000, 1_600);
1105            let b1 = authority
1106                .provision(NewCampPrincipal::new(bob.clone(), "b-1"), d, 1_100)
1107                .await
1108                .unwrap();
1109
1110            // Revoke alice — both alice camps flip, bob's stays Active.
1111            let count = authority.revoke_user_cascade(&alice, 2_000).await.unwrap();
1112            assert_eq!(count, 2);
1113
1114            for camp in ["a-1", "a-2"] {
1115                let p = store
1116                    .get_principal(&PrincipalId::camp(camp))
1117                    .await
1118                    .unwrap()
1119                    .unwrap();
1120                assert_eq!(p.status, PrincipalStatus::Revoked);
1121            }
1122            let bob_camp = store
1123                .get_principal(&PrincipalId::camp("b-1"))
1124                .await
1125                .unwrap()
1126                .unwrap();
1127            assert_eq!(bob_camp.status, PrincipalStatus::Active);
1128
1129            // bob's credential is unchanged.
1130            let bob_cred = store
1131                .get_credential(&b1.credential.token)
1132                .await
1133                .unwrap()
1134                .unwrap();
1135            assert!(!bob_cred.revoked);
1136        });
1137    }
1138
1139    #[test]
1140    fn revoke_user_cascade_is_idempotent() {
1141        let (authority, _, keys) = rig();
1142        let user = PrincipalId::user("alice");
1143        let kp = keypair_from_seed(30);
1144        trust_key(&keys, &user, *kp.pk);
1145
1146        block_on(async {
1147            let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
1148            authority
1149                .provision(NewCampPrincipal::new(user.clone(), "c"), d, 1_100)
1150                .await
1151                .unwrap();
1152
1153            let first = authority.revoke_user_cascade(&user, 2_000).await.unwrap();
1154            let second = authority.revoke_user_cascade(&user, 2_001).await.unwrap();
1155            assert_eq!(first, 1);
1156            assert_eq!(second, 0, "second sweep is a no-op");
1157        });
1158    }
1159
1160    #[test]
1161    fn revoke_user_cascade_revokes_associated_credentials() {
1162        let (authority, store, keys) = rig();
1163        let user = PrincipalId::user("alice");
1164        let kp = keypair_from_seed(31);
1165        trust_key(&keys, &user, *kp.pk);
1166
1167        block_on(async {
1168            let d = signed_delegation(&kp, user.clone(), "c", 1_000, 1_600);
1169            let prov = authority
1170                .provision(NewCampPrincipal::new(user.clone(), "c"), d, 1_100)
1171                .await
1172                .unwrap();
1173            assert!(!prov.credential.revoked);
1174
1175            authority.revoke_user_cascade(&user, 2_000).await.unwrap();
1176
1177            let cred = store
1178                .get_credential(&prov.credential.token)
1179                .await
1180                .unwrap()
1181                .unwrap();
1182            assert!(cred.revoked, "cascade must mark the credential revoked too");
1183        });
1184    }
1185
1186    #[test]
1187    fn revoke_user_cascade_rejects_non_user_kind() {
1188        let (authority, _, _) = rig();
1189        block_on(async {
1190            let err = authority
1191                .revoke_user_cascade(&PrincipalId::service("yubaba"), 1_000)
1192                .await
1193                .unwrap_err();
1194            match err {
1195                CampAuthorityError::WrongPrincipalKind(k) => {
1196                    assert_eq!(k, PrincipalKind::Service);
1197                }
1198                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1199            }
1200        });
1201    }
1202
1203    // ---- trait dyn-compat -------------------------------------------------
1204
1205    #[test]
1206    fn traits_are_dyn_compatible() {
1207        fn _s(_: &dyn CampPrincipalStore) {}
1208        fn _k(_: &dyn UserSigningKeyStore) {}
1209    }
1210
1211    // ---- serde ------------------------------------------------------------
1212
1213    #[test]
1214    fn user_signing_key_roundtrips_through_json_with_base64_pubkey() {
1215        let key = UserSigningKey {
1216            kid: "kid-1".into(),
1217            user: PrincipalId::user("alice"),
1218            public_key: [3u8; 32],
1219            status: UserSigningKeyStatus::Active,
1220            created_at: 100,
1221        };
1222        let json = serde_json::to_string(&key).unwrap();
1223        assert!(json.contains("\"public_key\":\""));
1224        assert!(!json.contains("[3,3,3"), "must NOT be a byte array: {json}");
1225        let back: UserSigningKey = serde_json::from_str(&json).unwrap();
1226        assert_eq!(back, key);
1227    }
1228
1229    #[test]
1230    fn camp_bootstrap_credential_roundtrips_json() {
1231        let cred = CampBootstrapCredential {
1232            token: "tok-1".into(),
1233            camp_id: PrincipalId::camp("c-1"),
1234            created_at: 100,
1235            expires_at: 200,
1236            revoked: false,
1237        };
1238        let json = serde_json::to_string(&cred).unwrap();
1239        let back: CampBootstrapCredential = serde_json::from_str(&json).unwrap();
1240        assert_eq!(back, cred);
1241    }
1242}