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