Skip to main content

aep_platform/
document.rs

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    Ok(DidDocument {
73        context: vec![DID_CONTEXT.to_owned()],
74        assertion_method: vec![method.id.clone()],
75        authentication: vec![method.id.clone()],
76        capability_invocation: vec![method.id.clone()],
77        id: identity.agent_did.clone(),
78        verification_method: vec![method],
79    })
80}
81
82pub(crate) fn create_discovery_document(
83    options: &DiscoveryOptions,
84    did_url_template: &str,
85    hosted_verification: bool,
86    signing_algorithms: Vec<aep_core::SigningAlgorithm>,
87    default_lifetime: Duration,
88) -> Result<DiscoveryDocument, PlatformError> {
89    for (name, path) in [
90        ("endpoint base", options.endpoint_base.as_str()),
91        ("lifecycle", options.lifecycle_endpoint.as_str()),
92        ("list", options.list_endpoint.as_str()),
93        ("provision", options.provision_endpoint.as_str()),
94        ("sign", options.sign_endpoint.as_str()),
95    ] {
96        validate_endpoint_path(name, path)?;
97    }
98    let hosted_verification_endpoint = match (
99        hosted_verification,
100        options.hosted_verification_endpoint.as_deref(),
101    ) {
102        (true, Some(path)) => {
103            validate_endpoint_path("hosted verification", path)?;
104            Some(path.to_owned())
105        }
106        (true, None) => {
107            return Err(PlatformError::InvalidConfiguration(
108                "AEP Platform hosted verification endpoint is required".to_owned(),
109            ));
110        }
111        (false, None) => None,
112        (false, Some(_)) => {
113            return Err(PlatformError::InvalidConfiguration(
114                "AEP Platform hosted verification endpoint requires hosted verification".to_owned(),
115            ));
116        }
117    };
118    if options.platform_name.is_empty() {
119        return Err(PlatformError::InvalidConfiguration(
120            "AEP Platform name is required".to_owned(),
121        ));
122    }
123    if options
124        .platform_did
125        .as_ref()
126        .is_some_and(|did| !is_did(did))
127    {
128        return Err(PlatformError::InvalidConfiguration(
129            "AEP Platform DID must be a DID".to_owned(),
130        ));
131    }
132    render_did_url(did_url_template, "validation")?;
133    Ok(DiscoveryDocument {
134        aep_version: aep_core::VERSION.to_owned(),
135        endpoints: DiscoveryEndpoints {
136            additional: BTreeMap::new(),
137            hosted_verification: hosted_verification_endpoint,
138            lifecycle: options.lifecycle_endpoint.clone(),
139            list: options.list_endpoint.clone(),
140            provision: options.provision_endpoint.clone(),
141            sign: options.sign_endpoint.clone(),
142        },
143        http: DiscoveryHttp {
144            additional: BTreeMap::new(),
145            endpoint_base: options.endpoint_base.clone(),
146        },
147        identity: DiscoveryIdentity {
148            additional: BTreeMap::new(),
149            did_methods: vec!["did:web".to_owned()],
150            did_url_template: did_url_template.to_owned(),
151        },
152        platform: DiscoveryPlatform {
153            additional: BTreeMap::new(),
154            did: options.platform_did.clone(),
155            hosted_verification,
156            name: options.platform_name.clone(),
157        },
158        signing: DiscoverySigning {
159            additional: BTreeMap::new(),
160            algorithms: signing_algorithms,
161            default_lifetime_seconds: default_lifetime.as_secs().to_string(),
162        },
163        additional: BTreeMap::new(),
164    })
165}
166
167pub(crate) fn render_did_url(template: &str, agent_did_id: &str) -> Result<String, PlatformError> {
168    if template.matches(DID_PLACEHOLDER).count() != 1 {
169        return Err(PlatformError::InvalidConfiguration(
170            "AEP Platform DID URL template must contain one {agent_did_id} placeholder".to_owned(),
171        ));
172    }
173    let rendered = template.replace(DID_PLACEHOLDER, &encode_component(agent_did_id));
174    let url = Url::parse(&rendered)?;
175    if url.scheme() != "https"
176        || url.host_str().is_none()
177        || !url.username().is_empty()
178        || url.password().is_some()
179        || url.fragment().is_some()
180    {
181        return Err(PlatformError::InvalidConfiguration(
182            "AEP Platform DID URL template must render an absolute HTTPS URL".to_owned(),
183        ));
184    }
185    Ok(rendered)
186}
187
188pub(crate) fn validate_identity_record(identity: &IdentityRecord) -> Result<(), PlatformError> {
189    if identity.agent_did.is_empty()
190        || identity.agent_did_id.is_empty()
191        || identity.agent_identity_id.is_empty()
192        || identity.did_document_url.is_empty()
193        || identity.key_id.is_empty()
194        || identity.principal.is_empty()
195        || identity.service_did.is_empty()
196        || identity.signing_algorithms.is_empty()
197    {
198        return Err(PlatformError::Store(
199            "AEP Platform identity store received an invalid record".to_owned(),
200        ));
201    }
202    Ok(())
203}
204
205pub(crate) fn is_did(value: &str) -> bool {
206    let Some(rest) = value.strip_prefix("did:") else {
207        return false;
208    };
209    let Some((method, identifier)) = rest.split_once(':') else {
210        return false;
211    };
212    !method.is_empty()
213        && method
214            .bytes()
215            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
216        && !identifier.is_empty()
217        && !identifier.chars().any(char::is_whitespace)
218}
219
220pub(crate) fn is_absolute_https_url(value: &str) -> bool {
221    Url::parse(value).is_ok_and(|url| {
222        url.scheme() == "https"
223            && url.host_str().is_some()
224            && url.username().is_empty()
225            && url.password().is_none()
226            && url.fragment().is_none()
227    })
228}
229
230fn validate_endpoint_path(name: &str, path: &str) -> Result<(), PlatformError> {
231    let parsed = Url::parse(&format!("https://platform.invalid{path}"));
232    if path.is_empty()
233        || !path.starts_with('/')
234        || path.starts_with("//")
235        || parsed.is_err()
236        || path.contains('?')
237        || path.contains('#')
238    {
239        return Err(PlatformError::InvalidConfiguration(format!(
240            "AEP Platform {name} endpoint must be an absolute path"
241        )));
242    }
243    Ok(())
244}
245
246fn encode_component(value: &str) -> String {
247    utf8_percent_encode(value, DID_COMPONENT).to_string()
248}