Skip to main content

auths_id/
agent_identity.rs

1//! Headless agent identity provisioning API.
2//!
3//! Provides a library-level API for creating AI agent identities without
4//! interactive prompts. Designed for CI/CD pipelines, orchestration systems,
5//! and daemon processes.
6//!
7//! # Storage Modes
8//!
9//! - [`AgentStorageMode::Persistent`]: Disk-based storage (default: `~/.auths-agent`).
10//!   Agent identity survives process restarts.
11//! - [`AgentStorageMode::InMemory`]: Ephemeral storage for stateless containers
12//!   (Fargate, Docker). Agent identity lives only for the process lifetime.
13//!   Explicitly trades persistence for statelessness.
14//!
15//! # Usage
16//!
17//! ```rust,ignore
18//! use auths_id::agent_identity::{provision_agent_identity, AgentProvisioningConfig, AgentStorageMode};
19//!
20//! let config = AgentProvisioningConfig {
21//!     agent_name: "ci-bot".to_string(),
22//!     capabilities: vec!["sign_commit".to_string()],
23//!     expires_in: Some(86400),
24//!     delegated_by: Some(IdentityDID::new_unchecked("did:keri:Eabc123")),
25//!     storage_mode: AgentStorageMode::Persistent { repo_path: None },
26//! };
27//!
28//! let keychain = auths_core::storage::keychain::get_platform_keychain()?;
29//! let bundle = provision_agent_identity(config, &my_passphrase_provider, keychain)?;
30//! println!("Agent DID: {}", bundle.agent_did);
31//! ```
32
33use 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// ── Public Types ────────────────────────────────────────────────────────────
54
55/// Storage mode for agent identity.
56#[derive(Debug, Clone)]
57pub enum AgentStorageMode {
58    /// Persistent storage at a filesystem path.
59    /// Defaults to `~/.auths-agent` if `repo_path` is `None`.
60    Persistent { repo_path: Option<PathBuf> },
61    /// In-memory storage for ephemeral/stateless containers (Fargate, Docker).
62    /// Agent identity lives only for the process lifetime.
63    InMemory,
64}
65
66/// Configuration for provisioning an agent identity.
67#[derive(Debug, Clone)]
68pub struct AgentProvisioningConfig {
69    /// Human-readable agent name (e.g., "ci-bot", "release-agent").
70    pub agent_name: String,
71    /// Capabilities to grant (e.g., `["sign_commit", "pr:create"]`).
72    pub capabilities: Vec<String>,
73    /// Duration in seconds until expiration (per RFC 6749).
74    pub expires_in: Option<u64>,
75    /// DID of the human who authorized this agent.
76    pub delegated_by: Option<IdentityDID>,
77    /// Storage mode (persistent or ephemeral).
78    pub storage_mode: AgentStorageMode,
79}
80
81/// Result of a successful agent provisioning.
82#[derive(Debug, Clone)]
83pub struct AgentIdentityBundle {
84    /// The agent's `did:keri:E...` identity.
85    pub agent_did: IdentityDID,
86    /// The key alias used for signing.
87    pub key_alias: KeyAlias,
88    /// The agent's attestation (with `signer_type: Agent`).
89    pub attestation: Attestation,
90    /// Path to the agent repo (`None` for `InMemory` mode).
91    pub repo_path: Option<PathBuf>,
92}
93
94/// Errors that can occur during agent provisioning.
95#[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
134// ── Public API ──────────────────────────────────────────────────────────────
135
136/// Provision a new agent identity.
137///
138/// Creates a KERI identity, signs an attestation with `signer_type: Agent`,
139/// and optionally writes an `auths-agent.toml` config file.
140///
141/// Args:
142/// * `backend` - The registry backend for KEL storage. Must be pre-initialized.
143/// * `config` - Provisioning configuration (name, capabilities, storage mode).
144/// * `passphrase_provider` - Plugin point for passphrase retrieval.
145/// * `keychain` - Key storage backend.
146///
147/// Usage:
148/// ```ignore
149/// let bundle = provision_agent_identity(Arc::new(my_backend), config, &provider, keychain)?;
150/// ```
151pub 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
191// ── Repo Setup ──────────────────────────────────────────────────────────────
192
193/// Resolve the repo path from storage mode. Returns `(path, is_ephemeral)`.
194fn 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            // Leak the tempdir so it persists for the process lifetime.
205            let tmp = tempfile::tempdir().map_err(AgentProvisioningError::ConfigWrite)?;
206            let path = tmp.path().to_path_buf();
207            // Leak the tempdir so cleanup doesn't run — ephemeral agents persist for process lifetime.
208            std::mem::forget(tmp);
209            Ok((path, true))
210        }
211    }
212}
213
214#[allow(clippy::disallowed_methods)] // INVARIANT: agent repo setup — directory creation before git init
215fn 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)] // INVARIANT: designated home-dir resolution for agent repo default path
226fn 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
243// ── Identity ────────────────────────────────────────────────────────────────
244
245/// Return the existing identity DID or create a new one.
246fn 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        // INVARIANT: visit_identities yields KERI prefixes from the registry, format! produces a valid did:keri string
257        {
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
272// ── Attestation ─────────────────────────────────────────────────────────────
273
274/// Create and sign an attestation with `signer_type: Agent`.
275///
276/// The flow:
277/// 1. Decrypt the key to extract the device public key
278/// 2. Create a base attestation via `create_signed_attestation`
279/// 3. Stamp `signer_type` and `delegated_by`
280/// 4. Re-sign so the canonical data covers the new fields
281fn 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
324/// Decrypt the stored key and return the 32-byte Ed25519 public key.
325fn 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// ── Config File ─────────────────────────────────────────────────────────────
369
370#[allow(clippy::disallowed_methods)] // INVARIANT: agent config file write — one-shot file creation during provisioning
371fn 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// ── Tests ───────────────────────────────────────────────────────────────────
415
416#[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}