Skip to main content

cheers_server/
service_principal.rs

1//! Service-principal lifecycle — provisioning, rotation, key publication.
2//!
3//! See `.yah/docs/working/mcp-auth-and-ownership.md` §Service principal
4//! bootstrap for the install-time contract this implements:
5//!
6//! 1. An operator-authenticated `POST /admin/service-principals` call
7//!    (HTTP layer is a peer ticket) provides a `desired_id` and the operator's
8//!    intended grants. Cheers allocates the [`Principal`] record, generates a
9//!    fresh Ed25519 keypair, persists the public half (kid'd, status=active),
10//!    and returns the **secret half exactly once** in the `ProvisionedKey`.
11//! 2. The consumer (yubaba) stores the secret in its config dir (mode 0600)
12//!    and mints its own short-lived MCP tokens from that keypair. Cheers
13//!    verifies those tokens via the JWKS endpoint (R020-F11) that publishes
14//!    the principal's public key alongside cheers's own signing keys.
15//! 3. Rotation registers a fresh keypair without invalidating in-flight
16//!    tokens: the previously-active key flips to `Retiring` with
17//!    `retire_at = now + overlap_seconds` (default 24 h, per
18//!    [`OverlapPolicy`]). The JWKS keeps publishing the retiring key until
19//!    `retire_at`, then [`prune_retired_keys`](ServicePrincipalAuthority::prune_retired_keys)
20//!    drops it.
21//!
22//! The crate-direction invariant from R019 still holds: this module is
23//! origin-only (it generates secret keys). The verify-side (kamaji, CF
24//! Worker) never depends on `cheers-server` and never sees a
25//! [`PasetoV4SecretMinter`] — it links `cheers-verify` and consumes the public
26//! halves via JWKS.
27
28use std::collections::HashMap;
29use std::sync::{Arc, Mutex};
30
31use async_trait::async_trait;
32use base64::Engine as _;
33use base64::engine::general_purpose::URL_SAFE_NO_PAD;
34use serde::{Deserialize, Serialize};
35
36use cheers_core::{
37    CodecError, Principal, PrincipalError, PrincipalId, PrincipalKind, PrincipalStatus, StoreError,
38};
39
40use crate::codec::PasetoV4SecretMinter;
41
42/// Lifecycle state of a signing key inside cheers's published JWKS.
43///
44/// `Active` keys are the ones cheers verifies tokens against without
45/// caveat. `Retiring` keys are still served in the JWKS (because in-flight
46/// tokens signed under them must still verify) but no consumer should mint
47/// new tokens with them — once `retire_at` has passed,
48/// [`prune_retired_keys`](ServicePrincipalAuthority::prune_retired_keys)
49/// drops them from the table and the next JWKS publication no longer
50/// includes them.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[non_exhaustive]
53#[serde(rename_all = "snake_case")]
54pub enum SigningKeyStatus {
55    Active,
56    Retiring,
57}
58
59/// One Ed25519 keypair (public half + lifecycle metadata) belonging to a
60/// service principal.
61///
62/// `public_key` is the raw 32-byte Ed25519 public key (PASETO V4's
63/// `AsymmetricPublicKey<V4>::as_bytes()` shape). It is the only key half cheers
64/// retains: the secret half is returned exactly once at provision/rotate time
65/// in [`ProvisionedKey::secret_key`] and never again.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[non_exhaustive]
68pub struct SigningKey {
69    /// Stable opaque identifier — the JWKS rotation handle. Unique across
70    /// the JWKS (not just within one principal); kamaji matches against
71    /// `kid` and falls back to a rate-limited refresh on unknown values.
72    pub kid: String,
73    /// Service principal this key signs for. `kind` is always
74    /// [`PrincipalKind::Service`] — provision/rotate refuse other kinds at
75    /// the authority layer.
76    pub principal_id: PrincipalId,
77    /// Raw Ed25519 public key — 32 bytes. Serialized to the JWKS as
78    /// base64url; in this struct kept as the byte array so a JWKS publisher
79    /// (R020-F11) doesn't have to re-decode.
80    #[serde(with = "public_key_serde")]
81    pub public_key: [u8; 32],
82    pub status: SigningKeyStatus,
83    pub created_at: i64,
84    /// `None` while the key is `Active`. Set to the unix-second drop time
85    /// when the key transitions to `Retiring` —
86    /// [`prune_retired_keys`](ServicePrincipalAuthority::prune_retired_keys)
87    /// removes rows whose `retire_at <= now`.
88    pub retire_at: Option<i64>,
89}
90
91impl SigningKey {
92    /// Build a [`SigningKey`] from its persisted parts.
93    ///
94    /// Provided so external [`ServicePrincipalStore`] impls (cheers-sqlx,
95    /// cheers-redis, …) can reconstruct rows without depending on the
96    /// `#[non_exhaustive]` struct expression — that compiles only from
97    /// inside this crate.
98    pub fn new(
99        kid: impl Into<String>,
100        principal_id: PrincipalId,
101        public_key: [u8; 32],
102        status: SigningKeyStatus,
103        created_at: i64,
104        retire_at: Option<i64>,
105    ) -> Self {
106        Self {
107            kid: kid.into(),
108            principal_id,
109            public_key,
110            status,
111            created_at,
112            retire_at,
113        }
114    }
115}
116
117/// Custom serde: the raw 32-byte Ed25519 public key rides on the wire as a
118/// base64url-no-pad string. Keeps the JSON shape readable and matches the
119/// JWKS key encoding R020-F11 will publish.
120mod public_key_serde {
121    use super::*;
122    use serde::de::Error as DeError;
123
124    pub fn serialize<S: serde::Serializer>(bytes: &[u8; 32], ser: S) -> Result<S::Ok, S::Error> {
125        ser.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
126    }
127
128    pub fn deserialize<'de, D: serde::Deserializer<'de>>(de: D) -> Result<[u8; 32], D::Error> {
129        let s = String::deserialize(de)?;
130        let decoded = URL_SAFE_NO_PAD
131            .decode(s.as_bytes())
132            .map_err(|e| D::Error::custom(format!("invalid base64url public_key: {e}")))?;
133        decoded
134            .try_into()
135            .map_err(|v: Vec<u8>| D::Error::custom(format!("expected 32 public_key bytes, got {}", v.len())))
136    }
137}
138
139/// Provision input — what the operator hands to
140/// [`ServicePrincipalAuthority::provision`].
141///
142/// `desired_id` becomes the bare id half of `svc:<desired_id>`. The
143/// authority refuses if a principal with that id already exists.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[non_exhaustive]
146pub struct NewServicePrincipal {
147    pub desired_id: String,
148}
149
150impl NewServicePrincipal {
151    pub fn new(desired_id: impl Into<String>) -> Self {
152        Self {
153            desired_id: desired_id.into(),
154        }
155    }
156}
157
158/// The output of a provision or rotate call — the **only** time cheers
159/// hands out a secret half.
160///
161/// `secret_key` is the 64-byte PASETO V4 `seed || public` layout. The caller
162/// (yubaba install flow) writes it to its config dir (mode 0600). Cheers
163/// retains nothing of it: the matching pubkey lives in [`SigningKey`] and a
164/// lost secret is unrecoverable — the recovery path is to rotate and accept
165/// a new keypair.
166#[derive(Clone)]
167#[non_exhaustive]
168pub struct ProvisionedKey {
169    pub principal: Principal,
170    pub signing_key: SigningKey,
171    pub secret_key: [u8; 64],
172}
173
174impl std::fmt::Debug for ProvisionedKey {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        // Never print the secret bytes.
177        f.debug_struct("ProvisionedKey")
178            .field("principal", &self.principal)
179            .field("signing_key", &self.signing_key)
180            .field("secret_key", &"***OMITTED***")
181            .finish()
182    }
183}
184
185/// Rotation policy — how long a retiring key keeps verifying in-flight
186/// tokens before [`prune_retired_keys`](ServicePrincipalAuthority::prune_retired_keys)
187/// drops it.
188///
189/// Default 24 h matches the doc's `service_overlap_window` knob. Longer
190/// overlaps tolerate slower JWKS-cache propagation at the cost of a wider
191/// window in which a leaked old secret could still mint accepted tokens —
192/// pick to taste.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194#[non_exhaustive]
195pub struct OverlapPolicy {
196    pub overlap_seconds: i64,
197}
198
199impl OverlapPolicy {
200    /// 24 hours.
201    pub const DEFAULT_OVERLAP_SECONDS: i64 = 24 * 60 * 60;
202
203    pub fn new(overlap_seconds: i64) -> Self {
204        Self { overlap_seconds }
205    }
206
207    pub fn with_overlap_seconds(mut self, seconds: i64) -> Self {
208        self.overlap_seconds = seconds;
209        self
210    }
211}
212
213impl Default for OverlapPolicy {
214    fn default() -> Self {
215        Self {
216            overlap_seconds: Self::DEFAULT_OVERLAP_SECONDS,
217        }
218    }
219}
220
221/// Typed failure modes for [`ServicePrincipalAuthority`] operations.
222///
223/// HTTP layer (peer ticket) maps these to status codes: `AlreadyExists` →
224/// 409, `UnknownPrincipal` → 404, `WrongPrincipalKind` → 500 (programmer
225/// error reaching the authority with a non-service id), `NoActiveKey` →
226/// 500 (data integrity — every provisioned principal MUST have at least
227/// one active key), `Codec` → 500, `Principal`/`Store` → bubble.
228#[derive(Debug, thiserror::Error)]
229#[non_exhaustive]
230pub enum ServicePrincipalError {
231    /// A principal with the requested `desired_id` already exists.
232    #[error("service principal '{0}' already exists")]
233    AlreadyExists(PrincipalId),
234    /// `rotate` (or a JWKS lookup) named a principal cheers doesn't know.
235    #[error("unknown service principal '{0}'")]
236    UnknownPrincipal(PrincipalId),
237    /// The id handed to provision/rotate wasn't a service kind. Bubbles up
238    /// the underlying [`PrincipalError`].
239    #[error(transparent)]
240    Principal(#[from] PrincipalError),
241    /// `kind` on the principal id was something other than `Service`.
242    #[error("service-principal authority requires kind=service; got {0}")]
243    WrongPrincipalKind(PrincipalKind),
244    /// `rotate` ran against a principal with no `Active` key. Indicates a
245    /// data-integrity break (every provision must seed one) — not a normal
246    /// user-input failure.
247    #[error("service principal '{0}' has no active signing key")]
248    NoActiveKey(PrincipalId),
249    /// Keypair generation failed inside the codec layer.
250    #[error(transparent)]
251    Codec(#[from] CodecError),
252    /// Underlying store failure.
253    #[error(transparent)]
254    Store(#[from] StoreError),
255}
256
257/// Persistence for service-principal records and their signing keys.
258///
259/// The store is `principal × signing_keys` normalised — one row per
260/// principal in the principals table, many rows per principal in the
261/// signing-keys table. The two are coupled at the authority layer rather
262/// than via FK at the store layer: the trait lets a backend pick the
263/// representation (one table joined, two tables, key-value collection) so
264/// long as the visible semantics hold.
265///
266/// Time arguments are unix-seconds, signed — same shape as
267/// [`OwnershipStore`](crate::OwnershipStore).
268#[async_trait]
269pub trait ServicePrincipalStore: Send + Sync {
270    /// Insert a fresh principal record. Returns [`StoreError::Conflict`] if
271    /// the id already exists; the authority surfaces that as
272    /// [`ServicePrincipalError::AlreadyExists`].
273    async fn insert_principal(&self, principal: &Principal) -> Result<(), StoreError>;
274
275    /// Look up the principal record. `None` if no such principal exists.
276    async fn get_principal(
277        &self,
278        id: &PrincipalId,
279    ) -> Result<Option<Principal>, StoreError>;
280
281    /// Insert a freshly-generated signing key. Returns
282    /// [`StoreError::Conflict`] if the kid is already taken (kids are
283    /// globally unique across the JWKS).
284    async fn insert_signing_key(&self, key: &SigningKey) -> Result<(), StoreError>;
285
286    /// All signing keys (active + retiring) owned by `principal`. Order is
287    /// unspecified — the authority filters and orders as it needs.
288    async fn list_signing_keys(
289        &self,
290        principal: &PrincipalId,
291    ) -> Result<Vec<SigningKey>, StoreError>;
292
293    /// All signing keys in the table (across every principal), regardless
294    /// of status. The JWKS publication path filters these by
295    /// `Active OR (Retiring AND retire_at > now)`.
296    async fn list_all_signing_keys(&self) -> Result<Vec<SigningKey>, StoreError>;
297
298    /// Flip the key identified by `kid` to `Retiring` with the supplied
299    /// `retire_at`. Returns [`StoreError::NotFound`] when the kid is
300    /// unknown; idempotent if the key is already retiring (overwrites the
301    /// existing `retire_at`).
302    async fn retire_signing_key(
303        &self,
304        kid: &str,
305        retire_at: i64,
306    ) -> Result<(), StoreError>;
307
308    /// Permanently drop every signing key whose status is `Retiring` and
309    /// whose `retire_at <= now`. Returns the count dropped.
310    ///
311    /// Idempotent — calling repeatedly with the same `now` returns 0 after
312    /// the first sweep. Active keys are never touched, irrespective of
313    /// their `created_at`.
314    async fn prune_retired_keys(&self, now: i64) -> Result<u64, StoreError>;
315}
316
317/// Origin-side facade: provision new service principals + rotate existing
318/// ones + publish the live JWKS material.
319///
320/// Generic over the [`ServicePrincipalStore`] impl (not `dyn`) so the
321/// assembled deployment surfaces in the type. Holds an [`OverlapPolicy`] so
322/// the rotation window is set at construction; deployments wanting a
323/// non-default value call [`with_policy`](Self::with_policy).
324///
325/// The authority deliberately doesn't hold a [`PasetoV4SecretMinter`] — it
326/// generates a *fresh* minter inside provision/rotate, hands the secret
327/// back through [`ProvisionedKey::secret_key`] once, and drops it on the
328/// floor. Cheers retains nothing of the secret half.
329pub struct ServicePrincipalAuthority<S> {
330    store: S,
331    policy: OverlapPolicy,
332}
333
334impl<S> std::fmt::Debug for ServicePrincipalAuthority<S> {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        f.debug_struct("ServicePrincipalAuthority")
337            .field("policy", &self.policy)
338            .finish_non_exhaustive()
339    }
340}
341
342impl<S> ServicePrincipalAuthority<S>
343where
344    S: ServicePrincipalStore,
345{
346    pub fn new(store: S) -> Self {
347        Self {
348            store,
349            policy: OverlapPolicy::default(),
350        }
351    }
352
353    pub fn with_policy(mut self, policy: OverlapPolicy) -> Self {
354        self.policy = policy;
355        self
356    }
357
358    pub fn policy(&self) -> &OverlapPolicy {
359        &self.policy
360    }
361
362    pub fn store(&self) -> &S {
363        &self.store
364    }
365
366    /// Allocate a fresh service principal: generate the Ed25519 keypair,
367    /// persist the public half + principal record, and return the secret
368    /// half **exactly once** in the [`ProvisionedKey`].
369    ///
370    /// Validates `input.desired_id` via [`Principal::try_new`]
371    /// (`kind=Service`, `bound_to=None`, status=Active). A collision on the
372    /// id surfaces as [`ServicePrincipalError::AlreadyExists`].
373    pub async fn provision(
374        &self,
375        input: NewServicePrincipal,
376        now: i64,
377    ) -> Result<ProvisionedKey, ServicePrincipalError> {
378        let id = PrincipalId::service(input.desired_id);
379        // Principal::try_new enforces kind=Service => bound_to=None and the
380        // status invariant; if a future caller calls `provision` with a
381        // pre-built PrincipalId, the same check applies.
382        let principal = Principal::try_new(id.clone(), None, PrincipalStatus::Active, now)?;
383        match self.store.insert_principal(&principal).await {
384            Ok(()) => {}
385            Err(StoreError::Conflict) => return Err(ServicePrincipalError::AlreadyExists(id)),
386            Err(other) => return Err(other.into()),
387        }
388        let (signing_key, secret_key) = self.mint_signing_key(&id, now).await?;
389        Ok(ProvisionedKey {
390            principal,
391            signing_key,
392            secret_key,
393        })
394    }
395
396    /// Register a fresh keypair for an existing principal, retire the
397    /// currently-active key with `retire_at = now + policy.overlap_seconds`,
398    /// and hand the new secret back once.
399    ///
400    /// Returns [`ServicePrincipalError::UnknownPrincipal`] if `id` doesn't
401    /// resolve; [`ServicePrincipalError::WrongPrincipalKind`] if `id.kind`
402    /// is something other than `Service`. The previous key remains in the
403    /// JWKS (status=Retiring) until pruned — in-flight tokens still verify.
404    pub async fn rotate(
405        &self,
406        id: &PrincipalId,
407        now: i64,
408    ) -> Result<ProvisionedKey, ServicePrincipalError> {
409        if id.kind != PrincipalKind::Service {
410            return Err(ServicePrincipalError::WrongPrincipalKind(id.kind));
411        }
412        let principal = self
413            .store
414            .get_principal(id)
415            .await?
416            .ok_or_else(|| ServicePrincipalError::UnknownPrincipal(id.clone()))?;
417
418        let retire_at = now + self.policy.overlap_seconds;
419        let existing = self.store.list_signing_keys(id).await?;
420        let active = existing
421            .iter()
422            .find(|k| matches!(k.status, SigningKeyStatus::Active))
423            .ok_or_else(|| ServicePrincipalError::NoActiveKey(id.clone()))?;
424        self.store
425            .retire_signing_key(&active.kid, retire_at)
426            .await?;
427
428        let (signing_key, secret_key) = self.mint_signing_key(id, now).await?;
429        Ok(ProvisionedKey {
430            principal,
431            signing_key,
432            secret_key,
433        })
434    }
435
436    /// Drop signing keys whose `Retiring` window has elapsed (`retire_at
437    /// <= now`). Returns the count dropped. Call from a periodic sweep
438    /// before re-publishing the JWKS.
439    pub async fn prune_retired_keys(
440        &self,
441        now: i64,
442    ) -> Result<u64, ServicePrincipalError> {
443        Ok(self.store.prune_retired_keys(now).await?)
444    }
445
446    /// The set of signing keys to publish in the JWKS *right now*: every
447    /// `Active` key, plus every `Retiring` key whose `retire_at > now`.
448    /// Retiring-and-due keys are filtered out without removing them — call
449    /// [`prune_retired_keys`](Self::prune_retired_keys) to actually drop
450    /// the rows.
451    pub async fn published_signing_keys(
452        &self,
453        now: i64,
454    ) -> Result<Vec<SigningKey>, ServicePrincipalError> {
455        let all = self.store.list_all_signing_keys().await?;
456        Ok(all
457            .into_iter()
458            .filter(|k| match k.status {
459                SigningKeyStatus::Active => true,
460                SigningKeyStatus::Retiring => k.retire_at.map(|t| t > now).unwrap_or(false),
461            })
462            .collect())
463    }
464
465    /// Generate a fresh Ed25519 keypair, persist its public half, and
466    /// return both the persisted record and the secret bytes.
467    async fn mint_signing_key(
468        &self,
469        principal_id: &PrincipalId,
470        now: i64,
471    ) -> Result<(SigningKey, [u8; 64]), ServicePrincipalError> {
472        let (minter, verifier) = PasetoV4SecretMinter::generate()?;
473        let public_bytes = verifier
474            .public_key()
475            .as_bytes()
476            .try_into()
477            .expect("v4.public key is 32 bytes");
478        // pasetors stores the v4 secret as a 64-byte seed||pubkey layout.
479        let secret_slice = minter.secret_key_bytes();
480        let mut secret_key = [0u8; 64];
481        secret_key.copy_from_slice(secret_slice);
482
483        let signing_key = SigningKey {
484            kid: mint_kid(),
485            principal_id: principal_id.clone(),
486            public_key: public_bytes,
487            status: SigningKeyStatus::Active,
488            created_at: now,
489            retire_at: None,
490        };
491        self.store.insert_signing_key(&signing_key).await?;
492        Ok((signing_key, secret_key))
493    }
494}
495
496/// 128-bit opaque kid encoded base64url-no-pad. Matches the entropy /
497/// shape of `generate_jti` in `session.rs`.
498fn mint_kid() -> String {
499    let mut bytes = [0u8; 16];
500    getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
501    URL_SAFE_NO_PAD.encode(bytes)
502}
503
504// ----------------------------------------------------------------------------
505// MemoryServicePrincipalStore — test-only impl
506// ----------------------------------------------------------------------------
507
508/// In-memory [`ServicePrincipalStore`] for tests and single-node
509/// bootstrapping before the persistent impl lands (peer ticket against
510/// `cheers-sqlx`). Cheap to `clone` — shares one backing map.
511#[derive(Default, Clone, Debug)]
512pub struct MemoryServicePrincipalStore {
513    inner: Arc<Mutex<MemoryServicePrincipalInner>>,
514}
515
516#[derive(Default, Debug)]
517struct MemoryServicePrincipalInner {
518    principals: HashMap<PrincipalId, Principal>,
519    keys: Vec<SigningKey>,
520}
521
522impl MemoryServicePrincipalStore {
523    pub fn new() -> Self {
524        Self::default()
525    }
526}
527
528#[async_trait]
529impl ServicePrincipalStore for MemoryServicePrincipalStore {
530    async fn insert_principal(&self, principal: &Principal) -> Result<(), StoreError> {
531        let mut g = self.inner.lock().expect("service-principal store mutex poisoned");
532        if g.principals.contains_key(&principal.id) {
533            return Err(StoreError::Conflict);
534        }
535        g.principals.insert(principal.id.clone(), principal.clone());
536        Ok(())
537    }
538
539    async fn get_principal(
540        &self,
541        id: &PrincipalId,
542    ) -> Result<Option<Principal>, StoreError> {
543        let g = self.inner.lock().expect("service-principal store mutex poisoned");
544        Ok(g.principals.get(id).cloned())
545    }
546
547    async fn insert_signing_key(&self, key: &SigningKey) -> Result<(), StoreError> {
548        let mut g = self.inner.lock().expect("service-principal store mutex poisoned");
549        if g.keys.iter().any(|k| k.kid == key.kid) {
550            return Err(StoreError::Conflict);
551        }
552        g.keys.push(key.clone());
553        Ok(())
554    }
555
556    async fn list_signing_keys(
557        &self,
558        principal: &PrincipalId,
559    ) -> Result<Vec<SigningKey>, StoreError> {
560        let g = self.inner.lock().expect("service-principal store mutex poisoned");
561        Ok(g.keys
562            .iter()
563            .filter(|k| &k.principal_id == principal)
564            .cloned()
565            .collect())
566    }
567
568    async fn list_all_signing_keys(&self) -> Result<Vec<SigningKey>, StoreError> {
569        let g = self.inner.lock().expect("service-principal store mutex poisoned");
570        Ok(g.keys.clone())
571    }
572
573    async fn retire_signing_key(
574        &self,
575        kid: &str,
576        retire_at: i64,
577    ) -> Result<(), StoreError> {
578        let mut g = self.inner.lock().expect("service-principal store mutex poisoned");
579        let key = g
580            .keys
581            .iter_mut()
582            .find(|k| k.kid == kid)
583            .ok_or(StoreError::NotFound)?;
584        key.status = SigningKeyStatus::Retiring;
585        key.retire_at = Some(retire_at);
586        Ok(())
587    }
588
589    async fn prune_retired_keys(&self, now: i64) -> Result<u64, StoreError> {
590        let mut g = self.inner.lock().expect("service-principal store mutex poisoned");
591        let before = g.keys.len();
592        g.keys.retain(|k| match k.status {
593            SigningKeyStatus::Active => true,
594            SigningKeyStatus::Retiring => k.retire_at.map(|t| t > now).unwrap_or(true),
595        });
596        Ok((before - g.keys.len()) as u64)
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use cheers_verify::PasetoV4PublicVerifier;
604    use pollster::block_on;
605
606    fn rig() -> ServicePrincipalAuthority<MemoryServicePrincipalStore> {
607        ServicePrincipalAuthority::new(MemoryServicePrincipalStore::new())
608    }
609
610    // ---- OverlapPolicy -----------------------------------------------------
611
612    #[test]
613    fn overlap_policy_default_is_24_hours() {
614        let p = OverlapPolicy::default();
615        assert_eq!(p.overlap_seconds, 24 * 60 * 60);
616        let custom = OverlapPolicy::new(3_600).with_overlap_seconds(7_200);
617        assert_eq!(custom.overlap_seconds, 7_200);
618    }
619
620    // ---- provision ---------------------------------------------------------
621
622    #[test]
623    fn provision_allocates_principal_and_returns_secret_once() {
624        let authority = rig();
625        block_on(async {
626            let provisioned = authority
627                .provision(NewServicePrincipal::new("yubaba-1"), 1_000)
628                .await
629                .unwrap();
630
631            // Principal record carries the right shape.
632            assert_eq!(
633                provisioned.principal.id,
634                PrincipalId::service("yubaba-1")
635            );
636            assert_eq!(provisioned.principal.bound_to, None);
637            assert_eq!(provisioned.principal.status, PrincipalStatus::Active);
638            assert_eq!(provisioned.principal.created_at, 1_000);
639
640            // Signing key is fresh + active, with a non-empty kid.
641            assert_eq!(provisioned.signing_key.principal_id, PrincipalId::service("yubaba-1"));
642            assert_eq!(provisioned.signing_key.status, SigningKeyStatus::Active);
643            assert!(provisioned.signing_key.retire_at.is_none());
644            assert_eq!(provisioned.signing_key.created_at, 1_000);
645            assert!(!provisioned.signing_key.kid.is_empty());
646
647            // Secret half is exactly 64 bytes and the embedded public half
648            // (last 32) matches the persisted record.
649            assert_eq!(
650                &provisioned.secret_key[32..],
651                &provisioned.signing_key.public_key,
652                "PASETO V4 secret layout is seed||pubkey",
653            );
654        });
655    }
656
657    #[test]
658    fn provision_persisted_pubkey_verifies_tokens_minted_by_returned_secret() {
659        // Round-trip: cheers persists only the pubkey; the returned secret
660        // half is what a service principal would carry off-cheers. Tokens
661        // it mints must verify under the pubkey cheers retained.
662        use cheers_core::{
663            Actor, AuthStrength, McpClaims, Owns, PrincipalId, Scope,
664        };
665
666        let authority = rig();
667        block_on(async {
668            let provisioned = authority
669                .provision(NewServicePrincipal::new("yubaba-2"), 1_000)
670                .await
671                .unwrap();
672
673            // Reconstruct the minter off-cheers, like yubaba would.
674            let off_cheers_minter =
675                PasetoV4SecretMinter::from_secret_key(&provisioned.secret_key).unwrap();
676
677            // Reconstruct the verifier from the public bytes cheers retained
678            // (the JWKS publication path).
679            let verifier =
680                PasetoV4PublicVerifier::from_public_key(&provisioned.signing_key.public_key)
681                    .unwrap();
682
683            let mut owns = Owns::default();
684            owns.service = vec!["svc-prod".into()];
685            let claims = McpClaims::new(
686                "https://cheers.example",
687                "https://kamaji.example",
688                PrincipalId::service("yubaba-2"),
689                1_000,
690                1_600,
691                "jti-rt",
692                vec![Scope::OwnershipWrite],
693            )
694            .with_act(Actor::new(PrincipalId::service("yubaba-2")))
695            .with_owns(owns)
696            .with_auth_strength(AuthStrength::Bootstrap);
697
698            let token = off_cheers_minter
699                .mint_mcp(&claims, &provisioned.signing_key.kid)
700                .unwrap();
701            let back = verifier
702                .verify_mcp_at(&token, 1_100, &provisioned.signing_key.kid)
703                .unwrap();
704            assert_eq!(back, claims);
705        });
706    }
707
708    #[test]
709    fn provision_rejects_duplicate_id() {
710        let authority = rig();
711        block_on(async {
712            authority
713                .provision(NewServicePrincipal::new("dup"), 1_000)
714                .await
715                .unwrap();
716            let err = authority
717                .provision(NewServicePrincipal::new("dup"), 1_001)
718                .await
719                .unwrap_err();
720            match err {
721                ServicePrincipalError::AlreadyExists(id) => {
722                    assert_eq!(id, PrincipalId::service("dup"));
723                }
724                other => panic!("expected AlreadyExists, got {other:?}"),
725            }
726        });
727    }
728
729    // ---- rotate ------------------------------------------------------------
730
731    #[test]
732    fn rotate_retires_old_key_and_returns_fresh_secret() {
733        let authority = rig();
734        block_on(async {
735            let first = authority
736                .provision(NewServicePrincipal::new("yubaba-r"), 1_000)
737                .await
738                .unwrap();
739            let id = first.principal.id.clone();
740
741            let second = authority.rotate(&id, 5_000).await.unwrap();
742
743            // Fresh kid; fresh pubkey.
744            assert_ne!(second.signing_key.kid, first.signing_key.kid);
745            assert_ne!(second.signing_key.public_key, first.signing_key.public_key);
746            assert_eq!(second.signing_key.status, SigningKeyStatus::Active);
747            assert!(second.signing_key.retire_at.is_none());
748
749            // Old key now Retiring with retire_at = now + 24h.
750            let stored = authority
751                .store
752                .list_signing_keys(&id)
753                .await
754                .unwrap();
755            assert_eq!(stored.len(), 2);
756            let old = stored.iter().find(|k| k.kid == first.signing_key.kid).unwrap();
757            assert_eq!(old.status, SigningKeyStatus::Retiring);
758            assert_eq!(
759                old.retire_at,
760                Some(5_000 + OverlapPolicy::DEFAULT_OVERLAP_SECONDS)
761            );
762        });
763    }
764
765    #[test]
766    fn rotate_respects_custom_overlap_window() {
767        let authority = rig().with_policy(OverlapPolicy::new(60 * 60)); // 1h
768        block_on(async {
769            let first = authority
770                .provision(NewServicePrincipal::new("yubaba-c"), 1_000)
771                .await
772                .unwrap();
773            authority.rotate(&first.principal.id, 2_000).await.unwrap();
774            let keys = authority
775                .store
776                .list_signing_keys(&first.principal.id)
777                .await
778                .unwrap();
779            let old = keys.iter().find(|k| k.kid == first.signing_key.kid).unwrap();
780            assert_eq!(old.retire_at, Some(2_000 + 3_600));
781        });
782    }
783
784    #[test]
785    fn rotate_rejects_unknown_principal() {
786        let authority = rig();
787        block_on(async {
788            let err = authority
789                .rotate(&PrincipalId::service("ghost"), 1_000)
790                .await
791                .unwrap_err();
792            match err {
793                ServicePrincipalError::UnknownPrincipal(id) => {
794                    assert_eq!(id, PrincipalId::service("ghost"));
795                }
796                other => panic!("expected UnknownPrincipal, got {other:?}"),
797            }
798        });
799    }
800
801    #[test]
802    fn rotate_rejects_non_service_principal_kind() {
803        let authority = rig();
804        block_on(async {
805            // A user principal handed to rotate is a programmer error.
806            let err = authority
807                .rotate(&PrincipalId::user("alice"), 1_000)
808                .await
809                .unwrap_err();
810            match err {
811                ServicePrincipalError::WrongPrincipalKind(k) => {
812                    assert_eq!(k, PrincipalKind::User);
813                }
814                other => panic!("expected WrongPrincipalKind, got {other:?}"),
815            }
816        });
817    }
818
819    // ---- published_signing_keys + prune_retired_keys -----------------------
820
821    #[test]
822    fn published_signing_keys_includes_active_and_retiring_within_window() {
823        let authority = rig().with_policy(OverlapPolicy::new(100));
824        block_on(async {
825            let first = authority
826                .provision(NewServicePrincipal::new("yubaba-p"), 1_000)
827                .await
828                .unwrap();
829            // Rotate at t=2000; old key retire_at=2100, new key active.
830            authority.rotate(&first.principal.id, 2_000).await.unwrap();
831
832            // At t=2050, both keys are in the JWKS.
833            let live = authority.published_signing_keys(2_050).await.unwrap();
834            assert_eq!(live.len(), 2, "got {live:#?}");
835
836            // At t=2099 (just before the window closes), still both.
837            let live = authority.published_signing_keys(2_099).await.unwrap();
838            assert_eq!(live.len(), 2);
839
840            // At t=2100 (retire_at), the retiring key drops out of the
841            // published set even though it's still in the store.
842            let live = authority.published_signing_keys(2_100).await.unwrap();
843            assert_eq!(live.len(), 1);
844            assert_eq!(live[0].status, SigningKeyStatus::Active);
845        });
846    }
847
848    #[test]
849    fn prune_retired_keys_removes_expired_only() {
850        let authority = rig().with_policy(OverlapPolicy::new(100));
851        block_on(async {
852            let first = authority
853                .provision(NewServicePrincipal::new("yubaba-x"), 1_000)
854                .await
855                .unwrap();
856            authority.rotate(&first.principal.id, 2_000).await.unwrap();
857
858            // Before the window closes, prune is a no-op.
859            let dropped = authority.prune_retired_keys(2_099).await.unwrap();
860            assert_eq!(dropped, 0);
861            assert_eq!(
862                authority
863                    .store
864                    .list_signing_keys(&first.principal.id)
865                    .await
866                    .unwrap()
867                    .len(),
868                2
869            );
870
871            // After retire_at, prune drops exactly the retiring key.
872            let dropped = authority.prune_retired_keys(2_100).await.unwrap();
873            assert_eq!(dropped, 1);
874            let remaining = authority
875                .store
876                .list_signing_keys(&first.principal.id)
877                .await
878                .unwrap();
879            assert_eq!(remaining.len(), 1);
880            assert_eq!(remaining[0].status, SigningKeyStatus::Active);
881
882            // Idempotent.
883            let dropped = authority.prune_retired_keys(2_200).await.unwrap();
884            assert_eq!(dropped, 0);
885        });
886    }
887
888    #[test]
889    fn published_signing_keys_omits_keys_with_no_retire_at_in_retiring_state() {
890        // Belt-and-braces: a Retiring key without retire_at (only possible
891        // via a misbehaving direct store write — the authority always sets
892        // it) is treated as not published. Keeps the wire shape from leaking
893        // a zombie key.
894        let store = MemoryServicePrincipalStore::new();
895        let key = SigningKey {
896            kid: "k".into(),
897            principal_id: PrincipalId::service("zombie"),
898            public_key: [0u8; 32],
899            status: SigningKeyStatus::Retiring,
900            created_at: 100,
901            retire_at: None,
902        };
903        block_on(async {
904            store.insert_signing_key(&key).await.unwrap();
905            let authority = ServicePrincipalAuthority::new(store);
906            let live = authority.published_signing_keys(1_000).await.unwrap();
907            assert!(live.is_empty());
908        });
909    }
910
911    // ---- serde -------------------------------------------------------------
912
913    #[test]
914    fn signing_key_roundtrips_through_json_with_base64_pubkey() {
915        let key = SigningKey {
916            kid: "kid-abc".into(),
917            principal_id: PrincipalId::service("yubaba"),
918            public_key: [1u8; 32],
919            status: SigningKeyStatus::Active,
920            created_at: 100,
921            retire_at: None,
922        };
923        let json = serde_json::to_string(&key).unwrap();
924        // pubkey is a single base64url string, not a 32-element array.
925        assert!(json.contains("\"public_key\":\""), "got {json}");
926        assert!(!json.contains("[1,1,1"), "must NOT be an array: {json}");
927        let back: SigningKey = serde_json::from_str(&json).unwrap();
928        assert_eq!(back, key);
929    }
930
931    #[test]
932    fn signing_key_deserialize_rejects_wrong_length_pubkey() {
933        // 4 base64url chars cleanly decode to 3 bytes — well short of 32, so
934        // the length guard fires (not the base64 parser).
935        let json = r#"{
936            "kid": "k",
937            "principal_id": "svc:x",
938            "public_key": "AAAA",
939            "status": "active",
940            "created_at": 0,
941            "retire_at": null
942        }"#;
943        let err = serde_json::from_str::<SigningKey>(json).unwrap_err();
944        assert!(
945            err.to_string().contains("32 public_key bytes"),
946            "got {err}"
947        );
948    }
949
950    // ---- trait shape -------------------------------------------------------
951
952    #[test]
953    fn service_principal_store_is_dyn_compatible() {
954        fn _f(_: &dyn ServicePrincipalStore) {}
955    }
956}