Skip to main content

aex_identity/
did_web.rs

1//! `did:web` identity provider.
2//!
3//! Resolves `did:web:<authority>[#<fragment>]` by fetching
4//! `https://<authority>/.well-known/agent-card.json` via the
5//! SSRF-resistant client from [`aex_net::safe_http`] (ADR-0045) and
6//! verifying the response as a JWS (ADR-0025) using
7//! [`aex_jws`].
8//!
9//! # Out of scope here
10//!
11//! Caching, single-flight stampede protection, and ETag-conditional
12//! revalidation belong to the resolver chain (chunk 5). This module
13//! does one fetch per `verify_peer` call; the resolver layer is what
14//! wraps it for production use.
15
16use std::sync::Arc;
17
18use aex_core::{
19    AgentId, Capability, CapabilitySet, Error, IdScheme, IdentityProvider, Result, Signature,
20    SignatureAlgorithm,
21};
22use aex_jws::{Algorithm as JwsAlgorithm, VerifierKey};
23use async_trait::async_trait;
24use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
25use serde::{Deserialize, Serialize};
26use tokio::sync::RwLock;
27
28/// Multicodec prefix for Ed25519 public keys in `did:key` form.
29const ED25519_MULTICODEC_PREFIX: [u8; 2] = [0xed, 0x01];
30
31/// Provider for `did:web` identities.
32///
33/// Holds the agent's own Ed25519 signing key + the `did:web` URI it
34/// claims (set externally because the URI binds to a domain the agent
35/// or its operator controls — not derivable from the key alone).
36///
37/// Peers are resolved on-demand from their `did:web` URI. A small
38/// optional cache keyed by `AgentId` lets test setups pre-seed peer
39/// keys without going through the network.
40pub struct DidWebProvider {
41    agent_id: AgentId,
42    signing_key: SigningKey,
43    peers: Arc<RwLock<std::collections::HashMap<AgentId, VerifyingKey>>>,
44    /// Component identifier passed to [`safe_http`] for user-agent.
45    component_name: String,
46}
47
48impl DidWebProvider {
49    /// Construct a provider that signs on behalf of `agent_id`.
50    ///
51    /// `agent_id` MUST be a `did:web:authority[#fragment]` URI;
52    /// otherwise the constructor errors out.
53    pub fn new(
54        agent_id: AgentId,
55        signing_key: SigningKey,
56        component_name: impl Into<String>,
57    ) -> Result<Self> {
58        if agent_id.scheme() != IdScheme::DidWeb {
59            return Err(Error::InvalidAgentId(format!(
60                "DidWebProvider requires a did:web agent_id, got {}",
61                agent_id.as_str()
62            )));
63        }
64        Ok(Self {
65            agent_id,
66            signing_key,
67            peers: Arc::new(RwLock::new(Default::default())),
68            component_name: component_name.into(),
69        })
70    }
71
72    /// Test-only / advanced: pre-register a peer's Ed25519 verifying
73    /// key so `verify_peer` does not hit the network for that peer.
74    pub async fn register_peer(&self, peer_id: AgentId, pubkey: VerifyingKey) {
75        self.peers.write().await.insert(peer_id, pubkey);
76    }
77
78    /// Build the well-known URL for a `did:web` agent_id.
79    ///
80    /// Per the W3C did:web spec, `did:web:example.com` resolves to
81    /// `https://example.com/.well-known/did.json`. AEX uses the
82    /// `agent-card.json` variant per ADR-0025; both live under
83    /// `/.well-known/`.
84    pub fn well_known_url(agent_id: &AgentId) -> Result<String> {
85        let uri = agent_id.as_did_uri().ok_or_else(|| {
86            Error::InvalidAgentId(format!(
87                "did:web id is not a valid DID URI: {}",
88                agent_id.as_str()
89            ))
90        })?;
91        if uri.method != "web" {
92            return Err(Error::InvalidAgentId(format!(
93                "expected did:web, got did:{}",
94                uri.method
95            )));
96        }
97        // W3C did:web allows `:` in the method-specific-id to encode
98        // path segments (e.g. did:web:example.com:agents:bob →
99        // https://example.com/agents/bob/did.json). For agent-card,
100        // AEX puts the card at the *authority root* (no path), so
101        // we take only the first `:`-segment as the authority.
102        let authority = uri.method_specific_id.split(':').next().unwrap_or("");
103        if authority.is_empty() {
104            return Err(Error::InvalidAgentId("did:web authority is empty".into()));
105        }
106        // Defensive: reject schemes that snuck into the authority
107        // (e.g. did:web:https://...) — they would let an attacker
108        // smuggle a non-https URL through.
109        if authority.contains('/') || authority.contains('?') || authority.contains('#') {
110            return Err(Error::InvalidAgentId(format!(
111                "did:web authority contains URL-reserved chars: {}",
112                authority
113            )));
114        }
115        Ok(format!("https://{}/.well-known/agent-card.json", authority))
116    }
117
118    /// Fetch the agent card for `peer_id`, verify its JWS signature,
119    /// and return the (verifying-key, parsed-payload) pair.
120    pub async fn fetch_and_verify_card(
121        &self,
122        peer_id: &AgentId,
123    ) -> Result<(VerifyingKey, AgentCardPayload)> {
124        let url = Self::well_known_url(peer_id)?;
125        let resp = aex_net::safe_get(&url, &self.component_name)
126            .await
127            .map_err(|e| Error::NotFound(format!("did:web fetch failed for {}: {}", peer_id, e)))?;
128
129        let jws = std::str::from_utf8(&resp.body)
130            .map_err(|e| Error::Crypto(format!("agent card not UTF-8: {}", e)))?;
131
132        // We verify the JWS by trusting the embedded `public_key` —
133        // it's self-attesting at this layer. The trust anchor for
134        // did:web is the DNS+TLS chain establishing the agent's
135        // ownership of the domain; ADR-0026 layers an extra proof
136        // block on top of that.
137        let verified = aex_jws::verify(jws.trim(), |kid| {
138            // Two-pass parse: peek at the payload to extract the
139            // declared public_key, then verify with it.
140            //
141            // RFC 7515 doesn't allow us to peek inside the payload
142            // before verifying, so we do a structural unpack: split
143            // the JWS, base64-decode the payload, parse the
144            // public_key, and return it as the verifier key.
145            // The signature verification will then guarantee the
146            // payload (including public_key) wasn't tampered with —
147            // attacker swapping the public_key forces the
148            // signature to break.
149            let payload_b64 = jws
150                .trim()
151                .split('.')
152                .nth(1)
153                .ok_or(aex_jws::JwsError::InvalidStructure)?;
154            use base64::engine::general_purpose::URL_SAFE_NO_PAD;
155            use base64::Engine;
156            let payload_bytes = URL_SAFE_NO_PAD
157                .decode(payload_b64)
158                .map_err(|e| aex_jws::JwsError::Base64Decode(format!("payload: {}", e)))?;
159            let payload: AgentCardPayload = serde_json::from_slice(&payload_bytes)
160                .map_err(|e| aex_jws::JwsError::InvalidHeader(format!("payload parse: {}", e)))?;
161
162            // Sanity: kid in header matches agent_id in payload.
163            // Reject otherwise — that's the kid-substitution attack.
164            if payload.agent_id != kid {
165                return Err(aex_jws::JwsError::KidAlgMismatch {
166                    kid: kid.into(),
167                    header_alg: "EdDSA".into(),
168                    key_alg: format!("payload claims {}", payload.agent_id),
169                });
170            }
171            let vk = decode_did_key_multibase(&payload.public_key.public_key_multibase)
172                .map_err(|e| aex_jws::JwsError::InvalidHeader(format!("public_key: {}", e)))?;
173            Ok(Some(VerifierKey::Ed25519(vk)))
174        })
175        .map_err(|e| Error::Crypto(format!("agent card JWS verify failed: {}", e)))?;
176
177        if verified.header.alg != JwsAlgorithm::EdDsa {
178            return Err(Error::Crypto(format!(
179                "did:web cards must use EdDSA for now; got {:?}",
180                verified.header.alg
181            )));
182        }
183
184        let payload: AgentCardPayload = serde_json::from_slice(&verified.payload)
185            .map_err(|e| Error::Crypto(format!("agent card payload parse: {}", e)))?;
186        let vk = decode_did_key_multibase(&payload.public_key.public_key_multibase)?;
187
188        Ok((vk, payload))
189    }
190}
191
192#[async_trait]
193impl IdentityProvider for DidWebProvider {
194    fn agent_id(&self) -> &AgentId {
195        &self.agent_id
196    }
197
198    async fn sign(&self, message: &[u8]) -> Result<Signature> {
199        let sig = self.signing_key.sign(message);
200        Ok(Signature {
201            algorithm: SignatureAlgorithm::Ed25519,
202            bytes: sig.to_bytes().to_vec(),
203        })
204    }
205
206    async fn verify_peer(
207        &self,
208        peer_id: &AgentId,
209        message: &[u8],
210        signature: &Signature,
211    ) -> Result<()> {
212        if signature.algorithm != SignatureAlgorithm::Ed25519 {
213            return Err(Error::SignatureFormat(format!(
214                "did:web (Ed25519 keys) requires Ed25519 signature, got {:?}",
215                signature.algorithm
216            )));
217        }
218
219        // Cached?
220        let cached = self.peers.read().await.get(peer_id).copied();
221        let pubkey = match cached {
222            Some(k) => k,
223            None => {
224                let (vk, _payload) = self.fetch_and_verify_card(peer_id).await?;
225                self.peers.write().await.insert(peer_id.clone(), vk);
226                vk
227            }
228        };
229
230        use ed25519_dalek::Verifier;
231        let sig_bytes: [u8; 64] = signature.bytes.as_slice().try_into().map_err(|_| {
232            Error::SignatureFormat(format!(
233                "Ed25519 signature must be 64 bytes, got {}",
234                signature.bytes.len()
235            ))
236        })?;
237        let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
238        pubkey
239            .verify(message, &sig)
240            .map_err(|_| Error::SignatureInvalid)
241    }
242}
243
244/// Parsed JWS payload of a JWS-signed agent card (ADR-0025).
245///
246/// Fields beyond these are tolerated (forward-compat); unknown fields
247/// are dropped by `serde(deny_unknown_fields = false)` (the default).
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct AgentCardPayload {
250    /// Issuer DID (typically the authority, e.g. `did:web:acme.com`).
251    pub iss: String,
252    /// Subject DID — same as `agent_id` for self-signed cards.
253    pub sub: String,
254    /// `iat` claim, Unix seconds.
255    pub iat: i64,
256    /// `exp` claim, Unix seconds.
257    pub exp: i64,
258    /// Full agent_id as advertised by this card.
259    pub agent_id: String,
260    /// Public key declaration (W3C Verifiable Credentials shape).
261    pub public_key: PublicKeyDeclaration,
262    /// Capability bits as wire strings (see [`aex_core::Capability`]).
263    #[serde(default)]
264    pub capabilities: CapabilitySet,
265    /// Endpoint hints (control plane, data planes).
266    #[serde(default)]
267    pub endpoints: Endpoints,
268}
269
270/// Public key block embedded in [`AgentCardPayload`].
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct PublicKeyDeclaration {
273    /// `"Ed25519VerificationKey2020"` for Ed25519 keys.
274    #[serde(rename = "type")]
275    pub key_type: String,
276    /// Multibase-encoded public key (W3C did:key §2.1 form).
277    #[serde(rename = "publicKeyMultibase")]
278    pub public_key_multibase: String,
279}
280
281/// Endpoint hints advertised by an agent card.
282#[derive(Debug, Clone, Default, Serialize, Deserialize)]
283pub struct Endpoints {
284    /// Control plane URL.
285    pub control_plane: Option<String>,
286    /// Zero or more data-plane URLs (for P2P bytes).
287    #[serde(default)]
288    pub data_planes: Vec<String>,
289}
290
291impl AgentCardPayload {
292    /// Convenience: does this card advertise the given capability?
293    pub fn has_capability(&self, cap: Capability) -> bool {
294        self.capabilities.has(cap)
295    }
296}
297
298/// Decode a multibase `z6Mk...` Ed25519 public key into a [`VerifyingKey`].
299///
300/// Shared with [`crate::did_key`] but kept local to avoid a public
301/// dependency between sibling modules — they may evolve different
302/// validation rules.
303fn decode_did_key_multibase(s: &str) -> Result<VerifyingKey> {
304    let after_z = s
305        .strip_prefix('z')
306        .ok_or_else(|| Error::Crypto(format!("multibase must start with 'z', got '{}'", s)))?;
307    let bytes = bs58::decode(after_z)
308        .into_vec()
309        .map_err(|e| Error::Crypto(format!("base58 decode: {}", e)))?;
310    if bytes.len() != ED25519_MULTICODEC_PREFIX.len() + 32 {
311        return Err(Error::Crypto(format!(
312            "multibase length mismatch: {} bytes",
313            bytes.len()
314        )));
315    }
316    if bytes[..2] != ED25519_MULTICODEC_PREFIX {
317        return Err(Error::Crypto(format!(
318            "multicodec prefix mismatch: {:02x?}",
319            &bytes[..2]
320        )));
321    }
322    let pk: [u8; 32] = bytes[2..].try_into().expect("length checked above");
323    VerifyingKey::from_bytes(&pk).map_err(|e| Error::Crypto(format!("Ed25519 key: {}", e)))
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    fn fixed_did_web_provider() -> DidWebProvider {
331        let sk = SigningKey::from_bytes(&[3u8; 32]);
332        let id = AgentId::new("did:web:acme.com#agent-vendite").unwrap();
333        DidWebProvider::new(id, sk, "test").unwrap()
334    }
335
336    #[test]
337    fn well_known_url_simple() {
338        let id = AgentId::new("did:web:acme.com#fatture").unwrap();
339        let url = DidWebProvider::well_known_url(&id).unwrap();
340        assert_eq!(url, "https://acme.com/.well-known/agent-card.json");
341    }
342
343    #[test]
344    fn well_known_url_strips_fragment() {
345        let id = AgentId::new("did:web:studio-rossi.it#clienti").unwrap();
346        let url = DidWebProvider::well_known_url(&id).unwrap();
347        assert!(!url.contains("clienti"));
348        assert_eq!(url, "https://studio-rossi.it/.well-known/agent-card.json");
349    }
350
351    #[test]
352    fn well_known_url_takes_authority_root() {
353        // did:web supports path-style msi (`example.com:agents:bob`)
354        // but AEX puts the card at the authority root.
355        let id = AgentId::new("did:web:example.com:agents:bob").unwrap();
356        let url = DidWebProvider::well_known_url(&id).unwrap();
357        assert_eq!(url, "https://example.com/.well-known/agent-card.json");
358    }
359
360    #[test]
361    fn well_known_url_rejects_non_web() {
362        let id = AgentId::new("did:key:zabc").unwrap();
363        let err = DidWebProvider::well_known_url(&id).unwrap_err();
364        assert!(matches!(err, Error::InvalidAgentId(_)));
365    }
366
367    #[test]
368    fn well_known_url_rejects_authority_with_slash() {
369        // did:web id constructed by hand carrying suspicious chars.
370        // AgentId::new lets this through because slashes are valid;
371        // well_known_url is the guard.
372        let id = AgentId::new("did:web:evil.com/path").unwrap();
373        let err = DidWebProvider::well_known_url(&id).unwrap_err();
374        assert!(matches!(err, Error::InvalidAgentId(_)));
375    }
376
377    #[test]
378    fn constructor_rejects_non_did_web_id() {
379        let sk = SigningKey::from_bytes(&[1u8; 32]);
380        let id = AgentId::new("did:key:zabc").unwrap();
381        match DidWebProvider::new(id, sk, "test") {
382            Err(Error::InvalidAgentId(_)) => {}
383            Err(other) => panic!("wrong error variant: {other:?}"),
384            Ok(_) => panic!("expected rejection of non-did:web agent_id"),
385        }
386    }
387
388    #[test]
389    fn agent_id_returns_did_web() {
390        let p = fixed_did_web_provider();
391        assert_eq!(p.agent_id().scheme(), IdScheme::DidWeb);
392        assert_eq!(p.agent_id().as_str(), "did:web:acme.com#agent-vendite");
393    }
394
395    #[tokio::test]
396    async fn sign_and_verify_self_with_registered_key() {
397        let p = fixed_did_web_provider();
398        let vk = p.signing_key.verifying_key();
399        // Pre-register own key for self-verification (would normally
400        // be done by the resolver chain).
401        p.register_peer(p.agent_id().clone(), vk).await;
402        let sig = p.sign(b"hi").await.unwrap();
403        p.verify_peer(p.agent_id(), b"hi", &sig).await.unwrap();
404    }
405
406    #[tokio::test]
407    async fn rejects_wrong_signature_algorithm() {
408        let p = fixed_did_web_provider();
409        let bogus = Signature {
410            algorithm: SignatureAlgorithm::EcdsaSecp256k1,
411            bytes: vec![0u8; 64],
412        };
413        let err = p.verify_peer(p.agent_id(), b"x", &bogus).await.unwrap_err();
414        assert!(matches!(err, Error::SignatureFormat(_)));
415    }
416
417    #[tokio::test]
418    async fn rejects_tampered_signature() {
419        let p = fixed_did_web_provider();
420        let vk = p.signing_key.verifying_key();
421        p.register_peer(p.agent_id().clone(), vk).await;
422        let mut sig = p.sign(b"x").await.unwrap();
423        sig.bytes[0] ^= 0xff;
424        let err = p.verify_peer(p.agent_id(), b"x", &sig).await.unwrap_err();
425        assert!(matches!(err, Error::SignatureInvalid));
426    }
427
428    #[test]
429    fn agent_card_payload_serde_roundtrip() {
430        let card = AgentCardPayload {
431            iss: "did:web:acme.com".into(),
432            sub: "did:web:acme.com#fatture".into(),
433            iat: 1_716_100_000,
434            exp: 1_716_186_400,
435            agent_id: "did:web:acme.com#fatture".into(),
436            public_key: PublicKeyDeclaration {
437                key_type: "Ed25519VerificationKey2020".into(),
438                public_key_multibase: "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV".into(),
439            },
440            capabilities: CapabilitySet::empty()
441                .with(Capability::WireV2)
442                .with(Capability::JwsAgentCard),
443            endpoints: Endpoints {
444                control_plane: Some("https://acme.com/aex".into()),
445                data_planes: vec!["https://data.acme.com".into()],
446            },
447        };
448        let json = serde_json::to_string(&card).unwrap();
449        let back: AgentCardPayload = serde_json::from_str(&json).unwrap();
450        assert_eq!(card.agent_id, back.agent_id);
451        assert!(back.has_capability(Capability::WireV2));
452        assert!(back.has_capability(Capability::JwsAgentCard));
453        assert!(!back.has_capability(Capability::A2ABridge));
454    }
455
456    #[test]
457    fn decode_did_key_multibase_roundtrip() {
458        let sk = SigningKey::from_bytes(&[5u8; 32]);
459        let vk = sk.verifying_key();
460        let mut buf: Vec<u8> = ED25519_MULTICODEC_PREFIX.to_vec();
461        buf.extend_from_slice(vk.as_bytes());
462        let encoded = format!("z{}", bs58::encode(buf).into_string());
463        let decoded = decode_did_key_multibase(&encoded).unwrap();
464        assert_eq!(decoded.as_bytes(), vk.as_bytes());
465    }
466
467    #[test]
468    fn decode_rejects_missing_z_prefix() {
469        let err = decode_did_key_multibase("ab12cd").unwrap_err();
470        assert!(matches!(err, Error::Crypto(_)));
471    }
472
473    #[test]
474    fn decode_rejects_bad_multicodec() {
475        // 0x12 0x20 = sha2-256 + 32 zero bytes — wrong codec.
476        let mut buf: Vec<u8> = vec![0x12, 0x20];
477        buf.extend_from_slice(&[0u8; 32]);
478        let s = format!("z{}", bs58::encode(buf).into_string());
479        let err = decode_did_key_multibase(&s).unwrap_err();
480        assert!(matches!(err, Error::Crypto(_)));
481    }
482}