Skip to main content

idprova_core/aid/
document.rs

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
8/// The DID method name for IDProva identifiers.
9pub const DID_METHOD: &str = "idprova";
10
11/// A parsed IDProva DID identifier: `did:idprova:{domain}:{local_name}`
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
13pub struct AidIdentifier {
14    /// The domain namespace (e.g., "techblaze.com.au").
15    pub domain: String,
16    /// The local agent name (e.g., "kai").
17    pub local_name: String,
18}
19
20impl AidIdentifier {
21    /// Parse a DID string into an AidIdentifier.
22    ///
23    /// Expected format: `did:idprova:{domain}:{local_name}`
24    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        // Validate domain (basic check)
41        if domain.is_empty() || !domain.contains('.') {
42            return Err(IdprovaError::InvalidAid(format!(
43                "invalid domain: {domain}"
44            )));
45        }
46
47        // Validate local name (lowercase alphanumeric + hyphens)
48        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    /// Convert to the full DID string.
62    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/// A verification method entry in the DID document.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct VerificationMethod {
76    /// Fragment identifier (e.g., "#key-ed25519").
77    pub id: String,
78    /// Key type (e.g., "Ed25519VerificationKey2020").
79    #[serde(rename = "type")]
80    pub key_type: String,
81    /// The controller DID.
82    pub controller: String,
83    /// The public key in multibase encoding.
84    #[serde(rename = "publicKeyMultibase")]
85    pub public_key_multibase: String,
86}
87
88/// Agent-specific metadata stored in the DID document service endpoint.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct AgentMetadata {
91    /// Human-readable agent name.
92    pub name: String,
93    /// Optional description.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub description: Option<String>,
96    /// AI model identifier (e.g., "acme-ai/agent-v2").
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub model: Option<String>,
99    /// Runtime environment (e.g., "openclaw/v2.1").
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub runtime: Option<String>,
102    /// BLAKE3 hash of the agent's configuration for attestation.
103    #[serde(rename = "configAttestation", skip_serializing_if = "Option::is_none")]
104    pub config_attestation: Option<String>,
105}
106
107/// A complete IDProva Agent Identity Document (W3C DID Document).
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct AidDocument {
110    /// JSON-LD context.
111    #[serde(rename = "@context")]
112    pub context: Vec<String>,
113
114    /// The DID identifier (e.g., "did:idprova:techblaze.com.au:kai").
115    pub id: String,
116
117    /// The controller DID (the human/entity who controls this agent).
118    pub controller: String,
119
120    /// Verification methods (public keys).
121    #[serde(rename = "verificationMethod")]
122    pub verification_method: Vec<VerificationMethod>,
123
124    /// Authentication method references.
125    pub authentication: Vec<String>,
126
127    /// Agent metadata service.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub service: Option<Vec<AidService>>,
130
131    /// Trust level (L0-L4).
132    #[serde(rename = "trustLevel", skip_serializing_if = "Option::is_none")]
133    pub trust_level: Option<String>,
134
135    /// Document version.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub version: Option<u32>,
138
139    /// Creation timestamp.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub created: Option<DateTime<Utc>>,
142
143    /// Last update timestamp.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub updated: Option<DateTime<Utc>>,
146
147    /// Cryptographic proof (signature by the controller).
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub proof: Option<AidProof>,
150}
151
152/// A service entry in the DID document.
153#[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/// Cryptographic proof for the AID document.
163#[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    /// Validate the structure of the AID document.
176    pub fn validate(&self) -> Result<()> {
177        // Validate the DID identifier
178        AidIdentifier::parse(&self.id)?;
179
180        // Validate controller is a valid DID
181        if !self.controller.starts_with("did:") {
182            return Err(IdprovaError::AidValidation(
183                "controller must be a valid DID".into(),
184            ));
185        }
186
187        // Must have at least one verification method
188        if self.verification_method.is_empty() {
189            return Err(IdprovaError::AidValidation(
190                "at least one verification method required".into(),
191            ));
192        }
193
194        // Authentication must reference existing verification methods
195        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    /// Serialize the document to canonical JSON (for signing).
208    ///
209    /// # Security: fix S4 (non-canonical JSON)
210    ///
211    /// Uses RFC 8785 JSON Canonicalization Scheme (JCS) via `json-canonicalization`
212    /// to produce deterministic output with sorted object keys. This ensures that
213    /// signatures produced on one platform verify correctly on all others.
214    ///
215    /// The proof field is excluded (it contains the signature itself).
216    pub fn to_canonical_json(&self) -> Result<Vec<u8>> {
217        let mut doc = self.clone();
218        doc.proof = None;
219        // Serialize to serde_json::Value first, then apply JCS ordering
220        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    /// S4: to_canonical_json() must produce RFC 8785 JCS output.
296    ///
297    /// The output must have sorted object keys so that the same document
298    /// serialized on any platform produces identical bytes.
299    #[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        // Top-level keys should appear in the canonical output sorted lexicographically
333        // Verify by checking that @context comes before authentication (@ < a in ASCII)
334        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        // Ensure the output is valid JSON
341        assert!(value.is_object());
342    }
343}