use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json_canonicalizer::to_vec as jcs_to_vec;
use std::fmt;
use crate::{IdprovaError, Result};
pub const DID_METHOD: &str = "aid";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct AidIdentifier {
pub domain: String,
pub local_name: String,
}
impl AidIdentifier {
pub fn parse(did: &str) -> Result<Self> {
let parts: Vec<&str> = did.splitn(4, ':').collect();
if parts.len() != 4 {
return Err(IdprovaError::InvalidAid(format!(
"expected did:aid:{{domain}}:{{name}}, got: {did}"
)));
}
if parts[0] != "did" || parts[1] != DID_METHOD {
return Err(IdprovaError::InvalidAid(format!(
"expected did:{DID_METHOD}:..., got: {did}"
)));
}
let domain = parts[2].to_string();
let local_name = parts[3].to_string();
if domain.is_empty() || !domain.contains('.') {
return Err(IdprovaError::InvalidAid(format!(
"invalid domain: {domain}"
)));
}
if local_name.is_empty()
|| !local_name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(IdprovaError::InvalidAid(format!(
"local name must be lowercase alphanumeric with hyphens: {local_name}"
)));
}
Ok(Self { domain, local_name })
}
pub fn to_did(&self) -> String {
format!("did:{}:{}:{}", DID_METHOD, self.domain, self.local_name)
}
}
impl fmt::Display for AidIdentifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_did())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationMethod {
pub id: String,
#[serde(rename = "type")]
pub key_type: String,
pub controller: String,
#[serde(rename = "publicKeyMultibase")]
pub public_key_multibase: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentMetadata {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(rename = "configAttestation", skip_serializing_if = "Option::is_none")]
pub config_attestation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AidDocument {
#[serde(rename = "@context")]
pub context: Vec<String>,
pub id: String,
pub controller: String,
#[serde(rename = "verificationMethod")]
pub verification_method: Vec<VerificationMethod>,
pub authentication: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<Vec<AidService>>,
#[serde(rename = "trustLevel", skip_serializing_if = "Option::is_none")]
pub trust_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proof: Option<AidProof>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AidService {
pub id: String,
#[serde(rename = "type")]
pub service_type: String,
#[serde(rename = "serviceEndpoint")]
pub service_endpoint: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AidProof {
#[serde(rename = "type")]
pub proof_type: String,
pub created: DateTime<Utc>,
#[serde(rename = "verificationMethod")]
pub verification_method: String,
#[serde(rename = "proofValue")]
pub proof_value: String,
}
impl AidDocument {
pub fn validate(&self) -> Result<()> {
AidIdentifier::parse(&self.id)?;
if !self.controller.starts_with("did:") {
return Err(IdprovaError::AidValidation(
"controller must be a valid DID".into(),
));
}
if self.verification_method.is_empty() {
return Err(IdprovaError::AidValidation(
"at least one verification method required".into(),
));
}
for auth_ref in &self.authentication {
let found = self.verification_method.iter().any(|vm| vm.id == *auth_ref);
if !found {
return Err(IdprovaError::AidValidation(format!(
"authentication reference {auth_ref} not found in verification methods"
)));
}
}
Ok(())
}
pub fn to_canonical_json(&self) -> Result<Vec<u8>> {
let mut doc = self.clone();
doc.proof = None;
let value = serde_json::to_value(&doc)?;
let canonical = jcs_to_vec(&value)?;
Ok(canonical)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_did() {
let id = AidIdentifier::parse("did:aid:techblaze.com.au:kai").unwrap();
assert_eq!(id.domain, "techblaze.com.au");
assert_eq!(id.local_name, "kai");
assert_eq!(id.to_did(), "did:aid:techblaze.com.au:kai");
}
#[test]
fn test_parse_invalid_method() {
assert!(AidIdentifier::parse("did:other:example.com:agent").is_err());
}
#[test]
fn test_parse_invalid_format() {
assert!(AidIdentifier::parse("not-a-did").is_err());
assert!(AidIdentifier::parse("did:aid:nodomain").is_err());
}
#[test]
fn test_parse_invalid_local_name() {
assert!(AidIdentifier::parse("did:aid:example.com:UPPERCASE").is_err());
assert!(AidIdentifier::parse("did:aid:example.com:has spaces").is_err());
}
#[test]
fn test_parse_valid_local_names() {
assert!(AidIdentifier::parse("did:aid:example.com:kai").is_ok());
assert!(AidIdentifier::parse("did:aid:example.com:billing-agent").is_ok());
assert!(AidIdentifier::parse("did:aid:example.com:agent-v2").is_ok());
}
#[test]
fn test_display() {
let id = AidIdentifier {
domain: "example.com".into(),
local_name: "kai".into(),
};
assert_eq!(format!("{id}"), "did:aid:example.com:kai");
}
fn sample_aid_document() -> AidDocument {
AidDocument {
context: vec![
"https://www.w3.org/ns/did/v1".into(),
"https://idprova.dev/ns/v1".into(),
],
id: "did:aid:example.com:kai".into(),
controller: "did:aid:example.com:root".into(),
verification_method: vec![VerificationMethod {
id: "#key-ed25519".into(),
key_type: "Ed25519VerificationKey2020".into(),
controller: "did:aid:example.com:kai".into(),
public_key_multibase: "zABCDEF".into(),
}],
authentication: vec!["#key-ed25519".into()],
service: None,
trust_level: Some("L2".into()),
version: Some(1),
created: None,
updated: None,
proof: None,
}
}
#[test]
fn test_s4_canonical_json_is_deterministic() {
let doc = sample_aid_document();
let canonical1 = doc.to_canonical_json().unwrap();
let canonical2 = doc.to_canonical_json().unwrap();
assert_eq!(
canonical1, canonical2,
"to_canonical_json() must be deterministic"
);
}
#[test]
fn test_s4_canonical_json_excludes_proof() {
let mut doc = sample_aid_document();
doc.proof = Some(AidProof {
proof_type: "Ed25519Signature2020".into(),
created: chrono::Utc::now(),
verification_method: "#key-ed25519".into(),
proof_value: "zsig123".into(),
});
let canonical = String::from_utf8(doc.to_canonical_json().unwrap()).unwrap();
assert!(
!canonical.contains("proof"),
"canonical JSON must exclude the proof field: {canonical}"
);
}
#[test]
fn test_s4_canonical_json_keys_are_sorted() {
let doc = sample_aid_document();
let canonical = String::from_utf8(doc.to_canonical_json().unwrap()).unwrap();
let value: serde_json::Value = serde_json::from_str(&canonical).unwrap();
let ctx_pos = canonical.find("\"@context\"").unwrap();
let auth_pos = canonical.find("\"authentication\"").unwrap();
assert!(
ctx_pos < auth_pos,
"@context must appear before authentication in JCS output"
);
assert!(value.is_object());
}
}