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, SignedArtifact, 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    /// Sign a [`SignedArtifact`] (R732-F6) — same flat-payload + `{"kid"}`
232    /// footer envelope as [`mint_mcp`](Self::mint_mcp), plus the artifact
233    /// kind's own implicit assertion, so the result never verifies as an
234    /// access token and no access token verifies as it. Verified by
235    /// `KeySetVerifier::verify_artifact` / `PasetoV4PublicVerifier::verify_artifact`.
236    /// The key must be published with the issuer role.
237    pub fn mint_artifact<T: SignedArtifact>(&self, artifact: &T, kid: &str) -> Result<String, CodecError> {
238        let payload = serde_json::to_vec(artifact)?;
239        let footer = serde_json::to_vec(&serde_json::json!({ "kid": kid }))?;
240        PublicToken::sign(&self.secret, &payload, Some(&footer), Some(T::IMPLICIT_ASSERTION))
241            .map_err(codec_err)
242    }
243}
244
245impl TokenMinter for PasetoV4SecretMinter {
246    fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
247        let mut p = PasetoClaims::new_expires_in(&core::time::Duration::ZERO)
248            .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
249        p.non_expiring();
250        let cheers_value = serde_json::to_value(claims)?;
251        p.add_additional("cheers", cheers_value)
252            .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
253        public::sign(&self.secret, &p, None, None).map_err(codec_err)
254    }
255}
256
257// ----------------------------------------------------------------------------
258// HmacBlobCodec — `b64url(json).b64url(hmac)`
259// ----------------------------------------------------------------------------
260
261/// HMAC-SHA256 blob codec. Cleartext payload, authenticated only.
262///
263/// Wire format: `b64url(json(claims)) + "." + b64url(hmac_sha256(json(claims)))`.
264/// The payload is *not* encrypted; anyone who has the token can read the
265/// claims. Pick [`PasetoV4Codec`] when that matters.
266pub struct HmacBlobCodec {
267    key: [u8; 32],
268}
269
270impl HmacBlobCodec {
271    pub fn new(key: [u8; 32]) -> Self {
272        Self { key }
273    }
274}
275
276impl TokenMinter for HmacBlobCodec {
277    fn mint(&self, claims: &Claims) -> Result<String, CodecError> {
278        let payload = serde_json::to_vec(claims)?;
279        let mut mac =
280            HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
281        mac.update(&payload);
282        let tag = mac.finalize().into_bytes();
283        let mut out = String::with_capacity(payload.len() * 2 + 64);
284        out.push_str(&URL_SAFE_NO_PAD.encode(&payload));
285        out.push('.');
286        out.push_str(&URL_SAFE_NO_PAD.encode(tag));
287        Ok(out)
288    }
289}
290
291impl TokenVerifier for HmacBlobCodec {
292    fn verify_at(&self, token: &str, now: i64) -> Result<Claims, CodecError> {
293        let (payload_b64, tag_b64) = token.split_once('.').ok_or(CodecError::Malformed)?;
294        let payload = URL_SAFE_NO_PAD
295            .decode(payload_b64)
296            .map_err(|_| CodecError::Malformed)?;
297        let tag = URL_SAFE_NO_PAD
298            .decode(tag_b64)
299            .map_err(|_| CodecError::Malformed)?;
300        let mut mac =
301            HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
302        mac.update(&payload);
303        let expected = mac.finalize().into_bytes();
304        if expected.ct_eq(&tag).unwrap_u8() != 1 {
305            return Err(CodecError::SignatureMismatch);
306        }
307        let claims: Claims = serde_json::from_slice(&payload)?;
308        if claims.is_expired_at(now) {
309            return Err(CodecError::Expired);
310        }
311        Ok(claims)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use cheers_core::yah_scopes;
319    use cheers_core::{DeviceBinding, DeviceId, UserId};
320
321    fn sample_claims(exp: i64) -> Claims {
322        Claims::new(
323            UserId::new("u1"),
324            DeviceId::new("d1"),
325            DeviceBinding::OidcGoogle,
326            1_000,
327            exp,
328        )
329    }
330
331    // ---- HmacBlobCodec ------------------------------------------------------
332
333    #[test]
334    fn hmac_blob_roundtrip() {
335        let codec = HmacBlobCodec::new([7u8; 32]);
336        let c = sample_claims(10_000);
337        let tok = codec.mint(&c).unwrap();
338        assert!(tok.contains('.'));
339        let back = codec.verify_at(&tok, 5_000).unwrap();
340        assert_eq!(back, c);
341    }
342
343    #[test]
344    fn hmac_blob_rejects_expired() {
345        let codec = HmacBlobCodec::new([7u8; 32]);
346        let c = sample_claims(100);
347        let tok = codec.mint(&c).unwrap();
348        let err = codec.verify_at(&tok, 200).unwrap_err();
349        assert!(matches!(err, CodecError::Expired));
350    }
351
352    #[test]
353    fn hmac_blob_rejects_wrong_key() {
354        let a = HmacBlobCodec::new([1u8; 32]);
355        let b = HmacBlobCodec::new([2u8; 32]);
356        let tok = a.mint(&sample_claims(10_000)).unwrap();
357        let err = b.verify_at(&tok, 5_000).unwrap_err();
358        assert!(matches!(err, CodecError::SignatureMismatch));
359    }
360
361    #[test]
362    fn hmac_blob_rejects_tampered_payload() {
363        let codec = HmacBlobCodec::new([7u8; 32]);
364        let tok = codec.mint(&sample_claims(10_000)).unwrap();
365        let (payload, tag) = tok.split_once('.').unwrap();
366        // Re-encode an *altered* payload while keeping the original tag.
367        let altered = {
368            let mut bytes = URL_SAFE_NO_PAD.decode(payload).unwrap();
369            let last = bytes.len() - 1;
370            bytes[last] ^= 0x01;
371            URL_SAFE_NO_PAD.encode(bytes)
372        };
373        let bad = format!("{altered}.{tag}");
374        let err = codec.verify_at(&bad, 5_000).unwrap_err();
375        assert!(matches!(err, CodecError::SignatureMismatch));
376    }
377
378    #[test]
379    fn hmac_blob_rejects_malformed() {
380        let codec = HmacBlobCodec::new([7u8; 32]);
381        assert!(matches!(
382            codec.verify_at("no-dot", 0).unwrap_err(),
383            CodecError::Malformed
384        ));
385        assert!(matches!(
386            codec.verify_at("not_base64!.also_not", 0).unwrap_err(),
387            CodecError::Malformed
388        ));
389    }
390
391    // ---- PasetoV4Codec ------------------------------------------------------
392
393    #[test]
394    fn paseto_v4_roundtrip() {
395        let codec = PasetoV4Codec::new(&[42u8; 32]).unwrap();
396        let c = sample_claims(10_000);
397        let tok = codec.mint(&c).unwrap();
398        assert!(tok.starts_with("v4.local."));
399        let back = codec.verify_at(&tok, 5_000).unwrap();
400        assert_eq!(back, c);
401    }
402
403    #[test]
404    fn paseto_v4_rejects_expired() {
405        let codec = PasetoV4Codec::new(&[42u8; 32]).unwrap();
406        let c = sample_claims(100);
407        let tok = codec.mint(&c).unwrap();
408        let err = codec.verify_at(&tok, 200).unwrap_err();
409        assert!(matches!(err, CodecError::Expired));
410    }
411
412    #[test]
413    fn paseto_v4_rejects_wrong_key() {
414        let a = PasetoV4Codec::new(&[1u8; 32]).unwrap();
415        let b = PasetoV4Codec::new(&[2u8; 32]).unwrap();
416        let tok = a.mint(&sample_claims(10_000)).unwrap();
417        let err = b.verify_at(&tok, 5_000).unwrap_err();
418        // Wrong key fails AEAD decryption; pasetors maps that to TokenValidation.
419        assert!(matches!(err, CodecError::SignatureMismatch));
420    }
421
422    #[test]
423    fn paseto_v4_rejects_malformed() {
424        let codec = PasetoV4Codec::new(&[42u8; 32]).unwrap();
425        assert!(matches!(
426            codec.verify_at("not-a-paseto", 0).unwrap_err(),
427            CodecError::Malformed
428        ));
429    }
430
431    // ---- PasetoV4Public (asymmetric: secret minter / public verifier) -------
432
433    fn v4_public_pair() -> (PasetoV4SecretMinter, PasetoV4PublicVerifier) {
434        PasetoV4SecretMinter::generate().unwrap()
435    }
436
437    #[test]
438    fn paseto_v4_public_roundtrip() {
439        let (minter, verifier) = v4_public_pair();
440        let c = sample_claims(10_000);
441        let tok = minter.mint(&c).unwrap();
442        assert!(tok.starts_with("v4.public."));
443        let back = verifier.verify_at(&tok, 5_000).unwrap();
444        assert_eq!(back, c);
445    }
446
447    #[test]
448    fn paseto_v4_public_rejects_expired() {
449        let (minter, verifier) = v4_public_pair();
450        let c = sample_claims(100);
451        let tok = minter.mint(&c).unwrap();
452        let err = verifier.verify_at(&tok, 200).unwrap_err();
453        assert!(matches!(err, CodecError::Expired));
454    }
455
456    #[test]
457    fn paseto_v4_public_rejects_wrong_key() {
458        let (minter, _) = v4_public_pair();
459        let (_, other_verifier) = v4_public_pair();
460        let tok = minter.mint(&sample_claims(10_000)).unwrap();
461        // A signature minted by one secret key must not verify under an
462        // unrelated public key.
463        let err = other_verifier.verify_at(&tok, 5_000).unwrap_err();
464        assert!(matches!(err, CodecError::SignatureMismatch));
465    }
466
467    #[test]
468    fn paseto_v4_public_verifier_derives_from_minter() {
469        // The origin can hand the edge a verify-only key derived from its
470        // secret, and tokens minted by that secret verify under it.
471        let (minter, _) = v4_public_pair();
472        let derived = minter.verifier().unwrap();
473        let c = sample_claims(10_000);
474        let tok = minter.mint(&c).unwrap();
475        assert_eq!(derived.verify_at(&tok, 5_000).unwrap(), c);
476    }
477
478    #[test]
479    fn paseto_v4_public_round_trips_through_key_bytes() {
480        // Generate a pair, then reconstruct the verifier from its serialized
481        // 32-byte public key — the shape a deployment uses (origin loads the
482        // secret, edge loads the published public key).
483        let (minter, verifier) = v4_public_pair();
484        let c = sample_claims(10_000);
485        let tok = minter.mint(&c).unwrap();
486        let pub_bytes: [u8; 32] = verifier
487            .public_key()
488            .as_bytes()
489            .try_into()
490            .expect("v4 public key is 32 bytes");
491        let rebuilt = PasetoV4PublicVerifier::from_public_key(&pub_bytes).unwrap();
492        assert_eq!(rebuilt.verify_at(&tok, 5_000).unwrap(), c);
493    }
494
495    // ---- PasetoV4 v4.public — McpClaims mint/verify (wire convention) ------
496    //
497    // R592-B7: mint_mcp/verify_mcp_at now speak the SAME envelope as
498    // kamaji-bin's verifier / cheers-mock / yubaba's minter — flat top-level
499    // JSON, i64 exp/iat, kid in the footer, low-level PublicToken sign/verify.
500    // A cross-shape token (session vs MCP) is no longer distinguished by an
501    // additional-claim key; it's distinguished by footer presence (session
502    // tokens never stamp one) and by claims deserialization (each shape's
503    // required fields differ).
504
505    use cheers_core::{
506        Actor, AuthStrength, McpClaims, Owns, PrincipalId,
507    };
508
509    const TEST_KID: &str = "codec-test-kid-1";
510
511    fn sample_mcp_claims(exp: i64) -> McpClaims {
512        // Owns is #[non_exhaustive] in cheers-core, so build via Default +
513        // field assignment from this crate.
514        let mut owns = Owns::default();
515        owns.service = vec!["svc-a".into()];
516        owns.arch_doc = vec!["doc-1".into()];
517
518        McpClaims::new(
519            "https://cheers.example",
520            "https://kamaji.camp.example",
521            PrincipalId::user("alice"),
522            1_000,
523            exp,
524            "jti-mcp-1",
525            vec![yah_scopes::CLOUD_DEPLOY, yah_scopes::CLOUD_READ],
526        )
527        .with_act(Actor::new(PrincipalId::service("agent-claude")))
528        .with_camp_id("camp-xyz")
529        .with_owns(owns)
530        .with_auth_strength(AuthStrength::UserFresh)
531    }
532
533    #[test]
534    fn paseto_v4_public_mcp_roundtrip() {
535        let (minter, verifier) = v4_public_pair();
536        let c = sample_mcp_claims(10_000);
537        let tok = minter.mint_mcp(&c, TEST_KID).unwrap();
538        assert!(tok.starts_with("v4.public."));
539        let back = verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap();
540        assert_eq!(back, c);
541    }
542
543    #[test]
544    fn paseto_v4_public_mcp_rejects_expired() {
545        let (minter, verifier) = v4_public_pair();
546        let c = sample_mcp_claims(100);
547        let tok = minter.mint_mcp(&c, TEST_KID).unwrap();
548        let err = verifier.verify_mcp_at(&tok, 200, TEST_KID).unwrap_err();
549        assert!(matches!(err, CodecError::Expired));
550    }
551
552    #[test]
553    fn paseto_v4_public_mcp_rejects_wrong_key() {
554        let (minter, _) = v4_public_pair();
555        let (_, other_verifier) = v4_public_pair();
556        let tok = minter.mint_mcp(&sample_mcp_claims(10_000), TEST_KID).unwrap();
557        let err = other_verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap_err();
558        assert!(matches!(err, CodecError::SignatureMismatch));
559    }
560
561    #[test]
562    fn paseto_v4_public_mcp_rejects_unknown_kid() {
563        // Same key, but the verifier only trusts TEST_KID — a token minted
564        // under a different kid string is rejected before signature check.
565        let (minter, verifier) = v4_public_pair();
566        let tok = minter
567            .mint_mcp(&sample_mcp_claims(10_000), "some-other-kid")
568            .unwrap();
569        let err = verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap_err();
570        assert!(
571            matches!(err, CodecError::UnknownKid(ref k) if k == "some-other-kid"),
572            "got {err:?}"
573        );
574    }
575
576    #[test]
577    fn mcp_token_not_verifiable_under_session_verify_at() {
578        // An MCP token is flat top-level JSON with i64 exp/iat — verify_at's
579        // high-level pasetors::public::verify parses the payload through
580        // Claims::from_string, which hard-rejects a numeric registered claim.
581        // The two shapes can't be confused even though both are valid
582        // v4.public signatures from the same key.
583        let (minter, verifier) = v4_public_pair();
584        let tok = minter.mint_mcp(&sample_mcp_claims(10_000), TEST_KID).unwrap();
585        let err = verifier.verify_at(&tok, 5_000).unwrap_err();
586        assert!(
587            matches!(err, CodecError::Crypto(ref msg) if msg.contains("InvalidClaim")),
588            "got {err:?}"
589        );
590    }
591
592    #[test]
593    fn session_token_not_verifiable_under_mcp_verify_at() {
594        // The inverse: a session token never stamps a footer at all, so
595        // verify_mcp_at's kid-in-footer requirement rejects it up front.
596        let (minter, verifier) = v4_public_pair();
597        let tok = minter.mint(&sample_claims(10_000)).unwrap();
598        let err = verifier.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap_err();
599        assert!(matches!(err, CodecError::MissingKid), "got {err:?}");
600    }
601
602    #[test]
603    fn paseto_v4_public_mcp_verifier_derives_from_minter() {
604        // Parity with the session path: the derived verifier verifies what the
605        // same minter produces.
606        let (minter, _) = v4_public_pair();
607        let derived = minter.verifier().unwrap();
608        let c = sample_mcp_claims(10_000);
609        let tok = minter.mint_mcp(&c, TEST_KID).unwrap();
610        assert_eq!(derived.verify_mcp_at(&tok, 5_000, TEST_KID).unwrap(), c);
611    }
612}