1use std::path::{Path, PathBuf};
34
35use chrono::{DateTime, Utc};
36
37use auths_core::crypto::signer::decrypt_keypair;
38use auths_core::signing::{PassphraseProvider, StorageSigner};
39use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage};
40use auths_verifier::core::{Attestation, SignerType};
41use auths_verifier::error::AttestationError;
42use auths_verifier::types::DeviceDID;
43use ring::signature::KeyPair;
44
45use std::sync::Arc;
46
47use crate::attestation::core::resign_attestation;
48use crate::attestation::create::create_signed_attestation;
49use crate::identity::initialize::initialize_registry_identity;
50use crate::storage::git_refs::AttestationMetadata;
51use crate::storage::registry::RegistryBackend;
52
53#[derive(Debug, Clone)]
57pub enum AgentStorageMode {
58 Persistent { repo_path: Option<PathBuf> },
61 InMemory,
64}
65
66#[derive(Debug, Clone)]
68pub struct AgentProvisioningConfig {
69 pub agent_name: String,
71 pub capabilities: Vec<String>,
73 pub expires_in: Option<u64>,
75 pub delegated_by: Option<IdentityDID>,
77 pub storage_mode: AgentStorageMode,
79}
80
81#[derive(Debug, Clone)]
83pub struct AgentIdentityBundle {
84 pub agent_did: IdentityDID,
86 pub key_alias: KeyAlias,
88 pub attestation: Attestation,
90 pub repo_path: Option<PathBuf>,
92}
93
94#[derive(Debug, thiserror::Error)]
96#[non_exhaustive]
97pub enum AgentProvisioningError {
98 #[error("repository creation failed: {0}")]
99 RepoCreation(#[from] git2::Error),
100 #[error("identity creation failed: {0}")]
101 IdentityCreation(#[from] crate::error::InitError),
102 #[error("attestation creation failed: {0}")]
103 AttestationCreation(#[from] AttestationError),
104 #[error("keychain access failed: {0}")]
105 KeychainAccess(String),
106 #[error("config write failed: {0}")]
107 ConfigWrite(#[from] std::io::Error),
108}
109
110impl auths_core::error::AuthsErrorInfo for AgentProvisioningError {
111 fn error_code(&self) -> &'static str {
112 match self {
113 Self::RepoCreation(_) => "AUTHS-E4301",
114 Self::IdentityCreation(_) => "AUTHS-E4302",
115 Self::AttestationCreation(_) => "AUTHS-E4303",
116 Self::KeychainAccess(_) => "AUTHS-E4304",
117 Self::ConfigWrite(_) => "AUTHS-E4305",
118 }
119 }
120
121 fn suggestion(&self) -> Option<&'static str> {
122 match self {
123 Self::RepoCreation(_) => Some("Check that the agent repo path is writable"),
124 Self::IdentityCreation(_) => {
125 Some("Identity creation failed; check keychain and backend")
126 }
127 Self::AttestationCreation(_) => Some("Attestation signing failed; verify key access"),
128 Self::KeychainAccess(_) => Some("Check keychain permissions and passphrase"),
129 Self::ConfigWrite(_) => Some("Check file permissions and disk space"),
130 }
131 }
132}
133
134pub fn provision_agent_identity(
152 now: DateTime<Utc>,
153 backend: Arc<dyn RegistryBackend + Send + Sync>,
154 config: AgentProvisioningConfig,
155 passphrase_provider: &dyn PassphraseProvider,
156 keychain: Box<dyn KeyStorage + Send + Sync>,
157) -> Result<AgentIdentityBundle, AgentProvisioningError> {
158 let (repo_path, ephemeral) = resolve_repo_path(&config.storage_mode)?;
159 ensure_git_repo(&repo_path)?;
160
161 let key_alias = key_alias_for(&config.storage_mode);
162 let agent_did = get_or_create_identity(
163 backend,
164 &key_alias,
165 &config,
166 passphrase_provider,
167 &*keychain,
168 )?;
169
170 let attestation = sign_agent_attestation(
171 now,
172 &agent_did,
173 &key_alias,
174 &config,
175 passphrase_provider,
176 keychain,
177 )?;
178
179 if !ephemeral {
180 write_agent_toml(&repo_path, agent_did.as_str(), key_alias.as_str(), &config)?;
181 }
182
183 Ok(AgentIdentityBundle {
184 agent_did,
185 key_alias,
186 attestation,
187 repo_path: if ephemeral { None } else { Some(repo_path) },
188 })
189}
190
191fn resolve_repo_path(mode: &AgentStorageMode) -> Result<(PathBuf, bool), AgentProvisioningError> {
195 match mode {
196 AgentStorageMode::Persistent { repo_path } => {
197 let path = match repo_path {
198 Some(p) => p.clone(),
199 None => default_agent_repo_path()?,
200 };
201 Ok((path, false))
202 }
203 AgentStorageMode::InMemory => {
204 let tmp = tempfile::tempdir().map_err(AgentProvisioningError::ConfigWrite)?;
206 let path = tmp.path().to_path_buf();
207 std::mem::forget(tmp);
209 Ok((path, true))
210 }
211 }
212}
213
214#[allow(clippy::disallowed_methods)] fn ensure_git_repo(path: &Path) -> Result<(), AgentProvisioningError> {
216 if !path.exists() {
217 std::fs::create_dir_all(path)?;
218 }
219 if git2::Repository::open(path).is_err() {
220 git2::Repository::init(path)?;
221 }
222 Ok(())
223}
224
225#[allow(clippy::disallowed_methods)] fn default_agent_repo_path() -> Result<PathBuf, AgentProvisioningError> {
227 let home = dirs::home_dir().ok_or_else(|| {
228 AgentProvisioningError::ConfigWrite(std::io::Error::new(
229 std::io::ErrorKind::NotFound,
230 "could not determine home directory",
231 ))
232 })?;
233 Ok(home.join(".auths-agent"))
234}
235
236fn key_alias_for(mode: &AgentStorageMode) -> KeyAlias {
237 match mode {
238 AgentStorageMode::Persistent { .. } => KeyAlias::new_unchecked("agent-key"),
239 AgentStorageMode::InMemory => KeyAlias::new_unchecked("agent-key-ephemeral"),
240 }
241}
242
243fn get_or_create_identity(
247 backend: Arc<dyn RegistryBackend + Send + Sync>,
248 key_alias: &KeyAlias,
249 _config: &AgentProvisioningConfig,
250 passphrase_provider: &dyn PassphraseProvider,
251 keychain: &(dyn KeyStorage + Send + Sync),
252) -> Result<IdentityDID, AgentProvisioningError> {
253 let mut existing_did: Option<IdentityDID> = None;
254 let _ = backend.visit_identities(&mut |prefix| {
255 #[allow(clippy::disallowed_methods)]
256 {
258 existing_did = Some(IdentityDID::new_unchecked(format!("did:keri:{}", prefix)));
259 }
260 std::ops::ControlFlow::Break(())
261 });
262 if let Some(did) = existing_did {
263 return Ok(did);
264 }
265
266 let (did, _) =
267 initialize_registry_identity(backend, key_alias, passphrase_provider, keychain, None)?;
268
269 Ok(did)
270}
271
272fn sign_agent_attestation(
282 now: DateTime<Utc>,
283 controller_did: &IdentityDID,
284 key_alias: &KeyAlias,
285 config: &AgentProvisioningConfig,
286 passphrase_provider: &dyn PassphraseProvider,
287 keychain: Box<dyn KeyStorage + Send + Sync>,
288) -> Result<Attestation, AgentProvisioningError> {
289 let device_pk = extract_public_key(key_alias, passphrase_provider, &*keychain)?;
290 let device_did = DeviceDID::from_ed25519(&device_pk);
291 let meta = build_attestation_meta(now, config);
292 let signer = StorageSigner::new(keychain);
293
294 let rid = format!("agent:{}", config.agent_name);
295 let mut att = create_signed_attestation(
296 now,
297 &rid,
298 controller_did,
299 &device_did,
300 &device_pk,
301 None,
302 &meta,
303 &signer,
304 passphrase_provider,
305 Some(key_alias),
306 Some(key_alias),
307 vec![],
308 None,
309 config.delegated_by.clone(),
310 )?;
311
312 att.signer_type = Some(SignerType::Agent);
313 resign_attestation(
314 &mut att,
315 &signer,
316 passphrase_provider,
317 Some(key_alias),
318 key_alias,
319 )?;
320
321 Ok(att)
322}
323
324fn extract_public_key(
326 key_alias: &KeyAlias,
327 passphrase_provider: &dyn PassphraseProvider,
328 keychain: &dyn KeyStorage,
329) -> Result<[u8; 32], AgentProvisioningError> {
330 let (_did, _role, encrypted) = keychain
331 .load_key(key_alias)
332 .map_err(|e| AgentProvisioningError::KeychainAccess(e.to_string()))?;
333
334 let passphrase = passphrase_provider
335 .get_passphrase("agent key passphrase")
336 .map_err(|e| AgentProvisioningError::KeychainAccess(e.to_string()))?;
337
338 let decrypted = decrypt_keypair(&encrypted, &passphrase)
339 .map_err(|e| AgentProvisioningError::KeychainAccess(e.to_string()))?;
340
341 let kp = crate::identity::helpers::load_keypair_from_der_or_seed(&decrypted)
342 .map_err(|e| AgentProvisioningError::KeychainAccess(format!("bad pkcs8: {}", e)))?;
343
344 let pk: [u8; 32] = kp
345 .public_key()
346 .as_ref()
347 .try_into()
348 .map_err(|_| AgentProvisioningError::KeychainAccess("unexpected key length".into()))?;
349
350 Ok(pk)
351}
352
353fn build_attestation_meta(
354 now: DateTime<Utc>,
355 config: &AgentProvisioningConfig,
356) -> AttestationMetadata {
357 let expires_at = config
358 .expires_in
359 .map(|s| now + chrono::Duration::seconds(s as i64));
360
361 AttestationMetadata {
362 note: Some(format!("Agent: {}", config.agent_name)),
363 timestamp: Some(now),
364 expires_at,
365 }
366}
367
368#[allow(clippy::disallowed_methods)] fn write_agent_toml(
372 repo_path: &Path,
373 did: &str,
374 key_alias: &str,
375 config: &AgentProvisioningConfig,
376) -> Result<(), AgentProvisioningError> {
377 let content = format_agent_toml(did, key_alias, config);
378 std::fs::write(repo_path.join("auths-agent.toml"), content)?;
379 Ok(())
380}
381
382pub fn format_agent_toml(did: &str, key_alias: &str, config: &AgentProvisioningConfig) -> String {
383 let caps = config
384 .capabilities
385 .iter()
386 .map(|c| format!("\"{}\"", c))
387 .collect::<Vec<_>>()
388 .join(", ");
389
390 let mut out = format!(
391 "# Auths Agent Configuration\n\
392 # Generated by provision_agent_identity()\n\n\
393 [agent]\n\
394 name = \"{}\"\n\
395 did = \"{}\"\n\
396 key_alias = \"{}\"\n\
397 signer_type = \"Agent\"\n",
398 config.agent_name, did, key_alias,
399 );
400
401 if let Some(ref delegator) = config.delegated_by {
402 out.push_str(&format!("delegated_by = \"{}\"\n", delegator));
403 }
404
405 out.push_str(&format!("\n[capabilities]\ngranted = [{}]\n", caps));
406
407 if let Some(secs) = config.expires_in {
408 out.push_str(&format!("\n[expiry]\nexpires_in = {}\n", secs));
409 }
410
411 out
412}
413
414#[cfg(test)]
417#[allow(clippy::disallowed_methods)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn format_agent_toml_with_all_fields() {
423 let config = AgentProvisioningConfig {
424 agent_name: "ci-bot".to_string(),
425 capabilities: vec!["sign_commit".to_string(), "pr:create".to_string()],
426 expires_in: Some(86400),
427 delegated_by: Some(IdentityDID::new_unchecked("did:keri:Eabc123")),
428 storage_mode: AgentStorageMode::Persistent { repo_path: None },
429 };
430 let toml = format_agent_toml("did:keri:Eagent", "agent-key", &config);
431 assert!(toml.contains("name = \"ci-bot\""));
432 assert!(toml.contains("did = \"did:keri:Eagent\""));
433 assert!(toml.contains("delegated_by = \"did:keri:Eabc123\""));
434 assert!(toml.contains("\"sign_commit\", \"pr:create\""));
435 assert!(toml.contains("expires_in = 86400"));
436 }
437
438 #[test]
439 fn format_agent_toml_minimal() {
440 let config = AgentProvisioningConfig {
441 agent_name: "solo".to_string(),
442 capabilities: vec![],
443 expires_in: None,
444 delegated_by: None,
445 storage_mode: AgentStorageMode::InMemory,
446 };
447 let toml = format_agent_toml("did:keri:E1", "k", &config);
448 assert!(!toml.contains("delegated_by"));
449 assert!(!toml.contains("[expiry]"));
450 }
451
452 #[test]
453 fn key_alias_persistent_vs_ephemeral() {
454 assert_eq!(
455 key_alias_for(&AgentStorageMode::Persistent { repo_path: None }).as_str(),
456 "agent-key"
457 );
458 assert_eq!(
459 key_alias_for(&AgentStorageMode::InMemory).as_str(),
460 "agent-key-ephemeral"
461 );
462 }
463
464 #[test]
465 fn default_repo_path_ends_with_auths_agent() {
466 let path = default_agent_repo_path().unwrap();
467 assert!(path.ends_with(".auths-agent"));
468 }
469}