1use std::{collections::BTreeMap, time::Duration};
2
3use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
4use url::Url;
5
6use crate::{
7 DidDocument, DidVerificationMethod, DiscoveryDocument, DiscoveryEndpoints, DiscoveryHttp,
8 DiscoveryIdentity, DiscoveryOptions, DiscoveryPlatform, DiscoverySigning, IdentityRecord,
9 PlatformError,
10};
11
12const DID_CONTEXT: &str = "https://www.w3.org/ns/did/v1";
13const DID_PLACEHOLDER: &str = "{agent_did_id}";
14const DID_COMPONENT: &AsciiSet = &CONTROLS
15 .add(b' ')
16 .add(b'"')
17 .add(b'#')
18 .add(b'%')
19 .add(b'/')
20 .add(b':')
21 .add(b'<')
22 .add(b'>')
23 .add(b'?')
24 .add(b'[')
25 .add(b'\\')
26 .add(b']')
27 .add(b'^')
28 .add(b'`')
29 .add(b'{')
30 .add(b'|')
31 .add(b'}');
32
33pub fn create_service_scoped_agent_did(
34 host: &str,
35 path_prefix: &str,
36 agent_did_id: &str,
37) -> Result<String, PlatformError> {
38 let url = Url::parse(&format!("https://{host}"))?;
39 if host.is_empty()
40 || agent_did_id.is_empty()
41 || url.host_str().is_none()
42 || url.as_str() != format!("https://{host}/")
43 {
44 return Err(PlatformError::InvalidConfiguration(
45 "AEP Platform DID host or Agent DID identifier is invalid".to_owned(),
46 ));
47 }
48 let mut parts = vec!["did".to_owned(), "web".to_owned(), encode_component(host)];
49 for part in path_prefix.trim_matches('/').split('/') {
50 if !part.is_empty() {
51 parts.push(encode_component(part));
52 }
53 }
54 parts.push(encode_component(agent_did_id));
55 Ok(parts.join(":"))
56}
57
58pub fn create_did_document(
59 identity: &IdentityRecord,
60 method: DidVerificationMethod,
61) -> Result<DidDocument, PlatformError> {
62 validate_identity_record(identity)?;
63 if method.id != identity.key_id
64 || method.controller != identity.agent_did
65 || method.method_type.is_empty()
66 || !method.public_key_jwk.is_object()
67 {
68 return Err(PlatformError::InvalidConfiguration(
69 "AEP Platform DID verification method does not match the managed identity".to_owned(),
70 ));
71 }
72 validate_public_key_jwk(&method.public_key_jwk, identity)?;
73 Ok(DidDocument {
74 context: vec![DID_CONTEXT.to_owned()],
75 assertion_method: vec![method.id.clone()],
76 authentication: vec![method.id.clone()],
77 capability_invocation: vec![method.id.clone()],
78 id: identity.agent_did.clone(),
79 verification_method: vec![method],
80 })
81}
82
83fn validate_public_key_jwk(
87 value: &serde_json::Value,
88 identity: &IdentityRecord,
89) -> Result<(), PlatformError> {
90 const PRIVATE_MEMBERS: [&str; 7] = ["d", "dp", "dq", "k", "p", "q", "qi"];
93 let Some(object) = value.as_object() else {
94 return Err(PlatformError::InvalidConfiguration(
95 "AEP Platform DID verification method does not match the managed identity".to_owned(),
96 ));
97 };
98 if PRIVATE_MEMBERS
99 .iter()
100 .any(|member| object.contains_key(*member))
101 {
102 return Err(PlatformError::InvalidConfiguration(
103 "AEP Platform DID verification method must not publish private key material".to_owned(),
104 ));
105 }
106 if object
107 .get("kid")
108 .is_some_and(|kid| kid.as_str() != Some(identity.key_id.as_str()))
109 || object.get("alg").is_some_and(|algorithm| {
110 algorithm.as_str().is_none_or(|algorithm| {
111 !identity
112 .signing_algorithms
113 .iter()
114 .any(|supported| supported.as_str() == algorithm)
115 })
116 })
117 || object
118 .get("use")
119 .is_some_and(|key_use| key_use.as_str() != Some("sig"))
120 {
121 return Err(PlatformError::InvalidConfiguration(
122 "AEP Platform DID verification method does not match the managed identity".to_owned(),
123 ));
124 }
125 Ok(())
126}
127
128pub(crate) fn create_discovery_document(
129 options: &DiscoveryOptions,
130 did_url_template: &str,
131 hosted_verification: bool,
132 signing_algorithms: Vec<aep_core::SigningAlgorithm>,
133 default_lifetime: Duration,
134) -> Result<DiscoveryDocument, PlatformError> {
135 for (name, path) in [
136 ("endpoint base", options.endpoint_base.as_str()),
137 ("lifecycle", options.lifecycle_endpoint.as_str()),
138 ("list", options.list_endpoint.as_str()),
139 ("provision", options.provision_endpoint.as_str()),
140 ("sign", options.sign_endpoint.as_str()),
141 ] {
142 validate_endpoint_path(name, path)?;
143 }
144 let hosted_verification_endpoint = match (
145 hosted_verification,
146 options.hosted_verification_endpoint.as_deref(),
147 ) {
148 (true, Some(path)) => {
149 validate_endpoint_path("hosted verification", path)?;
150 Some(path.to_owned())
151 }
152 (true, None) => {
153 return Err(PlatformError::InvalidConfiguration(
154 "AEP Platform hosted verification endpoint is required".to_owned(),
155 ));
156 }
157 (false, None) => None,
158 (false, Some(_)) => {
159 return Err(PlatformError::InvalidConfiguration(
160 "AEP Platform hosted verification endpoint requires hosted verification".to_owned(),
161 ));
162 }
163 };
164 if options.platform_name.is_empty() {
165 return Err(PlatformError::InvalidConfiguration(
166 "AEP Platform name is required".to_owned(),
167 ));
168 }
169 if options
170 .platform_did
171 .as_ref()
172 .is_some_and(|did| !is_did(did))
173 {
174 return Err(PlatformError::InvalidConfiguration(
175 "AEP Platform DID must be a DID".to_owned(),
176 ));
177 }
178 render_did_url(did_url_template, "validation")?;
179 Ok(DiscoveryDocument {
180 aep_version: aep_core::VERSION.to_owned(),
181 endpoints: DiscoveryEndpoints {
182 additional: BTreeMap::new(),
183 hosted_verification: hosted_verification_endpoint,
184 lifecycle: options.lifecycle_endpoint.clone(),
185 list: options.list_endpoint.clone(),
186 provision: options.provision_endpoint.clone(),
187 sign: options.sign_endpoint.clone(),
188 },
189 http: DiscoveryHttp {
190 additional: BTreeMap::new(),
191 endpoint_base: options.endpoint_base.clone(),
192 },
193 identity: DiscoveryIdentity {
194 additional: BTreeMap::new(),
195 did_methods: vec!["did:web".to_owned()],
196 did_url_template: did_url_template.to_owned(),
197 },
198 platform: DiscoveryPlatform {
199 additional: BTreeMap::new(),
200 did: options.platform_did.clone(),
201 hosted_verification,
202 name: options.platform_name.clone(),
203 },
204 signing: DiscoverySigning {
205 additional: BTreeMap::new(),
206 algorithms: signing_algorithms,
207 default_lifetime_seconds: default_lifetime.as_secs().to_string(),
208 },
209 additional: BTreeMap::new(),
210 })
211}
212
213pub(crate) fn render_did_url(template: &str, agent_did_id: &str) -> Result<String, PlatformError> {
214 if template.matches(DID_PLACEHOLDER).count() != 1 {
215 return Err(PlatformError::InvalidConfiguration(
216 "AEP Platform DID URL template must contain one {agent_did_id} placeholder".to_owned(),
217 ));
218 }
219 let rendered = template.replace(DID_PLACEHOLDER, &encode_component(agent_did_id));
220 let url = Url::parse(&rendered)?;
221 if url.scheme() != "https"
222 || url.host_str().is_none()
223 || !url.username().is_empty()
224 || url.password().is_some()
225 || url.fragment().is_some()
226 {
227 return Err(PlatformError::InvalidConfiguration(
228 "AEP Platform DID URL template must render an absolute HTTPS URL".to_owned(),
229 ));
230 }
231 Ok(rendered)
232}
233
234pub(crate) fn validate_identity_record(identity: &IdentityRecord) -> Result<(), PlatformError> {
235 if identity.agent_did.is_empty()
236 || identity.agent_did_id.is_empty()
237 || identity.agent_identity_id.is_empty()
238 || identity.did_document_url.is_empty()
239 || identity.key_id.is_empty()
240 || identity.principal.is_empty()
241 || identity.service_did.is_empty()
242 || identity.signing_algorithms.is_empty()
243 {
244 return Err(PlatformError::Store(
245 "AEP Platform identity store received an invalid record".to_owned(),
246 ));
247 }
248 Ok(())
249}
250
251pub(crate) fn is_did(value: &str) -> bool {
252 let Some(rest) = value.strip_prefix("did:") else {
253 return false;
254 };
255 let Some((method, identifier)) = rest.split_once(':') else {
256 return false;
257 };
258 !method.is_empty()
259 && method
260 .bytes()
261 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
262 && !identifier.is_empty()
263 && !identifier.chars().any(char::is_whitespace)
264}
265
266pub(crate) fn is_absolute_https_url(value: &str) -> bool {
267 Url::parse(value).is_ok_and(|url| {
268 url.scheme() == "https"
269 && url.host_str().is_some()
270 && url.username().is_empty()
271 && url.password().is_none()
272 && url.fragment().is_none()
273 })
274}
275
276fn validate_endpoint_path(name: &str, path: &str) -> Result<(), PlatformError> {
277 let parsed = Url::parse(&format!("https://platform.invalid{path}"));
278 if path.is_empty()
279 || !path.starts_with('/')
280 || path.starts_with("//")
281 || parsed.is_err()
282 || path.contains('?')
283 || path.contains('#')
284 {
285 return Err(PlatformError::InvalidConfiguration(format!(
286 "AEP Platform {name} endpoint must be an absolute path"
287 )));
288 }
289 Ok(())
290}
291
292fn encode_component(value: &str) -> String {
293 utf8_percent_encode(value, DID_COMPONENT).to_string()
294}