cheers_verify/public_verifier.rs
1//! PASETO v4.public **verifier** — the edge-safe half of the asymmetric codec.
2//!
3//! The origin holds the secret-key minter (`cheers_server::PasetoV4SecretMinter`);
4//! the edge holds *only* the public-key verifier here, which can check a
5//! signature but is physically unable to forge one. Both reuse the v4.local
6//! payload convention — the cheers [`Claims`] ride under a single `"cheers"`
7//! additional claim, with PASETO's own `exp` left non-expiring so that
8//! `verify_at(now)` owns the expiry decision (parity with the symmetric impls).
9//!
10//! v4.public signs the payload in the clear, so anything minted this way is
11//! readable by whoever holds the token: only non-secret claims (identity +
12//! expiry + jti) belong in an access token.
13
14use cheers_core::{Claims, CodecError, McpClaims, TokenVerifier};
15use pasetors::claims::ClaimsValidationRules;
16use pasetors::keys::AsymmetricPublicKey;
17use pasetors::public;
18use pasetors::token::UntrustedToken;
19use pasetors::version4::{PublicToken, V4};
20
21/// Map a `pasetors` crypto-library error into the shared [`CodecError`].
22///
23/// Lives here rather than as `impl From<pasetors::errors::Error> for CodecError`
24/// because the orphan rule would force that impl into `cheers-core`, dragging
25/// pasetors into the keyless contract crate. `cheers-server`'s symmetric codecs
26/// reuse this via `cheers_verify::codec_err`.
27pub fn codec_err(e: pasetors::errors::Error) -> CodecError {
28 use pasetors::errors::Error as P;
29 match e {
30 P::TokenValidation => CodecError::SignatureMismatch,
31 P::ClaimValidation(_) => CodecError::Expired,
32 other => CodecError::Crypto(format!("{other:?}")),
33 }
34}
35
36/// PASETO v4.public **verifier** — checks signatures with an Ed25519 public key.
37///
38/// The edge-safe half of v4.public: a public key can *verify* a token but is
39/// physically unable to *mint* one. It is the only [`TokenVerifier`] in the
40/// cheers tree that doesn't also carry minting power (the symmetric codecs in
41/// `cheers-server` are [`Codec`](cheers_core::Codec)s, so they do) — which is
42/// exactly what lets an edge (e.g. a CF Worker) authenticate sessions without the
43/// forge-any-session blast radius of holding a minting key. Pair with a
44/// `cheers_server::PasetoV4SecretMinter` at the origin.
45pub struct PasetoV4PublicVerifier {
46 public: AsymmetricPublicKey<V4>,
47}
48
49impl PasetoV4PublicVerifier {
50 /// Build from a 32-byte Ed25519 public key (the V4 public-key size).
51 pub fn from_public_key(bytes: &[u8; 32]) -> Result<Self, CodecError> {
52 let public = AsymmetricPublicKey::<V4>::from(bytes)
53 .map_err(|e| CodecError::Crypto(format!("{e:?}")))?;
54 Ok(Self { public })
55 }
56
57 /// Wrap an already-parsed Ed25519 public key — e.g. the half a minter derives
58 /// from its secret key (`cheers_server::PasetoV4SecretMinter::verifier`).
59 pub fn from_key(public: AsymmetricPublicKey<V4>) -> Self {
60 Self { public }
61 }
62
63 /// The underlying public key, e.g. to serialize the 32 bytes for publishing
64 /// to an edge.
65 pub fn public_key(&self) -> &AsymmetricPublicKey<V4> {
66 &self.public
67 }
68
69 /// Verify an MCP-call token signed by
70 /// `cheers_server::PasetoV4SecretMinter::mint_mcp`, returning the
71 /// embedded [`McpClaims`].
72 ///
73 /// **Wire convention** (R592-B7) — the shape kamaji-bin's verifier,
74 /// `cheers-mock`, and yubaba's minter already share: `claims` is the
75 /// token's FLAT top-level JSON payload (no wrapping claim key), verified
76 /// via pasetors' LOW-LEVEL [`PublicToken::verify`] — never the high-level
77 /// `public::verify`, which hard-rejects the i64 `exp`/`iat` this shape
78 /// carries.
79 ///
80 /// `kid` is REQUIRED in the PASETO footer: an empty footer is
81 /// [`CodecError::MissingKid`]; a footer that doesn't parse or has no
82 /// `kid` field is [`CodecError::Malformed`]; a `kid` that doesn't match
83 /// `expected_kid` is [`CodecError::UnknownKid`] (this verifier holds a
84 /// single trusted `(kid, public key)` pair — it has no JWKS of its own,
85 /// so "does the footer's kid match the one caller I trust" is the whole
86 /// key-selection check here; a full JWKS lookup across many kids is a
87 /// consumer's job, e.g. kamaji-bin's `AuthVerifier`).
88 ///
89 /// Expiry is owned by [`McpClaims::is_expired_at`] (parity with the
90 /// session-claim path) — `exp <= now` is [`CodecError::Expired`].
91 /// `iss`/`aud` are NOT checked here — that policy varies per consumer
92 /// (which issuer/audience a caller expects), so it lives at the caller
93 /// (e.g. `cloud-admin`'s `viewer_from_claims`), not in this shared
94 /// primitive.
95 pub fn verify_mcp_at(
96 &self,
97 token: &str,
98 now: i64,
99 expected_kid: &str,
100 ) -> Result<McpClaims, CodecError> {
101 let untrusted = UntrustedToken::<pasetors::token::Public, V4>::try_from(token)
102 .map_err(|_| CodecError::Malformed)?;
103
104 // Footer/kid check BEFORE the crypto check — cheap, and it's the
105 // key-selection signal a real multi-kid verifier would need anyway.
106 // The footer bytes are bound into the signature regardless of when
107 // we read them, so this ordering doesn't weaken anything.
108 let footer_bytes = untrusted.untrusted_footer();
109 if footer_bytes.is_empty() {
110 return Err(CodecError::MissingKid);
111 }
112 let footer: serde_json::Value =
113 serde_json::from_slice(footer_bytes).map_err(|_| CodecError::Malformed)?;
114 let kid = footer
115 .get("kid")
116 .and_then(|v| v.as_str())
117 .ok_or(CodecError::Malformed)?;
118 if kid != expected_kid {
119 return Err(CodecError::UnknownKid(kid.to_string()));
120 }
121
122 let trusted = PublicToken::verify(&self.public, &untrusted, None, None).map_err(codec_err)?;
123 let out: McpClaims = serde_json::from_str(trusted.payload())?;
124 if out.is_expired_at(now) {
125 return Err(CodecError::Expired);
126 }
127 Ok(out)
128 }
129}
130
131impl TokenVerifier for PasetoV4PublicVerifier {
132 fn verify_at(&self, token: &str, now: i64) -> Result<Claims, CodecError> {
133 let untrusted = UntrustedToken::<pasetors::token::Public, V4>::try_from(token)
134 .map_err(|_| CodecError::Malformed)?;
135 // Skip pasetors's wall-clock validation; enforce `now` ourselves for
136 // testability and parity with the symmetric impls.
137 let mut rules = ClaimsValidationRules::new();
138 rules.allow_non_expiring();
139 let trusted = public::verify(&self.public, &untrusted, &rules, None, None)
140 .map_err(codec_err)?;
141 let pclaims = trusted.payload_claims().ok_or(CodecError::Malformed)?;
142 let v = pclaims
143 .get_claim("cheers")
144 .ok_or(CodecError::Malformed)?
145 .clone();
146 let out: Claims = serde_json::from_value(v)?;
147 if out.is_expired_at(now) {
148 return Err(CodecError::Expired);
149 }
150 Ok(out)
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn rejects_malformed() {
160 let verifier = PasetoV4PublicVerifier::from_public_key(&[0u8; 32]).unwrap();
161 assert!(matches!(
162 verifier.verify_at("not-a-paseto", 0).unwrap_err(),
163 CodecError::Malformed
164 ));
165 }
166
167 #[test]
168 fn codec_err_maps_token_validation_to_signature_mismatch() {
169 let e = codec_err(pasetors::errors::Error::TokenValidation);
170 assert!(matches!(e, CodecError::SignatureMismatch));
171 }
172
173 // The full mint -> verify -> expiry round-trip lives in cheers-server's
174 // tests: minting needs the secret key, which is origin-only and therefore
175 // not reachable from this verify-only crate (the property under test).
176}