Skip to main content

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}