Skip to main content

lys_pass/
verify.rs

1//! Strict Ed25519 verification against a caller-owned published key cache.
2
3use std::collections::BTreeMap;
4
5use base64::Engine;
6use base64::engine::general_purpose::URL_SAFE_NO_PAD;
7use ed25519_dalek::{Signature, VerifyingKey};
8use serde::Deserialize;
9
10use crate::Error;
11use crate::rights::{Claims, Decision, Mode, Right, audience_owns, in_prefix};
12
13/// Maximum future issue or not-before offset in Unix seconds.
14///
15/// Five seconds covers small clock differences and request latency when a
16/// caller captures its verification time before the issuer signs the pass.
17pub const CLOCK_SKEW: u64 = 5;
18
19fn check_start(claims: &Claims, now: u64) -> Result<(), Error> {
20    let starts_at = claims.iat.max(claims.nbf.unwrap_or(claims.iat));
21    let skew = starts_at.saturating_sub(now);
22    if skew > CLOCK_SKEW {
23        return Err(Error::NotYetValid {
24            skew_ms: u128::from(skew) * 1000,
25        });
26    }
27    Ok(())
28}
29
30#[derive(Deserialize)]
31struct Header {
32    alg: String,
33    typ: String,
34    kid: String,
35    crit: Option<Vec<String>>,
36    b64: Option<bool>,
37}
38
39#[derive(Deserialize)]
40struct Jwk {
41    kty: String,
42    crv: String,
43    kid: String,
44    x: String,
45    alg: Option<String>,
46    #[serde(rename = "use")]
47    usage: Option<String>,
48    key_ops: Option<Vec<String>>,
49}
50
51#[derive(Deserialize)]
52struct Jwks {
53    keys: Vec<Jwk>,
54}
55
56/// The issuer's published Ed25519 verification keys, indexed by key id.
57#[derive(Debug)]
58pub struct KeySet {
59    keys: BTreeMap<String, VerifyingKey>,
60}
61
62impl KeySet {
63    /// Replace the cache only after the whole published keyset validates.
64    ///
65    /// # Errors
66    ///
67    /// Refuses unreadable JSON or key encoding, a key with the wrong type,
68    /// algorithm, use or operations, an empty or control-bearing key id,
69    /// an invalid or weak Ed25519 point, or a repeated key id.
70    pub fn from_json(json: &str) -> Result<Self, Error> {
71        let published: Jwks = serde_json::from_str(json)?;
72        let mut keys = BTreeMap::new();
73        for key in published.keys {
74            if key.kty != "OKP"
75                || key.crv != "Ed25519"
76                || key.kid.is_empty()
77                || key.kid.chars().any(char::is_control)
78                || key.alg.as_deref().is_some_and(|alg| alg != "EdDSA")
79                || key.usage.as_deref().is_some_and(|usage| usage != "sig")
80                || key
81                    .key_ops
82                    .as_ref()
83                    .is_some_and(|ops| !ops.iter().any(|op| op == "verify"))
84            {
85                return Err(Error::Invalid(
86                    "published key is not an Ed25519 signing key",
87                ));
88            }
89            let bytes = URL_SAFE_NO_PAD.decode(&key.x)?;
90            let bytes: [u8; 32] = bytes.try_into().map_err(|bytes: Vec<u8>| {
91                if bytes.is_empty() {
92                    Error::Invalid("published key is empty")
93                } else {
94                    Error::Invalid("published Ed25519 key has the wrong length")
95                }
96            })?;
97            let public = VerifyingKey::from_bytes(&bytes).map_err(Error::Signature)?;
98            if public.is_weak() || keys.insert(key.kid, public).is_some() {
99                return Err(Error::Invalid("published key is weak or duplicated"));
100            }
101        }
102        Ok(Self { keys })
103    }
104}
105
106/// The payload of the compact JWS `token`, once its header names `typ`,
107/// `EdDSA` and a published key, and its signature verifies strictly under
108/// that key. Nothing in the payload is read before then.
109pub(crate) fn signed_payload(token: &str, keys: &KeySet, typ: &str) -> Result<Vec<u8>, Error> {
110    let mut parts = token.split('.');
111    let header = parts.next().ok_or(Error::Invalid("missing header"))?;
112    let payload = parts.next().ok_or(Error::Invalid("missing claims"))?;
113    let signature = parts.next().ok_or(Error::Invalid("missing signature"))?;
114    if parts.next().is_some() {
115        return Err(Error::Invalid("too many token segments"));
116    }
117    let decoded: Header = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header)?)?;
118    if decoded.alg != "EdDSA"
119        || decoded.typ != typ
120        || decoded.kid.is_empty()
121        || decoded.kid.chars().any(char::is_control)
122        || decoded
123            .crit
124            .as_ref()
125            .is_some_and(|values| !values.is_empty())
126        || decoded.b64 == Some(false)
127    {
128        return Err(Error::Invalid("unsupported signed token header"));
129    }
130    let key = keys
131        .keys
132        .get(&decoded.kid)
133        .ok_or_else(|| Error::UnpublishedKey(decoded.kid.clone()))?;
134    let signature =
135        Signature::from_slice(&URL_SAFE_NO_PAD.decode(signature)?).map_err(Error::Signature)?;
136    key.verify_strict(format!("{header}.{payload}").as_bytes(), &signature)
137        .map_err(Error::Signature)?;
138    Ok(URL_SAFE_NO_PAD.decode(payload)?)
139}
140
141type ActionIndex = BTreeMap<String, Vec<usize>>;
142type ResourceIndex = BTreeMap<String, ActionIndex>;
143
144/// Claims whose signature, issuer, audience and construction time were checked.
145#[derive(Debug)]
146pub struct VerifiedPass {
147    claims: Claims,
148    index: BTreeMap<String, ResourceIndex>,
149}
150
151impl VerifiedPass {
152    /// Verify the complete signed token before trusting any permission claim.
153    ///
154    /// # Errors
155    ///
156    /// Refuses a malformed token, unsupported header, unpublished key or
157    /// invalid signature. The trusted issuer and audience must be present
158    /// and match; the holder and responsibility must be complete, the
159    /// lifetime nonempty and unexpired, and the start no more than
160    /// [`CLOCK_SKEW`] ahead of `now`. Rights or a truncation marker naming
161    /// another audience, incomplete rights and empty or control-bearing
162    /// targets or actions are refused before an index is returned.
163    pub fn verify(
164        token: &str,
165        keys: &KeySet,
166        issuer: &str,
167        audience: &str,
168        now: u64,
169    ) -> Result<Self, Error> {
170        if issuer.is_empty() || audience.is_empty() {
171            return Err(Error::Invalid("trusted issuer or audience is missing"));
172        }
173        let claims: Claims = serde_json::from_slice(&signed_payload(token, keys, "JWT")?)?;
174        if claims.iss != issuer {
175            return Err(Error::WrongIssuer);
176        }
177        if claims.aud != audience {
178            return Err(Error::WrongAudience);
179        }
180        if claims.sub.is_empty()
181            || claims.sub != claims.holder.id
182            || !matches!(
183                claims.holder.kind.as_str(),
184                "person" | "agent" | "connector" | "service_account" | "machine"
185            )
186            || (claims.holder.kind != "person"
187                && claims
188                    .holder
189                    .responsible
190                    .as_deref()
191                    .is_none_or(str::is_empty))
192        {
193            return Err(Error::Invalid("holder or responsibility is missing"));
194        }
195        if claims.exp <= claims.iat {
196            return Err(Error::Invalid("pass lifetime is empty"));
197        }
198        if claims.exp <= now {
199            return Err(Error::Expired);
200        }
201        check_start(&claims, now)?;
202        if claims
203            .rights_truncated
204            .as_deref()
205            .is_some_and(|app| app != audience)
206        {
207            return Err(Error::RightsOutsideAudience);
208        }
209        let mut index: BTreeMap<String, ResourceIndex> = BTreeMap::new();
210        for (position, right) in claims.rights.iter().enumerate() {
211            if !audience_owns(&right.resource.kind, audience) {
212                return Err(Error::RightsOutsideAudience);
213            }
214            if right.resource.kind.is_empty()
215                || right.resource.id.is_empty()
216                || right.grant.is_empty()
217                || right.actions.is_empty()
218                || right.resource.kind.chars().any(char::is_control)
219                || right.resource.id.chars().any(char::is_control)
220            {
221                return Err(Error::Invalid("effective right is incomplete"));
222            }
223            for action in &right.actions {
224                if action.is_empty() || action.chars().any(char::is_control) {
225                    return Err(Error::Invalid("effective action is invalid"));
226                }
227                index
228                    .entry(right.resource.kind.clone())
229                    .or_default()
230                    .entry(right.resource.id.clone())
231                    .or_default()
232                    .entry(action.clone())
233                    .or_default()
234                    .push(position);
235            }
236        }
237        Ok(Self { claims, index })
238    }
239
240    /// The verified claims, borrowed without cloning the pass.
241    #[must_use]
242    pub const fn claims(&self) -> &Claims {
243        &self.claims
244    }
245
246    /// Decide an exact target from the index; reach is never recomputed locally.
247    ///
248    /// # Errors
249    ///
250    /// Refuses an expired pass, a start more than [`CLOCK_SKEW`] ahead of `now`,
251    /// truncated rights or an inconsistent right index. No matching right
252    /// is a successful [`Decision::Refused`], not an error.
253    pub fn evaluate(
254        &self,
255        kind: &str,
256        id: &str,
257        action: &str,
258        now: u64,
259    ) -> Result<Decision<'_>, Error> {
260        if self.claims.exp <= now {
261            return Err(Error::Expired);
262        }
263        check_start(&self.claims, now)?;
264        if let Some(app) = &self.claims.rights_truncated {
265            return Err(Error::Truncated(app.clone()));
266        }
267        let Some(entries) = self
268            .index
269            .get(kind)
270            .and_then(|ids| ids.get(id))
271            .and_then(|actions| actions.get(action))
272        else {
273            return Ok(Decision::Refused);
274        };
275        let mut held = None;
276        for position in entries {
277            let right = self
278                .claims
279                .rights
280                .get(*position)
281                .ok_or(Error::Invalid("right index is inconsistent"))?;
282            if right.mode == Mode::Outright {
283                return Ok(Decision::Allowed {
284                    grant: &right.grant,
285                });
286            }
287            if held.is_none() {
288                held = Some(Decision::Held {
289                    grant: &right.grant,
290                    mode: right.mode,
291                });
292            }
293        }
294        Ok(held.unwrap_or(Decision::Refused))
295    }
296
297    /// Hot routes accept only an outright, unexpired and complete grant.
298    ///
299    /// # Errors
300    ///
301    /// Refuses an expired or not-yet-valid pass, truncated rights or an
302    /// inconsistent right index, as [`Self::evaluate`] does. A held or
303    /// missing right returns `false`.
304    pub fn hot_allowed(&self, kind: &str, id: &str, action: &str, now: u64) -> Result<bool, Error> {
305        Ok(matches!(
306            self.evaluate(kind, id, action, now)?,
307            Decision::Allowed { .. }
308        ))
309    }
310
311    /// Enumerate explicitly signed rights under a kind prefix, without granting descendants.
312    pub fn rights_in_prefix<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = &'a Right> + 'a {
313        self.claims
314            .rights
315            .iter()
316            .filter(move |right| in_prefix(&right.resource.kind, prefix))
317    }
318}