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}