Skip to main content

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}