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}