1use 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#[derive(Debug)]
41pub struct KeySet {
42 keys: BTreeMap<String, VerifyingKey>,
43}
44
45impl KeySet {
46 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
83pub(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#[derive(Debug)]
123pub struct VerifiedPass {
124 claims: Claims,
125 index: BTreeMap<String, ResourceIndex>,
126}
127
128impl VerifiedPass {
129 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 #[must_use]
211 pub const fn claims(&self) -> &Claims {
212 &self.claims
213 }
214
215 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 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 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}