Skip to main content

acp_runtime/
identity.rs

1// Copyright 2026 ACP Project
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use chrono::{Duration, Utc};
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use serde_json::{Map, Value, json};
12use uuid::Uuid;
13
14use crate::constants::{ACP_IDENTITY_VERSION, is_supported_trust_profile};
15use crate::crypto;
16use crate::errors::{AcpError, AcpResult};
17use crate::json_support;
18
19const IDENTITY_FILE_NAME: &str = "identity.json";
20const IDENTITY_DOC_FILE_NAME: &str = "identity_document.json";
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct AgentIdentity {
24    #[serde(rename = "agent_id")]
25    pub agent_id: String,
26    #[serde(rename = "signing_private_key")]
27    pub signing_private_key: String,
28    #[serde(rename = "signing_public_key")]
29    pub signing_public_key: String,
30    #[serde(rename = "encryption_private_key")]
31    pub encryption_private_key: String,
32    #[serde(rename = "encryption_public_key")]
33    pub encryption_public_key: String,
34    #[serde(rename = "signing_kid")]
35    pub signing_kid: String,
36    #[serde(rename = "encryption_kid")]
37    pub encryption_kid: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct AgentIdParts {
42    pub name: String,
43    pub domain: Option<String>,
44}
45
46#[derive(Debug, Clone)]
47pub struct IdentityBundle {
48    pub identity: AgentIdentity,
49    pub identity_document: Map<String, Value>,
50}
51
52impl AgentIdentity {
53    pub fn create(agent_id: &str) -> AcpResult<Self> {
54        parse_agent_id(agent_id)?;
55        let (signing_private_key, signing_public_key) = crypto::generate_ed25519_keypair();
56        let (encryption_private_key, encryption_public_key) = crypto::generate_x25519_keypair();
57        Ok(Self {
58            agent_id: agent_id.to_string(),
59            signing_private_key,
60            signing_public_key,
61            encryption_private_key,
62            encryption_public_key,
63            signing_kid: format!(
64                "sig-{}",
65                Uuid::new_v4()
66                    .simple()
67                    .to_string()
68                    .chars()
69                    .take(12)
70                    .collect::<String>()
71            ),
72            encryption_kid: format!(
73                "enc-{}",
74                Uuid::new_v4()
75                    .simple()
76                    .to_string()
77                    .chars()
78                    .take(12)
79                    .collect::<String>()
80            ),
81        })
82    }
83
84    #[allow(clippy::too_many_arguments)]
85    pub fn build_identity_document(
86        &self,
87        direct_endpoint: Option<&str>,
88        relay_hints: &[String],
89        trust_profile: &str,
90        capabilities: Option<&Map<String, Value>>,
91        valid_days: i64,
92        amqp_service: Option<&Map<String, Value>>,
93        mqtt_service: Option<&Map<String, Value>>,
94        http_security_profile: Option<&str>,
95        relay_security_profile: Option<&str>,
96    ) -> AcpResult<Map<String, Value>> {
97        if !is_supported_trust_profile(trust_profile) {
98            return Err(AcpError::Validation(format!(
99                "Unsupported trust profile: {trust_profile}"
100            )));
101        }
102
103        let mut service = Map::new();
104        if let Some(endpoint) = direct_endpoint {
105            service.insert(
106                "direct_endpoint".to_string(),
107                Value::String(endpoint.to_string()),
108            );
109        } else {
110            service.insert("direct_endpoint".to_string(), Value::Null);
111        }
112        service.insert(
113            "relay_hints".to_string(),
114            Value::Array(
115                relay_hints
116                    .iter()
117                    .map(|h| Value::String(h.clone()))
118                    .collect(),
119            ),
120        );
121        if let Some(amqp) = amqp_service {
122            service.insert("amqp".to_string(), Value::Object(amqp.clone()));
123        }
124        if let Some(mqtt) = mqtt_service {
125            service.insert("mqtt".to_string(), Value::Object(mqtt.clone()));
126        }
127        if let (Some(endpoint), Some(profile)) = (direct_endpoint, http_security_profile) {
128            service.insert(
129                "http".to_string(),
130                json!({
131                    "endpoint": endpoint,
132                    "security_profile": profile,
133                }),
134            );
135        }
136        if let (Some(profile), Some(first_relay)) = (relay_security_profile, relay_hints.first()) {
137            service.insert(
138                "relay".to_string(),
139                json!({
140                    "endpoint": first_relay,
141                    "security_profile": profile,
142                }),
143            );
144        }
145
146        let mut document = Map::new();
147        document.insert(
148            "acp_identity_version".to_string(),
149            Value::String(ACP_IDENTITY_VERSION.to_string()),
150        );
151        document.insert("agent_id".to_string(), Value::String(self.agent_id.clone()));
152        document.insert(
153            "created_at".to_string(),
154            Value::String(Utc::now().to_rfc3339()),
155        );
156        document.insert(
157            "valid_until".to_string(),
158            Value::String((Utc::now() + Duration::days(valid_days.max(1))).to_rfc3339()),
159        );
160        document.insert(
161            "trust_profile".to_string(),
162            Value::String(trust_profile.to_string()),
163        );
164        document.insert(
165            "keys".to_string(),
166            json!({
167                "signing": {
168                    "kid": self.signing_kid,
169                    "alg": "Ed25519",
170                    "public_key": self.signing_public_key
171                },
172                "encryption": {
173                    "kid": self.encryption_kid,
174                    "alg": "X25519",
175                    "public_key": self.encryption_public_key
176                }
177            }),
178        );
179        document.insert("service".to_string(), Value::Object(service));
180        document.insert(
181            "capabilities".to_string(),
182            Value::Object(capabilities.cloned().unwrap_or_default()),
183        );
184
185        let unsigned = Value::Object(document.clone());
186        let signature = crypto::sign_bytes(
187            &json_support::canonical_json_bytes(&unsigned)?,
188            &self.signing_private_key,
189        )?;
190        document.insert(
191            "signature".to_string(),
192            json!({
193                "algorithm": "Ed25519",
194                "signed_by": self.signing_kid,
195                "value": signature,
196            }),
197        );
198        Ok(document)
199    }
200}
201
202pub fn verify_identity_document(identity_document: &Map<String, Value>) -> bool {
203    for required in ["agent_id", "keys", "service", "signature", "valid_until"] {
204        if !identity_document.contains_key(required) {
205            return false;
206        }
207    }
208    let Some(profile) = as_string(identity_document.get("trust_profile")) else {
209        return false;
210    };
211    if !is_supported_trust_profile(profile) {
212        return false;
213    }
214    let Some(valid_until) = as_string(identity_document.get("valid_until")) else {
215        return false;
216    };
217    let Ok(valid_until) = chrono::DateTime::parse_from_rfc3339(valid_until) else {
218        return false;
219    };
220    if valid_until <= Utc::now() {
221        return false;
222    }
223
224    let signature_value = identity_document
225        .get("signature")
226        .and_then(Value::as_object)
227        .and_then(|signature| signature.get("value"))
228        .and_then(Value::as_str)
229        .map(str::trim)
230        .filter(|value| !value.is_empty());
231    let signing_public = identity_document
232        .get("keys")
233        .and_then(Value::as_object)
234        .and_then(|keys| keys.get("signing"))
235        .and_then(Value::as_object)
236        .and_then(|signing| signing.get("public_key"))
237        .and_then(Value::as_str)
238        .map(str::trim)
239        .filter(|value| !value.is_empty());
240    let (Some(signature_value), Some(signing_public)) = (signature_value, signing_public) else {
241        return false;
242    };
243
244    let mut unsigned = identity_document.clone();
245    unsigned.remove("signature");
246    let Ok(bytes) = json_support::canonical_json_bytes(&Value::Object(unsigned)) else {
247        return false;
248    };
249    crypto::verify_signature(&bytes, signature_value, signing_public)
250}
251
252pub fn parse_agent_id(agent_id: &str) -> AcpResult<AgentIdParts> {
253    let regex = Regex::new(r"^agent:(?P<name>[^@]+)(?:@(?P<domain>.+))?$")
254        .map_err(|e| AcpError::Validation(format!("Invalid agent ID regex: {e}")))?;
255    let captures = regex
256        .captures(agent_id)
257        .ok_or_else(|| AcpError::Validation(format!("Invalid agent identifier: {agent_id}")))?;
258    let name = captures
259        .name("name")
260        .map(|m| m.as_str().to_string())
261        .ok_or_else(|| AcpError::Validation("agent id missing name".to_string()))?;
262    let domain = captures.name("domain").map(|m| m.as_str().to_string());
263    Ok(AgentIdParts { name, domain })
264}
265
266pub fn sanitize_agent_id(agent_id: &str) -> String {
267    agent_id
268        .chars()
269        .map(|c| {
270            if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
271                c
272            } else {
273                '_'
274            }
275        })
276        .collect()
277}
278
279pub fn identity_path(storage_dir: &Path, agent_id: &str) -> PathBuf {
280    storage_dir.join(sanitize_agent_id(agent_id))
281}
282
283pub fn write_identity(
284    storage_dir: &Path,
285    identity: &AgentIdentity,
286    identity_document: &Map<String, Value>,
287) -> AcpResult<()> {
288    let path = identity_path(storage_dir, &identity.agent_id);
289    fs::create_dir_all(&path)?;
290    let identity_json = json_support::canonical_json_string(&serde_json::to_value(identity)?)?;
291    let doc_json = json_support::canonical_json_string(&Value::Object(identity_document.clone()))?;
292    fs::write(path.join(IDENTITY_FILE_NAME), identity_json)?;
293    fs::write(path.join(IDENTITY_DOC_FILE_NAME), doc_json)?;
294    Ok(())
295}
296
297pub fn read_identity(storage_dir: &Path, agent_id: &str) -> AcpResult<Option<IdentityBundle>> {
298    let path = identity_path(storage_dir, agent_id);
299    let identity_path = path.join(IDENTITY_FILE_NAME);
300    let doc_path = path.join(IDENTITY_DOC_FILE_NAME);
301    if !identity_path.exists() || !doc_path.exists() {
302        return Ok(None);
303    }
304    let identity = json_support::from_json::<AgentIdentity>(&fs::read_to_string(identity_path)?)?;
305    let identity_document = json_support::map_from_json(&fs::read_to_string(doc_path)?)?;
306    Ok(Some(IdentityBundle {
307        identity,
308        identity_document,
309    }))
310}
311
312fn as_string(value: Option<&Value>) -> Option<&str> {
313    value
314        .and_then(Value::as_str)
315        .map(str::trim)
316        .filter(|v| !v.is_empty())
317}