Skip to main content

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}