1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json_canonicalizer::to_vec as jcs_to_vec;
4use std::fmt;
5
6use crate::{IdprovaError, Result};
7
8pub const DID_METHOD: &str = "idprova";
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
13pub struct AidIdentifier {
14 pub domain: String,
16 pub local_name: String,
18}
19
20impl AidIdentifier {
21 pub fn parse(did: &str) -> Result<Self> {
25 let parts: Vec<&str> = did.splitn(4, ':').collect();
26 if parts.len() != 4 {
27 return Err(IdprovaError::InvalidAid(format!(
28 "expected did:idprova:{{domain}}:{{name}}, got: {did}"
29 )));
30 }
31 if parts[0] != "did" || parts[1] != DID_METHOD {
32 return Err(IdprovaError::InvalidAid(format!(
33 "expected did:{DID_METHOD}:..., got: {did}"
34 )));
35 }
36
37 let domain = parts[2].to_string();
38 let local_name = parts[3].to_string();
39
40 if domain.is_empty() || !domain.contains('.') {
42 return Err(IdprovaError::InvalidAid(format!(
43 "invalid domain: {domain}"
44 )));
45 }
46
47 if local_name.is_empty()
49 || !local_name
50 .chars()
51 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
52 {
53 return Err(IdprovaError::InvalidAid(format!(
54 "local name must be lowercase alphanumeric with hyphens: {local_name}"
55 )));
56 }
57
58 Ok(Self { domain, local_name })
59 }
60
61 pub fn to_did(&self) -> String {
63 format!("did:{}:{}:{}", DID_METHOD, self.domain, self.local_name)
64 }
65}
66
67impl fmt::Display for AidIdentifier {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 write!(f, "{}", self.to_did())
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct VerificationMethod {
76 pub id: String,
78 #[serde(rename = "type")]
80 pub key_type: String,
81 pub controller: String,
83 #[serde(rename = "publicKeyMultibase")]
85 pub public_key_multibase: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct AgentMetadata {
91 pub name: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub description: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub model: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub runtime: Option<String>,
102 #[serde(rename = "configAttestation", skip_serializing_if = "Option::is_none")]
104 pub config_attestation: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct AidDocument {
110 #[serde(rename = "@context")]
112 pub context: Vec<String>,
113
114 pub id: String,
116
117 pub controller: String,
119
120 #[serde(rename = "verificationMethod")]
122 pub verification_method: Vec<VerificationMethod>,
123
124 pub authentication: Vec<String>,
126
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub service: Option<Vec<AidService>>,
130
131 #[serde(rename = "trustLevel", skip_serializing_if = "Option::is_none")]
133 pub trust_level: Option<String>,
134
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub version: Option<u32>,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub created: Option<DateTime<Utc>>,
142
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub updated: Option<DateTime<Utc>>,
146
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub proof: Option<AidProof>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct AidService {
155 pub id: String,
156 #[serde(rename = "type")]
157 pub service_type: String,
158 #[serde(rename = "serviceEndpoint")]
159 pub service_endpoint: serde_json::Value,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct AidProof {
165 #[serde(rename = "type")]
166 pub proof_type: String,
167 pub created: DateTime<Utc>,
168 #[serde(rename = "verificationMethod")]
169 pub verification_method: String,
170 #[serde(rename = "proofValue")]
171 pub proof_value: String,
172}
173
174impl AidDocument {
175 pub fn validate(&self) -> Result<()> {
177 AidIdentifier::parse(&self.id)?;
179
180 if !self.controller.starts_with("did:") {
182 return Err(IdprovaError::AidValidation(
183 "controller must be a valid DID".into(),
184 ));
185 }
186
187 if self.verification_method.is_empty() {
189 return Err(IdprovaError::AidValidation(
190 "at least one verification method required".into(),
191 ));
192 }
193
194 for auth_ref in &self.authentication {
196 let found = self.verification_method.iter().any(|vm| vm.id == *auth_ref);
197 if !found {
198 return Err(IdprovaError::AidValidation(format!(
199 "authentication reference {auth_ref} not found in verification methods"
200 )));
201 }
202 }
203
204 Ok(())
205 }
206
207 pub fn to_canonical_json(&self) -> Result<Vec<u8>> {
217 let mut doc = self.clone();
218 doc.proof = None;
219 let value = serde_json::to_value(&doc)?;
221 let canonical = jcs_to_vec(&value)?;
222 Ok(canonical)
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn test_parse_valid_did() {
232 let id = AidIdentifier::parse("did:idprova:techblaze.com.au:kai").unwrap();
233 assert_eq!(id.domain, "techblaze.com.au");
234 assert_eq!(id.local_name, "kai");
235 assert_eq!(id.to_did(), "did:idprova:techblaze.com.au:kai");
236 }
237
238 #[test]
239 fn test_parse_invalid_method() {
240 assert!(AidIdentifier::parse("did:other:example.com:agent").is_err());
241 }
242
243 #[test]
244 fn test_parse_invalid_format() {
245 assert!(AidIdentifier::parse("not-a-did").is_err());
246 assert!(AidIdentifier::parse("did:idprova:nodomain").is_err());
247 }
248
249 #[test]
250 fn test_parse_invalid_local_name() {
251 assert!(AidIdentifier::parse("did:idprova:example.com:UPPERCASE").is_err());
252 assert!(AidIdentifier::parse("did:idprova:example.com:has spaces").is_err());
253 }
254
255 #[test]
256 fn test_parse_valid_local_names() {
257 assert!(AidIdentifier::parse("did:idprova:example.com:kai").is_ok());
258 assert!(AidIdentifier::parse("did:idprova:example.com:billing-agent").is_ok());
259 assert!(AidIdentifier::parse("did:idprova:example.com:agent-v2").is_ok());
260 }
261
262 #[test]
263 fn test_display() {
264 let id = AidIdentifier {
265 domain: "example.com".into(),
266 local_name: "kai".into(),
267 };
268 assert_eq!(format!("{id}"), "did:idprova:example.com:kai");
269 }
270
271 fn sample_aid_document() -> AidDocument {
272 AidDocument {
273 context: vec![
274 "https://www.w3.org/ns/did/v1".into(),
275 "https://idprova.dev/ns/v1".into(),
276 ],
277 id: "did:idprova:example.com:kai".into(),
278 controller: "did:idprova:example.com:root".into(),
279 verification_method: vec![VerificationMethod {
280 id: "#key-ed25519".into(),
281 key_type: "Ed25519VerificationKey2020".into(),
282 controller: "did:idprova:example.com:kai".into(),
283 public_key_multibase: "zABCDEF".into(),
284 }],
285 authentication: vec!["#key-ed25519".into()],
286 service: None,
287 trust_level: Some("L2".into()),
288 version: Some(1),
289 created: None,
290 updated: None,
291 proof: None,
292 }
293 }
294
295 #[test]
300 fn test_s4_canonical_json_is_deterministic() {
301 let doc = sample_aid_document();
302 let canonical1 = doc.to_canonical_json().unwrap();
303 let canonical2 = doc.to_canonical_json().unwrap();
304 assert_eq!(
305 canonical1, canonical2,
306 "to_canonical_json() must be deterministic"
307 );
308 }
309
310 #[test]
311 fn test_s4_canonical_json_excludes_proof() {
312 let mut doc = sample_aid_document();
313 doc.proof = Some(AidProof {
314 proof_type: "Ed25519Signature2020".into(),
315 created: chrono::Utc::now(),
316 verification_method: "#key-ed25519".into(),
317 proof_value: "zsig123".into(),
318 });
319
320 let canonical = String::from_utf8(doc.to_canonical_json().unwrap()).unwrap();
321 assert!(
322 !canonical.contains("proof"),
323 "canonical JSON must exclude the proof field: {canonical}"
324 );
325 }
326
327 #[test]
328 fn test_s4_canonical_json_keys_are_sorted() {
329 let doc = sample_aid_document();
330 let canonical = String::from_utf8(doc.to_canonical_json().unwrap()).unwrap();
331 let value: serde_json::Value = serde_json::from_str(&canonical).unwrap();
332 let ctx_pos = canonical.find("\"@context\"").unwrap();
335 let auth_pos = canonical.find("\"authentication\"").unwrap();
336 assert!(
337 ctx_pos < auth_pos,
338 "@context must appear before authentication in JCS output"
339 );
340 assert!(value.is_object());
342 }
343}