lys_pass/binding.rs
1//! A pass's grant binding (DIRECTORY-089 R2): the signed statement, separate
2//! from the pass, of the grant log, the revision and the complete ancestry
3//! each of the pass's rights was decided from.
4//!
5//! The binding is its own compact JWS, typed [`BINDING_TYPE`], signed by the
6//! same issuer key as the pass and published beside it in the token answer
7//! only when the product asks for it. The pass's bytes are never changed:
8//! the binding names the pass by the SHA-256 of its exact compact bytes, its
9//! issuer, holder, audience and lifetime, so it cannot be moved to another
10//! pass. A product maps each right to its dependency, keys its cache by
11//! (grant log, grant id), and admits only once its grant stream stands at
12//! [`BindingClaims::revision`] or later. A revocation names one grant; its
13//! descendants are resolved here, from the paths.
14//!
15//! A product that requires bindings refuses a pass without one by
16//! [`BINDING_REQUIRED`] and never assumes a revision of zero; a product
17//! that does not ask keeps verifying passes exactly as before.
18
19use std::collections::{BTreeMap, BTreeSet};
20
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24use crate::Error;
25use crate::membership::GrantLog;
26use crate::verify::{KeySet, VerifiedPass, signed_payload};
27
28/// The binding version this crate reads and the issuer produces.
29pub const BINDING_VERSION: u32 = 1;
30/// The token request form value asking for a binding of this version.
31pub const BINDING_REQUEST: &str = "1";
32/// The JWS `typ` a binding is signed under, never a pass's `JWT`.
33pub const BINDING_TYPE: &str = "lys-grant-binding+jwt";
34
35/// A binding is required and absent.
36pub const BINDING_REQUIRED: &str = "grant_binding_required";
37/// The binding is of a version this crate does not read.
38pub const BINDING_UNSUPPORTED: &str = "grant_binding_unsupported";
39/// The binding names another pass, issuer, holder, audience or lifetime.
40pub const BINDING_MISMATCH: &str = "grant_binding_mismatch";
41/// The binding names another grant log or reset epoch.
42pub const BINDING_LOG_MISMATCH: &str = "grant_binding_log_mismatch";
43/// A dependency's ancestry is missing, repeated or not the pass's rights.
44pub const BINDING_ANCESTRY: &str = "grant_binding_ancestry_refused";
45
46/// One right's grant and its complete ancestry.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema), schema(as = PassGrantDependency))]
50pub struct Dependency {
51 /// The grant a right of the pass names.
52 pub grant: String,
53 /// That grant and every ancestor, from the grant to its root.
54 pub path: Vec<String>,
55}
56
57/// A binding's signed claims.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema), schema(as = PassGrantBinding))]
61pub struct BindingClaims {
62 /// The binding version, [`BINDING_VERSION`].
63 pub binding: u32,
64 /// The issuer, the pass's.
65 pub iss: String,
66 /// The holder, the pass's.
67 pub sub: String,
68 /// The audience, the pass's.
69 pub aud: String,
70 /// The pass's issue instant.
71 pub iat: u64,
72 /// The pass's first invalid instant.
73 pub exp: u64,
74 /// The SHA-256, lowercase hex, of the pass's exact compact bytes.
75 pub pass: String,
76 /// The grant log every dependency belongs to.
77 pub log: GrantLog,
78 /// The receipt revision the rights were decided at: a product admits
79 /// only once its grant stream stands here or later.
80 pub revision: u64,
81 /// One dependency per grant the pass's rights name, in grant order.
82 pub dependencies: Vec<Dependency>,
83}
84
85fn refused(name: &'static str, reason: &'static str) -> Error {
86 Error::Binding { name, reason }
87}
88
89/// The SHA-256, lowercase hex, of a pass's exact compact bytes.
90#[must_use]
91pub fn pass_digest(token: &str) -> String {
92 const DIGITS: &[u8; 16] = b"0123456789abcdef";
93 let digest = Sha256::digest(token.as_bytes());
94 let mut text = String::with_capacity(digest.len() * 2);
95 for byte in digest {
96 text.push(char::from(DIGITS[usize::from(byte >> 4)]));
97 text.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
98 }
99 text
100}
101
102/// The binding a revocation-enabled product requires, or its absence by name.
103///
104/// # Errors
105///
106/// Refuses an absent or empty binding with [`BINDING_REQUIRED`].
107pub fn required(binding: Option<&str>) -> Result<&str, Error> {
108 binding
109 .filter(|binding| !binding.is_empty())
110 .ok_or_else(|| refused(BINDING_REQUIRED, "the pass carries no grant binding"))
111}
112
113/// What a binding must name of its pass: the pass's digest, issuer, holder,
114/// audience and lifetime, and exactly the grants its rights rest on.
115#[derive(Debug, Clone)]
116pub struct Bound<'a> {
117 /// The SHA-256, lowercase hex, of the pass's exact bytes.
118 pub pass: &'a str,
119 /// The pass's issuer.
120 pub iss: &'a str,
121 /// The pass's holder.
122 pub sub: &'a str,
123 /// The pass's audience.
124 pub aud: &'a str,
125 /// The pass's issue instant.
126 pub iat: u64,
127 /// The pass's first invalid instant.
128 pub exp: u64,
129 /// The grants the pass's rights rest on.
130 pub grants: &'a BTreeSet<&'a str>,
131}
132
133/// A binding whose signature, pass, issuer, holder, audience, lifetime,
134/// grant log and ancestry were checked against one verified pass.
135#[derive(Debug)]
136pub struct VerifiedBinding {
137 claims: BindingClaims,
138 by_grant: BTreeMap<String, usize>,
139}
140
141impl VerifiedBinding {
142 /// Verify `binding` as the binding of the pass `token`, already verified
143 /// as `pass`, on the grant log `log` the product's stream reads.
144 ///
145 /// # Errors
146 ///
147 /// Refuses a binding whose encoding, header, signing key or signature
148 /// does not verify, or whose claims do not match this pass, grant log
149 /// and grants. An unsupported version or malformed dependency ancestry
150 /// is refused by the same checks as [`Self::verify_bound`].
151 pub fn verify(
152 binding: &str,
153 token: &str,
154 pass: &VerifiedPass,
155 keys: &KeySet,
156 log: &GrantLog,
157 ) -> Result<Self, Error> {
158 let signed = pass.claims();
159 let digest = pass_digest(token);
160 let grants: BTreeSet<&str> = signed
161 .rights
162 .iter()
163 .map(|right| right.grant.as_str())
164 .collect();
165 Self::verify_bound(
166 binding,
167 &Bound {
168 pass: &digest,
169 iss: &signed.iss,
170 sub: &signed.sub,
171 aud: &signed.aud,
172 iat: signed.iat,
173 exp: signed.exp,
174 grants: &grants,
175 },
176 keys,
177 log,
178 )
179 }
180
181 /// Verify `binding` as the binding of a pass `bound` describes: one a
182 /// registry-owned producer issued and verified by its own contract,
183 /// whose binding Lys judged (`POST /grants/bindings`).
184 ///
185 /// # Errors
186 ///
187 /// Refuses unreadable token encoding or claims, an unsupported header,
188 /// an unpublished signing key or an invalid signature. Binding refusals
189 /// name an unsupported version, a different pass or its issuer, holder,
190 /// audience or lifetime, or another grant log or epoch. Ancestry is
191 /// refused when a path does not begin at its grant, contains an empty
192 /// or repeated grant, repeats a dependency, or does not cover exactly
193 /// the grants `bound` names.
194 pub fn verify_bound(
195 binding: &str,
196 bound: &Bound<'_>,
197 keys: &KeySet,
198 log: &GrantLog,
199 ) -> Result<Self, Error> {
200 let payload: serde_json::Value =
201 serde_json::from_slice(&signed_payload(binding, keys, BINDING_TYPE)?)?;
202 if payload.get("binding").and_then(serde_json::Value::as_u64)
203 != Some(u64::from(BINDING_VERSION))
204 {
205 return Err(refused(
206 BINDING_UNSUPPORTED,
207 "the binding is not of a version this verifier reads",
208 ));
209 }
210 let claims: BindingClaims = serde_json::from_value(payload)?;
211 if claims.pass != bound.pass {
212 return Err(refused(BINDING_MISMATCH, "the binding names another pass"));
213 }
214 if (
215 claims.iss.as_str(),
216 claims.sub.as_str(),
217 claims.aud.as_str(),
218 claims.iat,
219 claims.exp,
220 ) != (bound.iss, bound.sub, bound.aud, bound.iat, bound.exp)
221 {
222 return Err(refused(
223 BINDING_MISMATCH,
224 "the binding names another issuer, holder, audience or lifetime",
225 ));
226 }
227 if &claims.log != log {
228 return Err(refused(
229 BINDING_LOG_MISMATCH,
230 "the binding names another grant log or reset epoch",
231 ));
232 }
233 let mut by_grant = BTreeMap::new();
234 for (position, dependency) in claims.dependencies.iter().enumerate() {
235 if dependency.path.first() != Some(&dependency.grant) {
236 return Err(refused(
237 BINDING_ANCESTRY,
238 "a dependency's path does not begin at its grant",
239 ));
240 }
241 let mut seen = BTreeSet::new();
242 if dependency
243 .path
244 .iter()
245 .any(|grant| grant.is_empty() || !seen.insert(grant))
246 {
247 return Err(refused(
248 BINDING_ANCESTRY,
249 "a dependency's path is empty or names a grant twice",
250 ));
251 }
252 if by_grant
253 .insert(dependency.grant.clone(), position)
254 .is_some()
255 {
256 return Err(refused(BINDING_ANCESTRY, "a grant has two dependencies"));
257 }
258 }
259 if bound.grants.len() != by_grant.len()
260 || bound
261 .grants
262 .iter()
263 .any(|grant| !by_grant.contains_key(*grant))
264 {
265 return Err(refused(
266 BINDING_ANCESTRY,
267 "the dependencies are not exactly the grants the pass's rights name",
268 ));
269 }
270 Ok(Self { claims, by_grant })
271 }
272
273 /// The verified claims.
274 #[must_use]
275 pub const fn claims(&self) -> &BindingClaims {
276 &self.claims
277 }
278
279 /// The revision a product's stream must stand at before admitting.
280 #[must_use]
281 pub const fn revision(&self) -> u64 {
282 self.claims.revision
283 }
284
285 /// The dependency of the right resting on `grant`, as a decision of the
286 /// pass names it.
287 #[must_use]
288 pub fn dependency(&self, grant: &str) -> Option<&Dependency> {
289 self.by_grant
290 .get(grant)
291 .and_then(|position| self.claims.dependencies.get(*position))
292 }
293}