Skip to main content

cheers_verify/
artifact.rs

1//! [`IssuerTrust`] — which issuer a verifier believes, and how it knows that
2//! issuer's keys (R732-F6).
3//!
4//! Every [`SignedArtifact`] consumer (the revocation-set replica here; the
5//! membership snapshot and standing node binding of R732-F4/F5) needs the same
6//! answer: "was this signed by the issuer I trust?". The keys come either from
7//! one pinned public key (the edge configured with the issuer's 32 bytes) or
8//! from a JWKS key set, where only an **issuer**-role key may sign (§D5).
9
10use std::sync::Arc;
11
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use base64::Engine as _;
14use serde::{Deserialize, Serialize};
15
16use cheers_core::{CodecError, SignedArtifact};
17
18use crate::jwks::{JwksDoc, JwksError, KeySet};
19use crate::key_set::{KeySetVerifier, VerifyError};
20use crate::public_verifier::PasetoV4PublicVerifier;
21
22/// An [`IssuerTrust`] as a document an edge can persist (R732-F5).
23///
24/// Standing credentials never expire, so an edge that restarts offline must
25/// verify the ones it holds against the keys it trusted when it accepted them —
26/// not against a JWKS it cannot fetch. Write [`IssuerTrust::export`] next to
27/// the persisted binding, ledger and revocation set, and rebuild with
28/// [`IssuerTrust::from_doc`] at boot. The rebuilt trust is static: it never
29/// fetches.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct TrustDoc {
32    pub issuer: String,
33    pub anchor: AnchorDoc,
34}
35
36/// The keys of a [`TrustDoc`].
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum AnchorDoc {
40    /// One pinned Ed25519 public key, base64url without padding.
41    Pinned(String),
42    /// A JWKS key set, exactly as published (roles and owners included).
43    KeySet(JwksDoc),
44}
45
46#[derive(Clone)]
47enum Anchor {
48    Pinned(Arc<PasetoV4PublicVerifier>),
49    KeySet(KeySetVerifier),
50}
51
52/// One trusted issuer and the keys that may speak for it. Cheap to clone.
53#[derive(Clone)]
54pub struct IssuerTrust {
55    issuer: String,
56    anchor: Anchor,
57}
58
59impl std::fmt::Debug for IssuerTrust {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        let anchor = match self.anchor {
62            Anchor::Pinned(_) => "pinned",
63            Anchor::KeySet(_) => "key-set",
64        };
65        f.debug_struct("IssuerTrust")
66            .field("issuer", &self.issuer)
67            .field("anchor", &anchor)
68            .finish()
69    }
70}
71
72impl IssuerTrust {
73    /// Trust `issuer` as whatever `key` signs.
74    pub fn pinned(issuer: impl Into<String>, key: PasetoV4PublicVerifier) -> Self {
75        Self {
76            issuer: issuer.into(),
77            anchor: Anchor::Pinned(Arc::new(key)),
78        }
79    }
80
81    /// Trust `issuer` through a JWKS key set: the signing key must be
82    /// issuer-role and owned by `issuer`.
83    pub fn key_set(issuer: impl Into<String>, keys: KeySetVerifier) -> Self {
84        Self {
85            issuer: issuer.into(),
86            anchor: Anchor::KeySet(keys),
87        }
88    }
89
90    pub fn issuer(&self) -> &str {
91        &self.issuer
92    }
93
94    /// The keys this trust verifies with right now, as a persistable document.
95    /// A JWKS-cache-backed trust exports the cache's current set.
96    pub fn export(&self) -> TrustDoc {
97        let anchor = match &self.anchor {
98            Anchor::Pinned(key) => AnchorDoc::Pinned(URL_SAFE_NO_PAD.encode(key.public_key().as_bytes())),
99            Anchor::KeySet(keys) => AnchorDoc::KeySet(keys.key_set().doc().clone()),
100        };
101        TrustDoc {
102            issuer: self.issuer.clone(),
103            anchor,
104        }
105    }
106
107    /// Rebuild a trust from [`export`](Self::export)'s document. Static: it
108    /// verifies with exactly the persisted keys and never fetches.
109    pub fn from_doc(doc: TrustDoc) -> Result<Self, JwksError> {
110        match doc.anchor {
111            AnchorDoc::Pinned(b64) => {
112                let bad = |reason: String| JwksError::BadKey {
113                    kid: "<pinned>".into(),
114                    reason,
115                };
116                let bytes = URL_SAFE_NO_PAD
117                    .decode(&b64)
118                    .map_err(|e| bad(format!("base64url decode: {e}")))?;
119                let key: [u8; 32] = bytes
120                    .try_into()
121                    .map_err(|v: Vec<u8>| bad(format!("expected 32 bytes, got {}", v.len())))?;
122                let verifier = PasetoV4PublicVerifier::from_public_key(&key).map_err(|e| bad(e.to_string()))?;
123                Ok(Self::pinned(doc.issuer, verifier))
124            }
125            AnchorDoc::KeySet(jwks) => Ok(Self::key_set(
126                doc.issuer,
127                KeySetVerifier::from_key_set(KeySet::from_doc(jwks)?),
128            )),
129        }
130    }
131
132    /// Verify `token` as a `T` signed for this issuer.
133    pub async fn verify<T: SignedArtifact>(&self, token: &str) -> Result<T, VerifyError> {
134        let artifact: T = match &self.anchor {
135            Anchor::Pinned(key) => key.verify_artifact(token).map_err(from_codec)?,
136            Anchor::KeySet(keys) => keys.verify_artifact(token).await?,
137        };
138        if artifact.issuer() != self.issuer {
139            return Err(VerifyError::BadIssuer {
140                expected: self.issuer.clone(),
141                got: artifact.issuer().to_owned(),
142            });
143        }
144        Ok(artifact)
145    }
146}
147
148fn from_codec(e: CodecError) -> VerifyError {
149    match e {
150        CodecError::SignatureMismatch => VerifyError::SignatureMismatch,
151        CodecError::Serde(e) => VerifyError::BadClaims(e.to_string()),
152        other => VerifyError::Malformed(other.to_string()),
153    }
154}