cheers_server/codec.rs
1//! Concrete session-token codecs — **origin-side**.
2//!
3//! Three impls of the [`cheers_core`] capability traits:
4//!
5//! - [`PasetoV4Codec`] — PASETO v4.local (XChaCha20-Poly1305 + BLAKE2b).
6//! Encrypted *and* authenticated claims, opaque to whoever holds the cookie.
7//! Symmetric, so it impls **both** [`TokenMinter`] and [`TokenVerifier`] —
8//! origin-only verification (decrypting needs the secret key).
9//! - [`HmacBlobCodec`] — JSON payload + HMAC-SHA256 tag, base64url-encoded.
10//! Cleartext-readable; authenticated, not encrypted. Symmetric (both traits).
11//! - [`PasetoV4SecretMinter`] — PASETO v4.public (Ed25519) **mint only**. The
12//! matching verify-only [`PasetoV4PublicVerifier`](cheers_verify::PasetoV4PublicVerifier)
13//! lives in `cheers-verify`; the origin signs, the edge verifies.
14//!
15//! The symmetric codecs impl both traits on one type, so they MUST live here in
16//! `cheers-server`, never in `cheers-verify` — putting them there would re-grant
17//! mint to the edge through the back door.
18//!
19//! ```
20//! use cheers_core::{Claims, DeviceBinding, DeviceId, TokenMinter, TokenVerifier, UserId};
21//! use cheers_server::HmacBlobCodec;
22//!
23//! let codec = HmacBlobCodec::new([0u8; 32]);
24//! let claims = Claims::new(
25//! UserId::new("u1"),
26//! DeviceId::new("d1"),
27//! DeviceBinding::Passkey,
28//! 1_700_000_000,
29//! i64::MAX, // far-future expiry for the doctest
30//! );
31//! let token = codec.mint(&claims).unwrap();
32//! let back = codec.verify(&token).unwrap();
33//! assert_eq!(back, claims);
34//! ```
35//!
36//! @yah:ticket(R020-T15, "McpClaims mint/verify helpers on PasetoV4 codecs (cheers-server)")
37//! @yah:assignee(agent:claude)
38//! @yah:at(2026-06-04T18:25:48Z)
39//! @yah:status(review)
40//! @yah:phase(P2)
41//! @yah:parent(R020)
42//! @yah:verify("cargo test -p cheers-server (round-trip: mint_mcp → verify_mcp_at returns the same McpClaims; expired token → CodecError::Expired; wrong-key sig → SignatureMismatch).")
43//! @yah:verify("cargo test -p cheers-verify (no regression: existing Claims-typed verify path still passes).")
44//! @yah:gotcha("TokenMinter::mint / TokenVerifier::verify_at are hard-coded to cheers_core::Claims (session contract). McpClaims is a peer shape (R020-F3) — do NOT change the existing trait signatures; add sibling inherent methods instead. Generalising the traits is a deeper refactor that should wait for a second MCP-style claim shape to land.")
45//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
46//! @yah:next("R020-F6/F7 mint paths now have: (a) expand_scopes (F5) → (b) validate_grant per expanded Scope (defense in depth) → (c) McpClaims::new + with_* builders (F3) → (d) PasetoV4SecretMinter::mint_mcp (this ticket). All ingredients present — F6 (user-passkey) and F7 (camp-bootstrap) compose them differently around the principal lookup + bundle resolution.")
47//! @yah:next("When R020-T16 (Bearer/McpClaims middleware in cheers-axum) lands, the edge will call verify_mcp_at and surface Malformed for cross-shape tokens — wire it as a 401, not 500.")
48//! @yah:handoff("Landed: PasetoV4SecretMinter::mint_mcp(&McpClaims) -> Result<String, CodecError> in cheers-server/src/codec.rs as an inherent sibling to TokenMinter::mint — same v4.public sign path. PasetoV4PublicVerifier::verify_mcp_at(token, now) -> Result<McpClaims, CodecError> in cheers-verify/src/public_verifier.rs.")
49//! @yah:handoff("Key separation is structural: MCP_CLAIM_KEY = \"mcp\" (exported from cheers-verify so the server-side minter uses the same identifier) vs the session-claim \"cheers\". Cross-shape mix-ups surface as CodecError::Malformed at the get_claim() step — verified by mcp_token_not_verifiable_under_session_verify_at and session_token_not_verifiable_under_mcp_verify_at.")
50//! @yah:handoff("Kept TokenMinter/TokenVerifier trait signatures untouched (still Claims-typed) — the generalisation refactor waits for a 2nd MCP-style claim shape, per the in-source gotcha.")
51//! @yah:handoff("Caller owns iat/exp in McpClaims (parity with the Claims path); TTL policy belongs upstream (R020-F6/F7's mint paths will set per-call lifetime per the doc §TTLs).")
52//! @yah:handoff("Owns is #[non_exhaustive] in cheers-core so the test fixture uses Owns::default() + field assignment (struct literal blocked outside the defining crate).")
53//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (no change), cheers-server 49 → 55 unit tests incl. 6 new mcp-codec tests, cheers-verify unchanged. Bundle work from R020-F5 still green.")
54
55use base64::Engine as _;
56use base64::engine::general_purpose::URL_SAFE_NO_PAD;
57use hmac::{Hmac, Mac};
58use pasetors::claims::{Claims as PasetoClaims, ClaimsValidationRules};
59use pasetors::keys::{AsymmetricPublicKey, AsymmetricSecretKey, SymmetricKey};
60use pasetors::token::UntrustedToken;
61use pasetors::version4::{PublicToken, V4};
62use pasetors::{local, public};
63use sha2::Sha256;
64use subtle::ConstantTimeEq;
65
66use cheers_core::{Claims, CodecError, McpClaims, TokenMinter, TokenVerifier};
67use cheers_verify::{codec_err, PasetoV4PublicVerifier};
68
69type HmacSha256 = Hmac<Sha256>;
70
71// ----------------------------------------------------------------------------
72// PasetoV4Codec — v4.local (encrypted + authenticated)
73// ----------------------------------------------------------------------------
74
75/// PASETO v4.local codec. Encrypts and authenticates with a 32-byte symmetric key.
76pub struct PasetoV4Codec {
77 key: SymmetricKey<V4>,
78}
79
80impl PasetoV4Codec {
81 /// Build from a 32-byte symmetric key.
82 pub fn new(key_bytes: &[u8; 32]) -> Result<Self, CodecError> {
83 let key = SymmetricKey::<V4>::from(key_bytes)
84 .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
85 Ok(Self { key })
86 }
87}
88
89impl TokenMinter for PasetoV4Codec {
90 fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
91 // pasetors's standard `exp` claim is validated against the wall
92 // clock and we want `verify_at(now)` to own that decision — so we
93 // store the full cheers payload under one additional key and mark
94 // the token as non-expiring at the PASETO layer.
95 let mut p = PasetoClaims::new_expires_in(&core::time::Duration::ZERO)
96 .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
97 p.non_expiring();
98 let cheers_value = serde_json::to_value(claims)?;
99 p.add_additional("cheers", cheers_value)
100 .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
101 local::encrypt(&self.key, &p, None, None).map_err(codec_err)
102 }
103}
104
105impl TokenVerifier for PasetoV4Codec {
106 fn verify_at(&self, token: &str, now: i64) -> Result<Claims, CodecError> {
107 let untrusted = UntrustedToken::<pasetors::token::Local, V4>::try_from(token)
108 .map_err(|_| CodecError::Malformed)?;
109 // Skip pasetors's wall-clock validation; we enforce `now` ourselves
110 // for testability and consistency with HmacBlobCodec.
111 let mut rules = ClaimsValidationRules::new();
112 rules.allow_non_expiring();
113 let trusted = local::decrypt(&self.key, &untrusted, &rules, None, None)
114 .map_err(codec_err)?;
115 let pclaims = trusted.payload_claims().ok_or(CodecError::Malformed)?;
116 let v = pclaims
117 .get_claim("cheers")
118 .ok_or(CodecError::Malformed)?
119 .clone();
120 let out: Claims = serde_json::from_value(v)?;
121 if out.is_expired_at(now) {
122 return Err(CodecError::Expired);
123 }
124 Ok(out)
125 }
126}
127
128// ----------------------------------------------------------------------------
129// PasetoV4SecretMinter — v4.public (Ed25519: signed, NOT encrypted)
130// ----------------------------------------------------------------------------
131
132/// PASETO v4.public **minter** — signs claims with an Ed25519 secret key.
133///
134/// Origin-only: holding the secret key *is* minting power. Pair it with a
135/// [`PasetoV4PublicVerifier`] (the matching public key) at the edge. Because
136/// v4.public is signed but **not** encrypted, only mint non-secret claims this
137/// way — anyone holding the token can read them.
138pub struct PasetoV4SecretMinter {
139 secret: AsymmetricSecretKey<V4>,
140}
141
142impl PasetoV4SecretMinter {
143 /// Build from a 64-byte Ed25519 secret key in PASETO's `seed || public_key`
144 /// layout (the V4 secret-key size). Rejects a key whose trailing public half
145 /// doesn't match the seed.
146 pub fn from_secret_key(bytes: &[u8; 64]) -> Result<Self, CodecError> {
147 let secret = AsymmetricSecretKey::<V4>::from(bytes)
148 .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
149 Ok(Self { secret })
150 }
151
152 /// Generate a fresh Ed25519 keypair, returning the origin minter and its
153 /// matching edge [`PasetoV4PublicVerifier`].
154 pub fn generate() -> Result<(Self, PasetoV4PublicVerifier), CodecError> {
155 use pasetors::keys::{AsymmetricKeyPair, Generate};
156 let kp = AsymmetricKeyPair::<V4>::generate()
157 .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
158 Ok((
159 Self { secret: kp.secret },
160 PasetoV4PublicVerifier::from_key(kp.public),
161 ))
162 }
163
164 /// Derive the matching public verifier — e.g. to publish the verify key to
165 /// an edge while the secret never leaves the origin.
166 pub fn verifier(&self) -> Result<PasetoV4PublicVerifier, CodecError> {
167 let public = AsymmetricPublicKey::<V4>::try_from(&self.secret)
168 .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
169 Ok(PasetoV4PublicVerifier::from_key(public))
170 }
171
172 /// Raw bytes of the underlying PASETO V4 secret key — 64 bytes in the
173 /// `seed || public_key` layout.
174 ///
175 /// **Origin-only, single-use affordance.** The
176 /// [`ServicePrincipalAuthority`](crate::service_principal::ServicePrincipalAuthority)
177 /// returns these bytes to a service-principal install flow exactly once
178 /// at provision/rotate time (see
179 /// `.yah/docs/working/mcp-auth-and-ownership.md` §Service principal
180 /// bootstrap). Cheers's *own* session signing key never reaches this
181 /// accessor — it is constructed once at startup and never extracted —
182 /// so leaking the cheers secret half is impossible by construction
183 /// (the symmetric codecs don't expose this affordance at all).
184 pub fn secret_key_bytes(&self) -> &[u8] {
185 self.secret.as_bytes()
186 }
187
188 /// Sign an MCP-call token carrying [`McpClaims`].
189 ///
190 /// **Wire convention** (R592-B7) — the same envelope kamaji-bin's
191 /// verifier, `cheers-mock`, and yubaba's `mint_ownership_write_token`
192 /// already agree on (pinned byte-for-byte in
193 /// `cheers_test_support::fixtures`): `claims` serializes directly as the
194 /// token's FLAT top-level JSON payload — no wrapping claim key, `exp`/
195 /// `iat` stay i64 Unix seconds — signed via pasetors' LOW-LEVEL
196 /// [`PublicToken::sign`], never the high-level `Claims` wrapper (which
197 /// hard-rejects non-string registered claims). `kid` rides in the PASETO
198 /// footer as `{"kid":"<kid>"}`; the matching
199 /// [`PasetoV4PublicVerifier::verify_mcp_at`] requires it.
200 ///
201 /// The caller owns `iat`/`exp` (mirrors the session-claim path) — TTL
202 /// policy for MCP tokens belongs upstream of this primitive. `kid`
203 /// identifies which published key this mint used — same string a
204 /// verifier's JWKS/footer lookup keys off of.
205 ///
206 /// Serializes via `serde_json::Value` rather than serializing `claims`
207 /// directly: every reference wire producer (yubaba's
208 /// `mint_ownership_write_token`, cheers-mock's `MockIssuer::mint`,
209 /// `cheers_test_support::fixtures::sign_fixture`) builds its payload as a
210 /// `Value`/`json!{}` map, whose default (non-`preserve_order`)
211 /// `BTreeMap`-backed key order is alphabetical — NOT the struct's
212 /// field-declaration order `#[derive(Serialize)]` would emit. Routing
213 /// through `Value` here reproduces that same alphabetical byte layout,
214 /// which is what makes `mint_mcp`'s output byte-identical to the golden
215 /// fixtures (verified in `cheers-server/tests/golden_fixtures.rs`).
216 /// Deserialization is unaffected either way (JSON objects key-match by
217 /// name), but the mint side has to match on the wire.
218 pub fn mint_mcp(&self, claims: &McpClaims, kid: &str) -> Result<String, CodecError> {
219 let value = serde_json::to_value(claims)?;
220 let payload = serde_json::to_vec(&value)?;
221 // Build the footer through serde rather than string interpolation so a
222 // `kid` containing `"`, `\`, or control characters is JSON-escaped
223 // instead of corrupting the footer (or smuggling extra footer fields).
224 // For the base64url kids `mint_kid` produces, the compact serde output
225 // is byte-identical to the old `{"kid":"<kid>"}` form, so the golden
226 // fixtures are unaffected.
227 let footer = serde_json::to_vec(&serde_json::json!({ "kid": kid }))?;
228 PublicToken::sign(&self.secret, &payload, Some(&footer), None).map_err(codec_err)
229 }
230}
231
232impl TokenMinter for PasetoV4SecretMinter {
233 fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
234 let mut p = PasetoClaims::new_expires_in(&core::time::Duration::ZERO)
235 .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
236 p.non_expiring();
237 let cheers_value = serde_json::to_value(claims)?;
238 p.add_additional("cheers", cheers_value)
239 .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
240 public::sign(&self.secret, &p, None, None).map_err(codec_err)
241 }
242}
243
244// ----------------------------------------------------------------------------
245// HmacBlobCodec — `b64url(json).b64url(hmac)`
246// ----------------------------------------------------------------------------
247
248/// HMAC-SHA256 blob codec. Cleartext payload, authenticated only.
249///
250/// Wire format: `b64url(json(claims)) + "." + b64url(hmac_sha256(json(claims)))`.
251/// The payload is *not* encrypted; anyone who has the token can read the
252/// claims. Pick [`PasetoV4Codec`] when that matters.
253pub struct HmacBlobCodec {
254 key: [u8; 32],
255}
256
257impl HmacBlobCodec {
258 pub fn new(key: [u8; 32]) -> Self {
259 Self { key }
260 }
261}
262
263impl TokenMinter for HmacBlobCodec {
264 fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
265 let payload = serde_json::to_vec(claims)?;
266 let mut mac =
267 HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
268 mac.update(&payload);
269 let tag = mac.finalize().into_bytes();
270 let mut out = String::with_capacity(payload.len() * 2 + 64);
271 out.push_str(&URL_SAFE_NO_PAD.encode(&payload));
272 out.push('.');
273 out.push_str(&URL_SAFE_NO_PAD.encode(tag));
274 Ok(out)
275 }
276}
277
278impl TokenVerifier for HmacBlobCodec {
279 fn verify_at(&self, token: &str, now: i64) -> Result<Claims, CodecError> {
280 let (payload_b64, tag_b64) = token.split_once('.').ok_or(CodecError::Malformed)?;
281 let payload = URL_SAFE_NO_PAD
282 .decode(payload_b64)
283 .map_err(|_| CodecError::Malformed)?;
284 let tag = URL_SAFE_NO_PAD
285 .decode(tag_b64)
286 .map_err(|_| CodecError::Malformed)?;
287 let mut mac =
288 HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
289 mac.update(&payload);
290 let expected = mac.finalize().into_bytes();
291 if expected.ct_eq(&tag).unwrap_u8() != 1 {
292 return Err(CodecError::SignatureMismatch);
293 }
294 let claims: Claims = serde_json::from_slice(&payload)?;
295 if claims.is_expired_at(now) {
296 return Err(CodecError::Expired);
297 }
298 Ok(claims)
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use cheers_core::{DeviceBinding, DeviceId, UserId};
306
307 fn sample_claims(exp: i64) -> Claims {
308 Claims::new(
309 UserId::new("u1"),
310 DeviceId::new("d1"),
311 DeviceBinding::OidcGoogle,
312 1_000,
313 exp,
314 )
315 }
316
317 // ---- HmacBlobCodec ------------------------------------------------------
318
319 #[test]
320 fn hmac_blob_roundtrip() {
321 let codec = HmacBlobCodec::new([7u8; 32]);
322 let c = sample_claims(10_000);
323 let tok = codec.mint(&c).unwrap();
324 assert!(tok.contains('.'));
325 let back = codec.verify_at(&tok, 5_000).unwrap();
326 assert_eq!(back, c);
327 }
328
329 #[test]
330 fn hmac_blob_rejects_expired() {
331 let codec = HmacBlobCodec::new([7u8; 32]);
332 let c = sample_claims(100);
333 let tok = codec.mint(&c).unwrap();
334 let err = codec.verify_at(&tok, 200).unwrap_err();
335 assert!(matches!(err, CodecError::Expired));
336 }
337
338 #[test]
339 fn hmac_blob_rejects_wrong_key() {
340 let a = HmacBlobCodec::new([1u8; 32]);
341 let b = HmacBlobCodec::new([2u8; 32]);
342 let tok = a.mint(&sample_claims(10_000)).unwrap();
343 let err = b.verify_at(&tok, 5_000).unwrap_err();
344 assert!(matches!(err, CodecError::SignatureMismatch));
345 }
346
347 #[test]
348 fn hmac_blob_rejects_tampered_payload() {
349 let codec = HmacBlobCodec::new([7u8; 32]);
350 let tok = codec.mint(&sample_claims(10_000)).unwrap();
351 let (payload, tag) = tok.split_once('.').unwrap();
352 // Re-encode an *altered* payload while keeping the original tag.
353 let altered = {
354 let mut bytes = URL_SAFE_NO_PAD.decode(payload).unwrap();
355 let last = bytes.len() - 1;
356 bytes[last] ^= 0x01;
357 URL_SAFE_NO_PAD.encode(bytes)
358 };
359 let bad = format!("{altered}.{tag}");
360 let err = codec.verify_at(&bad, 5_000).unwrap_err();
361 assert!(matches!(err, CodecError::SignatureMismatch));
362 }
363
364 #[test]
365 fn hmac_blob_rejects_malformed() {
366 let codec = HmacBlobCodec::new([7u8; 32]);
367 assert!(matches!(
368 codec.verify_at("no-dot", 0).unwrap_err(),
369 CodecError::Malformed
370 ));
371 assert!(matches!(
372 codec.verify_at("not_base64!.also_not", 0).unwrap_err(),
373 CodecError::Malformed
374 ));
375 }
376
377 // ---- PasetoV4Codec ------------------------------------------------------
378
379 #[test]
380 fn paseto_v4_roundtrip() {
381 let codec = PasetoV4Codec::new(&[42u8; 32]).unwrap();
382 let c = sample_claims(10_000);
383 let tok = codec.mint(&c).unwrap();
384 assert!(tok.starts_with("v4.local."));
385 let back = codec.verify_at(&tok, 5_000).unwrap();
386 assert_eq!(back, c);
387 }
388
389 #[test]
390 fn paseto_v4_rejects_expired() {
391 let codec = PasetoV4Codec::new(&[42u8; 32]).unwrap();
392 let c = sample_claims(100);
393 let tok = codec.mint(&c).unwrap();
394 let err = codec.verify_at(&tok, 200).unwrap_err();
395 assert!(matches!(err, CodecError::Expired));
396 }
397
398 #[test]
399 fn paseto_v4_rejects_wrong_key() {
400 let a = PasetoV4Codec::new(&[1u8; 32]).unwrap();
401 let b = PasetoV4Codec::new(&[2u8; 32]).unwrap();
402 let tok = a.mint(&sample_claims(10_000)).unwrap();
403 let err = b.verify_at(&tok, 5_000).unwrap_err();
404 // Wrong key fails AEAD decryption; pasetors maps that to TokenValidation.
405 assert!(matches!(err, CodecError::SignatureMismatch));
406 }
407
408 #[test]
409 fn paseto_v4_rejects_malformed() {
410 let codec = PasetoV4Codec::new(&[42u8; 32]).unwrap();
411 assert!(matches!(
412 codec.verify_at("not-a-paseto", 0).unwrap_err(),
413 CodecError::Malformed
414 ));
415 }
416
417 // ---- PasetoV4Public (asymmetric: secret minter / public verifier) -------
418
419 fn v4_public_pair() -> (PasetoV4SecretMinter, PasetoV4PublicVerifier) {
420 PasetoV4SecretMinter::generate().unwrap()
421 }
422
423 #[test]
424 fn paseto_v4_public_roundtrip() {
425 let (minter, verifier) = v4_public_pair();
426 let c = sample_claims(10_000);
427 let tok = minter.mint(&c).unwrap();
428 assert!(tok.starts_with("v4.public."));
429 let back = verifier.verify_at(&tok, 5_000).unwrap();
430 assert_eq!(back, c);
431 }
432
433 #[test]
434 fn paseto_v4_public_rejects_expired() {
435 let (minter, verifier) = v4_public_pair();
436 let c = sample_claims(100);
437 let tok = minter.mint(&c).unwrap();
438 let err = verifier.verify_at(&tok, 200).unwrap_err();
439 assert!(matches!(err, CodecError::Expired));
440 }
441
442 #[test]
443 fn paseto_v4_public_rejects_wrong_key() {
444 let (minter, _) = v4_public_pair();
445 let (_, other_verifier) = v4_public_pair();
446 let tok = minter.mint(&sample_claims(10_000)).unwrap();
447 // A signature minted by one secret key must not verify under an
448 // unrelated public key.
449 let err = other_verifier.verify_at(&tok, 5_000).unwrap_err();
450 assert!(matches!(err, CodecError::SignatureMismatch));
451 }
452
453 #[test]
454 fn paseto_v4_public_verifier_derives_from_minter() {
455 // The origin can hand the edge a verify-only key derived from its
456 // secret, and tokens minted by that secret verify under it.
457 let (minter, _) = v4_public_pair();
458 let derived = minter.verifier().unwrap();
459 let c = sample_claims(10_000);
460 let tok = minter.mint(&c).unwrap();
461 assert_eq!(derived.verify_at(&tok, 5_000).unwrap(), c);
462 }
463
464 #[test]
465 fn paseto_v4_public_round_trips_through_key_bytes() {
466 // Generate a pair, then reconstruct the verifier from its serialized
467 // 32-byte public key — the shape a deployment uses (origin loads the
468 // secret, edge loads the published public key).
469 let (minter, verifier) = v4_public_pair();
470 let c = sample_claims(10_000);
471 let tok = minter.mint(&c).unwrap();
472 let pub_bytes: [u8; 32] = verifier
473 .public_key()
474 .as_bytes()
475 .try_into()
476 .expect("v4 public key is 32 bytes");
477 let rebuilt = PasetoV4PublicVerifier::from_public_key(&pub_bytes).unwrap();
478 assert_eq!(rebuilt.verify_at(&tok, 5_000).unwrap(), c);
479 }
480
481 // ---- PasetoV4 v4.public — McpClaims mint/verify (wire convention) ------
482 //
483 // R592-B7: mint_mcp/verify_mcp_at now speak the SAME envelope as
484 // kamaji-bin's verifier / cheers-mock / yubaba's minter — flat top-level
485 // JSON, i64 exp/iat, kid in the footer, low-level PublicToken sign/verify.
486 // A cross-shape token (session vs MCP) is no longer distinguished by an
487 // additional-claim key; it's distinguished by footer presence (session
488 // tokens never stamp one) and by claims deserialization (each shape's
489 // required fields differ).
490
491 use cheers_core::{
492 Actor, AuthStrength, McpClaims, Owns, PrincipalId, Scope,
493 };
494
495 const TEST_KID: &str = "codec-test-kid-1";
496
497 fn sample_mcp_claims(exp: i64) -> McpClaims {
498 // Owns is #[non_exhaustive] in cheers-core, so build via Default +
499 // field assignment from this crate.
500 let mut owns = Owns::default();
501 owns.service = vec!["svc-a".into()];
502 owns.arch_doc = vec!["doc-1".into()];
503
504 McpClaims::new(
505 "https://cheers.example",
506 "https://kamaji.camp.example",
507 PrincipalId::user("alice"),
508 1_000,
509 exp,
510 "jti-mcp-1",
511 vec![Scope::CloudDeploy, Scope::CloudRead],
512 )
513 .with_act(Actor::new(PrincipalId::service("agent-claude")))
514 .with_camp_id("camp-xyz")
515 .with_owns(owns)
516 .with_auth_strength(AuthStrength::UserFresh)
517 }
518
519 #[test]
520 fn paseto_v4_public_mcp_roundtrip() {
521 let (minter, verifier) = v4_public_pair();
522 let c = sample_mcp_claims(10_000);
523 let tok = minter.mint_mcp(&c, TEST_KID).unwrap();
524 assert!(tok.starts_with("v4.public."));
525 let back = verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap();
526 assert_eq!(back, c);
527 }
528
529 #[test]
530 fn paseto_v4_public_mcp_rejects_expired() {
531 let (minter, verifier) = v4_public_pair();
532 let c = sample_mcp_claims(100);
533 let tok = minter.mint_mcp(&c, TEST_KID).unwrap();
534 let err = verifier.verify_mcp_at(&tok, 200, TEST_KID).unwrap_err();
535 assert!(matches!(err, CodecError::Expired));
536 }
537
538 #[test]
539 fn paseto_v4_public_mcp_rejects_wrong_key() {
540 let (minter, _) = v4_public_pair();
541 let (_, other_verifier) = v4_public_pair();
542 let tok = minter.mint_mcp(&sample_mcp_claims(10_000), TEST_KID).unwrap();
543 let err = other_verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap_err();
544 assert!(matches!(err, CodecError::SignatureMismatch));
545 }
546
547 #[test]
548 fn paseto_v4_public_mcp_rejects_unknown_kid() {
549 // Same key, but the verifier only trusts TEST_KID — a token minted
550 // under a different kid string is rejected before signature check.
551 let (minter, verifier) = v4_public_pair();
552 let tok = minter
553 .mint_mcp(&sample_mcp_claims(10_000), "some-other-kid")
554 .unwrap();
555 let err = verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap_err();
556 assert!(
557 matches!(err, CodecError::UnknownKid(ref k) if k == "some-other-kid"),
558 "got {err:?}"
559 );
560 }
561
562 #[test]
563 fn mcp_token_not_verifiable_under_session_verify_at() {
564 // An MCP token is flat top-level JSON with i64 exp/iat — verify_at's
565 // high-level pasetors::public::verify parses the payload through
566 // Claims::from_string, which hard-rejects a numeric registered claim.
567 // The two shapes can't be confused even though both are valid
568 // v4.public signatures from the same key.
569 let (minter, verifier) = v4_public_pair();
570 let tok = minter.mint_mcp(&sample_mcp_claims(10_000), TEST_KID).unwrap();
571 let err = verifier.verify_at(&tok, 5_000).unwrap_err();
572 assert!(
573 matches!(err, CodecError::Crypto(ref msg) if msg.contains("InvalidClaim")),
574 "got {err:?}"
575 );
576 }
577
578 #[test]
579 fn session_token_not_verifiable_under_mcp_verify_at() {
580 // The inverse: a session token never stamps a footer at all, so
581 // verify_mcp_at's kid-in-footer requirement rejects it up front.
582 let (minter, verifier) = v4_public_pair();
583 let tok = minter.mint(&sample_claims(10_000)).unwrap();
584 let err = verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap_err();
585 assert!(matches!(err, CodecError::MissingKid), "got {err:?}");
586 }
587
588 #[test]
589 fn paseto_v4_public_mcp_verifier_derives_from_minter() {
590 // Parity with the session path: the derived verifier verifies what the
591 // same minter produces.
592 let (minter, _) = v4_public_pair();
593 let derived = minter.verifier().unwrap();
594 let c = sample_mcp_claims(10_000);
595 let tok = minter.mint_mcp(&c, TEST_KID).unwrap();
596 assert_eq!(derived.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap(), c);
597 }
598}