Skip to main content

solid_pod_rs/auth/
self_signed.rs

1//! Self-signed proof verifier abstraction (Sprint 11 row 152).
2//!
3//! Controlled Identifier (CID) authentication admits multiple proof
4//! formats: did:key self-signed JWTs (Sprint 11 row 153), NIP-98
5//! Nostr events (Sprint 3 `auth::nip98`), did:nostr bridged profiles
6//! (Sprint 6 `interop::did_nostr`), and any future DID method that
7//! publishes a verification-relationship-bearing controller document.
8//!
9//! This module defines the transport-independent contract every
10//! verifier implements, plus a fan-out [`CidVerifier`] that consults
11//! each registered inner verifier in order and returns the first
12//! success. Wiring is at the [`crate::wac::issuer`] layer: an
13//! `acl:IssuerCondition` with `acl:issuer <cid:Verifier>` dispatches
14//! through a dispatcher the consumer builds from a `CidVerifier`.
15//!
16//! Reference: W3C Controlled Identifier Document 1.0
17//! (<https://www.w3.org/TR/cid/>). WAC 2.0 profile:
18//! <https://webacl.org/secure-access-conditions/>.
19
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use thiserror::Error;
24
25/// Proof envelope passed to every [`SelfSignedVerifier`] implementation.
26///
27/// The fields are borrowed so callers do not allocate on the hot path.
28/// Concrete verifiers interpret `proof` according to their own wire
29/// format (JWT compact serialisation, base64 Nostr event, etc.).
30#[derive(Debug, Clone, Copy)]
31pub struct ProofEnvelope<'a> {
32    /// Wire-format proof string (JWT / NIP-98 event / …).
33    pub proof: &'a str,
34
35    /// Canonical HTTP method in upper-case (`GET`, `POST`, …). Matches
36    /// the DPoP `htm` / NIP-98 `method` tag.
37    pub method: &'a str,
38
39    /// Absolute request URI. Matches the DPoP `htu` / NIP-98 `u` tag.
40    pub uri: &'a str,
41
42    /// Caller's current wall-clock time in seconds since the Unix epoch.
43    /// Passed explicitly for deterministic tests; production callers use
44    /// `SystemTime::now()`.
45    pub now_unix: u64,
46
47    /// Optional subject hint — for example, the WebID supplied in a
48    /// request's `Authorization` metadata. A verifier MAY use it to
49    /// short-circuit matching but MUST NOT accept a proof whose actual
50    /// verification output disagrees with the hint.
51    pub expected_subject_hint: Option<&'a str>,
52}
53
54/// Output of a successful [`SelfSignedVerifier::verify`] call.
55///
56/// `did` is the canonical subject IRI — `did:key:z…`,
57/// `did:nostr:<hex>`, `urn:nip98:<pubkey>`, or any other resolvable
58/// controller identifier. `verification_method` is the specific key /
59/// relationship that actually produced the signature; under the CID
60/// model this is what the policy layer pins.
61#[derive(Debug, Clone)]
62pub struct VerifiedSubject {
63    /// Canonical subject DID.
64    pub did: String,
65
66    /// Verification method identifier (often `did#keys-0` or a JWK `kid`).
67    pub verification_method: String,
68}
69
70/// Errors returned by any [`SelfSignedVerifier`].
71#[derive(Debug, Error)]
72pub enum SelfSignedError {
73    /// Proof envelope is syntactically malformed (invalid base64,
74    /// unparseable JSON, wrong segment count for JWT, …).
75    #[error("malformed proof: {0}")]
76    Malformed(String),
77
78    /// Proof's embedded method/URI does not match the request.
79    #[error("proof scope mismatch: {0}")]
80    ScopeMismatch(String),
81
82    /// Proof signature did not verify against the advertised key.
83    #[error("signature invalid: {0}")]
84    InvalidSignature(String),
85
86    /// Proof's timestamp is outside the acceptance window.
87    #[error("proof timestamp out of range: {0}")]
88    OutOfTimeWindow(String),
89
90    /// No registered verifier recognised this proof format.
91    #[error("no verifier matched the proof format")]
92    UnrecognisedFormat,
93
94    /// Bubbled-up implementation-specific failure.
95    #[error("verifier: {0}")]
96    Other(String),
97}
98
99/// Verifier for a single self-signed proof format.
100///
101/// Implementations MUST be inexpensive to clone behind an `Arc` and MUST
102/// be `Send + Sync` so they can live inside a request-scoped dispatcher.
103#[async_trait]
104pub trait SelfSignedVerifier: Send + Sync {
105    /// Attempt to verify the proof. Returns `Ok(Some(subject))` on a
106    /// successful verification, `Ok(None)` if the proof does not match
107    /// this verifier's format (allows the fan-out dispatcher to try the
108    /// next one), or `Err(…)` when the format matches but verification
109    /// fails — in which case the fan-out stops.
110    async fn verify(
111        &self,
112        envelope: &ProofEnvelope<'_>,
113    ) -> Result<Option<VerifiedSubject>, SelfSignedError>;
114
115    /// Short name for diagnostics / metrics (`"did:key"`, `"nip98"`, …).
116    fn name(&self) -> &'static str;
117}
118
119/// Fan-out dispatcher — tries each inner verifier in order. The first
120/// one returning `Ok(Some(_))` wins. Any `Err(_)` short-circuits with
121/// that error so a broken-but-matching proof surfaces a precise
122/// diagnostic rather than being masked as `UnrecognisedFormat`.
123pub struct CidVerifier {
124    inner: Vec<Arc<dyn SelfSignedVerifier>>,
125}
126
127impl CidVerifier {
128    /// Build an empty dispatcher; use [`CidVerifier::with`] to register
129    /// verifiers.
130    pub fn new() -> Self {
131        Self { inner: Vec::new() }
132    }
133
134    /// Register another inner verifier. Verifiers are tried in the
135    /// order they are added.
136    #[must_use]
137    pub fn with(mut self, verifier: Arc<dyn SelfSignedVerifier>) -> Self {
138        self.inner.push(verifier);
139        self
140    }
141
142    /// Number of registered inner verifiers.
143    pub fn len(&self) -> usize {
144        self.inner.len()
145    }
146
147    pub fn is_empty(&self) -> bool {
148        self.inner.is_empty()
149    }
150
151    /// Names of the registered inner verifiers — used by the WAC issuer
152    /// condition layer to echo supported CID methods in 422 responses.
153    pub fn registered(&self) -> Vec<&'static str> {
154        self.inner.iter().map(|v| v.name()).collect()
155    }
156}
157
158impl Default for CidVerifier {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164#[async_trait]
165impl SelfSignedVerifier for CidVerifier {
166    async fn verify(
167        &self,
168        envelope: &ProofEnvelope<'_>,
169    ) -> Result<Option<VerifiedSubject>, SelfSignedError> {
170        if self.inner.is_empty() {
171            return Err(SelfSignedError::UnrecognisedFormat);
172        }
173        for v in &self.inner {
174            match v.verify(envelope).await {
175                Ok(Some(subj)) => return Ok(Some(subj)),
176                Ok(None) => continue,
177                Err(SelfSignedError::UnrecognisedFormat) => continue,
178                Err(e) => {
179                    // A matching-but-broken proof short-circuits.
180                    return Err(e);
181                }
182            }
183        }
184        Err(SelfSignedError::UnrecognisedFormat)
185    }
186
187    fn name(&self) -> &'static str {
188        "cid:Verifier"
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Tests — verifier-trait level only. Format-specific tests live in the
194// integration crate (`tests/cid_verifier_sprint11.rs`) so they can
195// exercise real did:key / NIP-98 fixtures.
196// ---------------------------------------------------------------------------
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    struct EchoVerifier {
203        name: &'static str,
204        want_prefix: &'static str,
205        did: &'static str,
206    }
207
208    #[async_trait]
209    impl SelfSignedVerifier for EchoVerifier {
210        async fn verify(
211            &self,
212            envelope: &ProofEnvelope<'_>,
213        ) -> Result<Option<VerifiedSubject>, SelfSignedError> {
214            if envelope.proof.starts_with(self.want_prefix) {
215                Ok(Some(VerifiedSubject {
216                    did: self.did.to_string(),
217                    verification_method: format!("{}#keys-0", self.did),
218                }))
219            } else {
220                Ok(None)
221            }
222        }
223        fn name(&self) -> &'static str {
224            self.name
225        }
226    }
227
228    struct BrokenVerifier;
229
230    #[async_trait]
231    impl SelfSignedVerifier for BrokenVerifier {
232        async fn verify(
233            &self,
234            envelope: &ProofEnvelope<'_>,
235        ) -> Result<Option<VerifiedSubject>, SelfSignedError> {
236            if envelope.proof.starts_with("broken:") {
237                Err(SelfSignedError::InvalidSignature("stub".into()))
238            } else {
239                Ok(None)
240            }
241        }
242        fn name(&self) -> &'static str {
243            "broken"
244        }
245    }
246
247    fn envelope(proof: &str) -> ProofEnvelope<'_> {
248        ProofEnvelope {
249            proof,
250            method: "GET",
251            uri: "https://pod.example/r",
252            now_unix: 1_700_000_000,
253            expected_subject_hint: None,
254        }
255    }
256
257    #[tokio::test]
258    async fn empty_dispatcher_returns_unrecognised() {
259        let c = CidVerifier::new();
260        let env = envelope("anything");
261        let err = c.verify(&env).await.unwrap_err();
262        assert!(matches!(err, SelfSignedError::UnrecognisedFormat));
263    }
264
265    #[tokio::test]
266    async fn first_matching_wins() {
267        let c = CidVerifier::new()
268            .with(Arc::new(EchoVerifier {
269                name: "a",
270                want_prefix: "a:",
271                did: "did:a:1",
272            }))
273            .with(Arc::new(EchoVerifier {
274                name: "b",
275                want_prefix: "b:",
276                did: "did:b:1",
277            }));
278        let env = envelope("b:hello");
279        let subj = c.verify(&env).await.unwrap().unwrap();
280        assert_eq!(subj.did, "did:b:1");
281    }
282
283    #[tokio::test]
284    async fn broken_matching_verifier_short_circuits() {
285        let c = CidVerifier::new()
286            .with(Arc::new(BrokenVerifier))
287            .with(Arc::new(EchoVerifier {
288                name: "a",
289                want_prefix: "a:",
290                did: "did:a:1",
291            }));
292        let env = envelope("broken:sigbad");
293        let err = c.verify(&env).await.unwrap_err();
294        assert!(matches!(err, SelfSignedError::InvalidSignature(_)));
295    }
296
297    #[tokio::test]
298    async fn no_matching_verifier_returns_unrecognised() {
299        let c = CidVerifier::new().with(Arc::new(EchoVerifier {
300            name: "a",
301            want_prefix: "a:",
302            did: "did:a:1",
303        }));
304        let env = envelope("z:none");
305        let err = c.verify(&env).await.unwrap_err();
306        assert!(matches!(err, SelfSignedError::UnrecognisedFormat));
307    }
308
309    #[test]
310    fn registered_lists_names() {
311        let c = CidVerifier::new()
312            .with(Arc::new(EchoVerifier {
313                name: "first",
314                want_prefix: "f:",
315                did: "did:a",
316            }))
317            .with(Arc::new(BrokenVerifier));
318        assert_eq!(c.registered(), vec!["first", "broken"]);
319        assert_eq!(c.len(), 2);
320    }
321}