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.
103pub fn required(binding: Option<&str>) -> Result<&str, Error> {
104    binding
105        .filter(|binding| !binding.is_empty())
106        .ok_or_else(|| refused(BINDING_REQUIRED, "the pass carries no grant binding"))
107}
108
109/// What a binding must name of its pass: the pass's digest, issuer, holder,
110/// audience and lifetime, and exactly the grants its rights rest on.
111#[derive(Debug, Clone)]
112pub struct Bound<'a> {
113    /// The SHA-256, lowercase hex, of the pass's exact bytes.
114    pub pass: &'a str,
115    /// The pass's issuer.
116    pub iss: &'a str,
117    /// The pass's holder.
118    pub sub: &'a str,
119    /// The pass's audience.
120    pub aud: &'a str,
121    /// The pass's issue instant.
122    pub iat: u64,
123    /// The pass's first invalid instant.
124    pub exp: u64,
125    /// The grants the pass's rights rest on.
126    pub grants: &'a BTreeSet<&'a str>,
127}
128
129/// A binding whose signature, pass, issuer, holder, audience, lifetime,
130/// grant log and ancestry were checked against one verified pass.
131#[derive(Debug)]
132pub struct VerifiedBinding {
133    claims: BindingClaims,
134    by_grant: BTreeMap<String, usize>,
135}
136
137impl VerifiedBinding {
138    /// Verify `binding` as the binding of the pass `token`, already verified
139    /// as `pass`, on the grant log `log` the product's stream reads.
140    pub fn verify(
141        binding: &str,
142        token: &str,
143        pass: &VerifiedPass,
144        keys: &KeySet,
145        log: &GrantLog,
146    ) -> Result<Self, Error> {
147        let signed = pass.claims();
148        let digest = pass_digest(token);
149        let grants: BTreeSet<&str> = signed
150            .rights
151            .iter()
152            .map(|right| right.grant.as_str())
153            .collect();
154        Self::verify_bound(
155            binding,
156            &Bound {
157                pass: &digest,
158                iss: &signed.iss,
159                sub: &signed.sub,
160                aud: &signed.aud,
161                iat: signed.iat,
162                exp: signed.exp,
163                grants: &grants,
164            },
165            keys,
166            log,
167        )
168    }
169
170    /// Verify `binding` as the binding of a pass `bound` describes: one a
171    /// registry-owned producer issued and verified by its own contract,
172    /// whose binding Lys judged (`POST /grants/bindings`).
173    pub fn verify_bound(
174        binding: &str,
175        bound: &Bound<'_>,
176        keys: &KeySet,
177        log: &GrantLog,
178    ) -> Result<Self, Error> {
179        let payload: serde_json::Value =
180            serde_json::from_slice(&signed_payload(binding, keys, BINDING_TYPE)?)?;
181        if payload.get("binding").and_then(serde_json::Value::as_u64)
182            != Some(u64::from(BINDING_VERSION))
183        {
184            return Err(refused(
185                BINDING_UNSUPPORTED,
186                "the binding is not of a version this verifier reads",
187            ));
188        }
189        let claims: BindingClaims = serde_json::from_value(payload)?;
190        if claims.pass != bound.pass {
191            return Err(refused(BINDING_MISMATCH, "the binding names another pass"));
192        }
193        if (
194            claims.iss.as_str(),
195            claims.sub.as_str(),
196            claims.aud.as_str(),
197            claims.iat,
198            claims.exp,
199        ) != (bound.iss, bound.sub, bound.aud, bound.iat, bound.exp)
200        {
201            return Err(refused(
202                BINDING_MISMATCH,
203                "the binding names another issuer, holder, audience or lifetime",
204            ));
205        }
206        if &claims.log != log {
207            return Err(refused(
208                BINDING_LOG_MISMATCH,
209                "the binding names another grant log or reset epoch",
210            ));
211        }
212        let mut by_grant = BTreeMap::new();
213        for (position, dependency) in claims.dependencies.iter().enumerate() {
214            if dependency.path.first() != Some(&dependency.grant) {
215                return Err(refused(
216                    BINDING_ANCESTRY,
217                    "a dependency's path does not begin at its grant",
218                ));
219            }
220            let mut seen = BTreeSet::new();
221            if dependency
222                .path
223                .iter()
224                .any(|grant| grant.is_empty() || !seen.insert(grant))
225            {
226                return Err(refused(
227                    BINDING_ANCESTRY,
228                    "a dependency's path is empty or names a grant twice",
229                ));
230            }
231            if by_grant
232                .insert(dependency.grant.clone(), position)
233                .is_some()
234            {
235                return Err(refused(BINDING_ANCESTRY, "a grant has two dependencies"));
236            }
237        }
238        if bound.grants.len() != by_grant.len()
239            || bound
240                .grants
241                .iter()
242                .any(|grant| !by_grant.contains_key(*grant))
243        {
244            return Err(refused(
245                BINDING_ANCESTRY,
246                "the dependencies are not exactly the grants the pass's rights name",
247            ));
248        }
249        Ok(Self { claims, by_grant })
250    }
251
252    /// The verified claims.
253    #[must_use]
254    pub const fn claims(&self) -> &BindingClaims {
255        &self.claims
256    }
257
258    /// The revision a product's stream must stand at before admitting.
259    #[must_use]
260    pub const fn revision(&self) -> u64 {
261        self.claims.revision
262    }
263
264    /// The dependency of the right resting on `grant`, as a decision of the
265    /// pass names it.
266    #[must_use]
267    pub fn dependency(&self, grant: &str) -> Option<&Dependency> {
268        self.by_grant
269            .get(grant)
270            .and_then(|position| self.claims.dependencies.get(*position))
271    }
272}