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}