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