diff --git a/Cargo.lock b/Cargo.lock index 7d268a1f..c9c2d103 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,7 @@ name = "atomic-agent" version = "0.17.1" dependencies = [ "anyhow", + "atomic-canonical", "atomic-core", "atomic-identity", "atomic-repository", @@ -258,6 +259,7 @@ dependencies = [ "anyhow", "atomic-config", "blake3", + "bs58", "chrono", "data-encoding", "dirs", @@ -298,6 +300,7 @@ name = "atomic-remote" version = "0.17.1" dependencies = [ "anyhow", + "atomic-canonical", "atomic-core", "atomic-objects", "bytes", diff --git a/atomic-agent/Cargo.toml b/atomic-agent/Cargo.toml index 9ac07fcd..1b634ac4 100644 --- a/atomic-agent/Cargo.toml +++ b/atomic-agent/Cargo.toml @@ -12,6 +12,7 @@ rust-version.workspace = true # Internal crates atomic-core = { workspace = true } atomic-repository = { workspace = true } +atomic-canonical = { workspace = true } atomic-identity = { workspace = true } # Watchman file watching diff --git a/atomic-agent/src/hooks/sherpa.rs b/atomic-agent/src/hooks/sherpa.rs index 4628e8e3..f9deb7aa 100644 --- a/atomic-agent/src/hooks/sherpa.rs +++ b/atomic-agent/src/hooks/sherpa.rs @@ -119,6 +119,20 @@ struct SherpaHookInput { /// Used as the atomic record commit message on `turn-end`. #[serde(default)] intent_title: Option, + + /// Absolute path to the turn's transcript file, when the harness + /// wrote one. + /// + /// Sherpa writes Claude Code-shaped JSONL, which is what + /// `format_for_agent("sherpa")` selects. Without this the turn is + /// recorded with a message and a file list but no account of the + /// assistant's replies or its tool calls — the provenance Claude Code + /// and OpenCode turns both carry. + /// + /// Aliased because the harness has always sent the field as + /// `trace_file`. + #[serde(default, alias = "trace_file")] + transcript_path: Option, } // --------------------------------------------------------------------------- @@ -205,6 +219,14 @@ impl AgentHook for SherpaHook { let mut event = TurnEvent::new(parsed.session_id.clone(), hook_type).with_raw_json(raw); + // Same as Claude Code: whichever hook carries the transcript sets + // it, and the session keeps the first one it sees. + if let Some(path) = &parsed.transcript_path { + if !path.is_empty() { + event = event.with_transcript_path(path.clone()); + } + } + // Stamp model and provider so the orchestrator can persist them on // the AgentSession — same pattern as OpenCode. if let Some(model) = &parsed.model { @@ -342,6 +364,42 @@ mod tests { assert_eq!(make_hook().display_name(), "Sherpa"); } + // --- transcript_path --- + + #[test] + fn test_turn_end_carries_transcript_path() { + let input = br#"{"session_id":"s-1","turn_number":2,"transcript_path":"/w/.atomic/sessions/s-1/turn-1.jsonl"}"#; + let event = make_hook() + .parse_event(HookType::TurnEnd, input) + .expect("parse"); + assert_eq!( + event.transcript_path.as_deref(), + Some(std::path::Path::new("/w/.atomic/sessions/s-1/turn-1.jsonl")), + ); + } + + #[test] + fn test_trace_file_is_accepted_as_an_alias() { + // The harness has always named the field `trace_file`. + let input = br#"{"session_id":"s-1","trace_file":"/w/turn-1.jsonl"}"#; + let event = make_hook() + .parse_event(HookType::TurnEnd, input) + .expect("parse"); + assert_eq!( + event.transcript_path.as_deref(), + Some(std::path::Path::new("/w/turn-1.jsonl")), + ); + } + + #[test] + fn test_transcript_path_absent_stays_none() { + let input = br#"{"session_id":"s-1"}"#; + let event = make_hook() + .parse_event(HookType::TurnEnd, input) + .expect("parse"); + assert!(event.transcript_path.is_none()); + } + // --- supported_hooks --- #[test] diff --git a/atomic-agent/src/identity.rs b/atomic-agent/src/identity.rs index b530e14a..0367c60c 100644 --- a/atomic-agent/src/identity.rs +++ b/atomic-agent/src/identity.rs @@ -23,9 +23,20 @@ //! //! # Identity Resolution Order //! -//! 1. Look for the user's default identity in `~/.atomic/identities/` -//! 2. If found, derive the agent author with `+tag` email -//! 3. If not found, fall back to `{agent_display_name} ` +//! 1. A **delegated agent identity** (from [`AgentAuthorOptions::agent_identity`] +//! or `ATOMIC_AGENT_IDENTITY`) — the agent has a keypair of its own, so the +//! change is attributed to *its* public key and the human is recoverable +//! through the delegation certificate. +//! 2. The user's default identity in `~/.atomic/identities/`, with a `+tag` +//! author — attribution by naming convention, signed with the human's key. +//! 3. Neither: fall back to `{agent_display_name}` with no key at all. +//! +//! Only (1) is cryptographic. In (2) the author string says "an agent did +//! this" and the key says "the human did this"; anyone who can read the +//! repository can see the difference, but nothing *proves* which agent, or +//! that the human authorized it. That is the gap a delegated identity closes, +//! and why the author line looks the same either way: the visible format is +//! not what changed, the key behind it is. //! //! # Example //! @@ -37,6 +48,7 @@ //! agent_display_name: "Claude Code", //! session_id: "60f5cbd2-aa23-40ee-9085-4375dd186ce7", //! identity_dir: None, // use default ~/.atomic/identities/ +//! agent_identity: None, // or Some("alice+claude") to sign with the agent's own key //! }; //! //! let author = resolve_agent_author(&options); @@ -66,6 +78,14 @@ pub struct AgentAuthorOptions<'a> { /// /// If `None`, uses `~/.atomic/identities/`. Set this for testing. pub identity_dir: Option, + + /// Name of a delegated agent identity to sign as. + /// + /// When set and resolvable, the change is attributed to the agent's own + /// key instead of the human's. `None` falls back to `ATOMIC_AGENT_IDENTITY` + /// and then to the plus-tag path, so an environment with no agent + /// configured behaves exactly as it did before agent identities existed. + pub agent_identity: Option, } // Author Resolution @@ -99,13 +119,104 @@ pub struct AgentAuthorOptions<'a> { /// Author { name: "Claude Code", email: None } /// ``` pub fn resolve_agent_author(options: &AgentAuthorOptions<'_>) -> Author { - // Try to load the user's default identity + // A delegated identity is the only path where the key in the change + // header actually belongs to the agent, so it is tried first. + if let Some(author) = delegated_agent_author(options) { + return author; + } + + // Otherwise: plus-tag the human's identity. Legible, not provable. match load_default_user_identity(options.identity_dir.as_deref()) { Some(user) => derive_agent_author(&user, options), None => fallback_agent_author(options), } } +/// The delegation certificate currently authorizing this agent, as a URN. +/// +/// Recorded on the change envelope so a reader months later can ask the server +/// whether that specific certificate was still good, rather than inferring +/// authority from an author string. Returns `None` when no agent identity is +/// configured or none of its certificates is currently in force — the plus-tag +/// path has no certificate to name. +pub fn active_delegation_urn( + agent_identity: Option<&str>, + identity_dir: Option<&Path>, +) -> Option { + let name = agent_identity + .map(str::to_string) + .or_else(|| std::env::var(AGENT_IDENTITY_ENV).ok()) + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty())?; + + let store = match identity_dir { + Some(dir) => atomic_identity::IdentityStore::open(dir), + None => atomic_identity::IdentityStore::open_default(), + } + .ok()?; + + let identity = store.load_by_name(&name).ok()?; + atomic_canonical::delegation::active_for_delegate(&store, &identity) + .map(|d| d.delegation.id.to_urn()) +} + +/// Environment variable naming the delegated agent identity to sign as. +/// +/// Mirrors the CLI's `ATOMIC_AGENT_IDENTITY`, so a runner that sets it once +/// gets both authenticated pushes and correctly attributed changes. +pub const AGENT_IDENTITY_ENV: &str = "ATOMIC_AGENT_IDENTITY"; + +/// Build an author from a delegated agent identity, if one is configured and +/// resolvable. +/// +/// Returns `None` — rather than failing — whenever the identity is missing or +/// unreadable. Recording a turn must not break because an agent identity was +/// mistyped; falling back to the plus-tag author keeps the work attributed to +/// *someone* and leaves a debug log explaining why it is not keyed. +fn delegated_agent_author(options: &AgentAuthorOptions<'_>) -> Option { + let name = options + .agent_identity + .clone() + .or_else(|| std::env::var(AGENT_IDENTITY_ENV).ok()) + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty())?; + + let store = match options.identity_dir.as_deref() { + Some(dir) => atomic_identity::IdentityStore::open(dir), + None => atomic_identity::IdentityStore::open_default(), + } + .ok()?; + + let identity = match store.load_by_name(&name) { + Ok(identity) => identity, + Err(e) => { + log::debug!("Agent identity '{name}' not usable ({e}); falling back to plus-tag"); + return None; + } + }; + + // A human identity here would silently sign agent work as the human with + // no delegation behind it — worse than the plus-tag fallback, which at + // least does not claim to be keyed to an agent. + if !identity.identity_type.is_delegated() && !identity.identity_type.is_agent() { + log::debug!("'{name}' is not an agent identity; falling back to plus-tag"); + return None; + } + + let session_short = extract_session_short(options.session_id); + let tag = format!( + "{}+{}", + normalize_agent_name(options.agent_name), + session_short + ); + + Some(Author::with_identity( + &tag, + identity.email.clone(), + identity.public_key_base32(), + )) +} + /// Derive the agent author from the user's identity. /// /// Constructs: `{agent_name}+{session_short} <{user_email}>` @@ -397,6 +508,7 @@ pub fn build_agent_author(agent_name: &str, agent_display_name: &str, session_id agent_display_name, session_id, identity_dir: None, + agent_identity: None, }; resolve_agent_author(&options) } @@ -504,6 +616,7 @@ mod tests { agent_display_name: "Claude Code", session_id: "60f5cbd2-aa23-40ee-9085-4375dd186ce7", identity_dir: None, + agent_identity: None, }; let author = derive_agent_author(&user, &options); @@ -525,6 +638,7 @@ mod tests { agent_display_name: "Gemini CLI", session_id: "abcdef1234", identity_dir: None, + agent_identity: None, }; let author = derive_agent_author(&user, &options); @@ -546,6 +660,7 @@ mod tests { agent_display_name: "Claude Code", session_id: "2026-01-15-abc123de-f456-7890", identity_dir: None, + agent_identity: None, }; let author = derive_agent_author(&user, &options); @@ -564,6 +679,7 @@ mod tests { agent_display_name: "Claude Code", session_id: "sess-123", identity_dir: None, + agent_identity: None, }; let author = fallback_agent_author(&options); @@ -573,6 +689,139 @@ mod tests { assert!(author.identity.is_none()); } + // Delegated agent identity (keyed attribution) + + /// The whole point of a delegated identity: the key in the change header + /// is the agent's, not the human's. Same visible author, different key. + #[test] + fn a_delegated_identity_signs_with_its_own_key() { + use atomic_identity::{Identity, IdentityStore, IdentityType, KeyPair}; + + let dir = TempDir::new().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + + let human_key = KeyPair::generate(); + let human = Identity::builder("alice") + .email("alice@example.com") + .public_key(human_key.public.clone()) + .build() + .unwrap(); + store.save_with_keypair(&human, &human_key, None).unwrap(); + + let agent_key = KeyPair::generate(); + let agent = Identity::builder("alice+claude") + .identity_type(IdentityType::Agent) + .email("alice+claude@example.com") + .public_key(agent_key.public.clone()) + .delegated_by(human.id) + .build() + .unwrap(); + store.save_with_keypair(&agent, &agent_key, None).unwrap(); + + let author = resolve_agent_author(&AgentAuthorOptions { + agent_name: "claude-code", + agent_display_name: "Claude Code", + session_id: "60f5cbd2-aa23-40ee-9085-4375dd186ce7", + identity_dir: Some(dir.path().to_path_buf()), + agent_identity: Some("alice+claude".to_string()), + }); + + // The author line is unchanged — legibility was never the problem. + assert_eq!(author.name, "claude+60f5"); + // The key is the agent's, which is what changed. + assert_eq!( + author.identity.as_deref(), + Some(agent.public_key_base32().as_str()) + ); + assert_ne!( + author.identity.as_deref(), + Some(human.public_key_base32().as_str()) + ); + } + + /// A human identity passed as the agent identity must not be used: signing + /// agent work with the human's key and *calling* it keyed attribution is + /// worse than the honest plus-tag fallback. + #[test] + fn a_non_agent_identity_is_refused_and_falls_back() { + use atomic_identity::{Identity, IdentityStore, KeyPair}; + + let dir = TempDir::new().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + let key = KeyPair::generate(); + let human = Identity::builder("alice") + .email("alice@example.com") + .public_key(key.public.clone()) + .build() + .unwrap(); + store.save_with_keypair(&human, &key, None).unwrap(); + + let options = AgentAuthorOptions { + agent_name: "claude-code", + agent_display_name: "Claude Code", + session_id: "sess1234", + identity_dir: Some(dir.path().to_path_buf()), + agent_identity: Some("alice".to_string()), + }; + assert!(delegated_agent_author(&options).is_none()); + } + + /// A mistyped agent identity must not break recording. + #[test] + fn an_unknown_agent_identity_falls_back_rather_than_failing() { + let dir = TempDir::new().unwrap(); + let options = AgentAuthorOptions { + agent_name: "claude-code", + agent_display_name: "Claude Code", + session_id: "sess1234", + identity_dir: Some(dir.path().to_path_buf()), + agent_identity: Some("nobody+here".to_string()), + }; + assert!(delegated_agent_author(&options).is_none()); + + // And the public entry point still produces a usable author. + let author = resolve_agent_author(&options); + assert_eq!(author.name, "Claude Code"); + } + + /// No agent identity configured: unchanged behavior. + #[test] + fn no_agent_identity_means_no_keyed_path() { + let dir = TempDir::new().unwrap(); + let options = AgentAuthorOptions { + agent_name: "claude-code", + agent_display_name: "Claude Code", + session_id: "sess1234", + identity_dir: Some(dir.path().to_path_buf()), + agent_identity: None, + }; + // Guard against a stray env var in the test environment. + if std::env::var(AGENT_IDENTITY_ENV).is_err() { + assert!(delegated_agent_author(&options).is_none()); + } + } + + /// With no certificate installed there is nothing to name on the envelope. + #[test] + fn active_delegation_urn_is_none_without_a_certificate() { + use atomic_identity::{Identity, IdentityStore, IdentityType, KeyPair}; + + let dir = TempDir::new().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + let key = KeyPair::generate(); + let agent = Identity::builder("alice+claude") + .identity_type(IdentityType::Agent) + .public_key(key.public.clone()) + .build() + .unwrap(); + store.save_with_keypair(&agent, &key, None).unwrap(); + + assert_eq!( + active_delegation_urn(Some("alice+claude"), Some(dir.path())), + None + ); + } + // resolve_agent_author (integration) #[test] @@ -585,6 +834,7 @@ mod tests { agent_display_name: "Claude Code", session_id: "60f5cbd2", identity_dir: Some(nonexistent), + agent_identity: None, }; let author = resolve_agent_author(&options); @@ -603,6 +853,7 @@ mod tests { agent_display_name: "Claude Code", session_id: "60f5cbd2", identity_dir: Some(dir.path().to_path_buf()), + agent_identity: None, }; let author = resolve_agent_author(&options); @@ -639,6 +890,7 @@ identity_type = "user" agent_display_name: "Claude Code", session_id: "60f5cbd2-aa23-40ee-9085-4375dd186ce7", identity_dir: Some(dir.path().to_path_buf()), + agent_identity: None, }; let author = resolve_agent_author(&options); @@ -670,6 +922,7 @@ identity_type = "user" agent_display_name: "Gemini CLI", session_id: "abcdef12", identity_dir: Some(dir.path().to_path_buf()), + agent_identity: None, }; let author = resolve_agent_author(&options); @@ -784,6 +1037,7 @@ version = 1 agent_display_name: "Claude Code", session_id: "60f5cbd2-aa23-40ee-9085-4375dd186ce7", identity_dir: None, + agent_identity: None, }; let author = derive_agent_author(&user, &options); @@ -799,6 +1053,7 @@ version = 1 agent_display_name: "Claude Code", session_id: "sess", identity_dir: None, + agent_identity: None, }); assert_eq!(author.display_short(), "Claude Code"); @@ -825,6 +1080,7 @@ version = 1 agent_display_name: agent, session_id: session, identity_dir: None, + agent_identity: None, }; let author = derive_agent_author(&user, &options); assert_eq!( diff --git a/atomic-agent/src/record/provenance.rs b/atomic-agent/src/record/provenance.rs index a825b666..59590a09 100644 --- a/atomic-agent/src/record/provenance.rs +++ b/atomic-agent/src/record/provenance.rs @@ -240,6 +240,14 @@ pub(crate) fn build_turn_envelope( builder = builder.prompt_hash(hash); } + // Name the certificate that authorized this turn. Absent when the agent + // has no delegated identity — the plus-tag path signs with the human's key + // and there is no certificate to point at, which is precisely the + // difference the field is there to record. + if let Some(urn) = crate::identity::active_delegation_urn(None, None) { + builder = builder.delegation_id(urn); + } + builder.build() } diff --git a/atomic-canonical/src/delegation.rs b/atomic-canonical/src/delegation.rs new file mode 100644 index 00000000..d7fe2f38 --- /dev/null +++ b/atomic-canonical/src/delegation.rs @@ -0,0 +1,951 @@ +//! Agent delegation certificates — minting and verification. +//! +//! A delegation certificate is the artifact that answers "does this agent key +//! belong to that person, and what may it do?". It is a canonical JSON-LD node +//! signed with `eddsa-jcs-2022`, so it goes through the same +//! [`crate::jcs`]/[`crate::proof`] path as every other typed node: one +//! canonicalization, one signature format, no second code path to drift. +//! +//! Three documents live here, all built on the same signing machinery: +//! +//! | Document | Signed by | Proves | +//! |---|---|---| +//! | [`mint`] — `AgentDelegation` | the delegator (human) | the agent belongs to this human, and its bounds | +//! | [`mint_request`] — `AgentDelegationRequest` | the delegate (agent) | the agent possesses the key it is asking about | +//! | [`mint_revocation`] — `DelegationRevocation` | the delegator (human) | the delegation is withdrawn | +//! +//! # Why the certificate carries two DIDs for the agent +//! +//! `did:atomic` is `base32(blake3(pubkey))` — a *fingerprint*, from which the +//! key cannot be recovered. A verifier handed only a certificate would have +//! nothing to check the agent's later signatures against. So the certificate +//! also carries `delegateKey`, the standard `did:key` form, which does encode +//! the key; [`verify`] confirms the two agree. That is what makes offline +//! verification of a clone possible: the human's public key verifies the +//! certificate, and the certificate yields the agent's public key. +//! +//! # What verification does and does not establish +//! +//! [`verify`] establishes that the delegator signed this exact scope for this +//! exact agent key, and that the document has not been altered. It says nothing +//! about revocation (server state) or about what the delegator is themselves +//! allowed to do. A server must check both; effective permission is always the +//! intersection of the delegator's own access and the certificate's scope. +//! +//! # Example +//! +//! ```rust +//! use atomic_canonical::delegation; +//! use atomic_identity::{Identity, IdentityType, KeyPair}; +//! use atomic_identity::delegation::{Delegation, DelegationPermission, DelegationScope}; +//! +//! // The human, holding a keypair. +//! let human_key = KeyPair::generate(); +//! let human = Identity::new("alice", &human_key); +//! +//! // The agent, with a keypair of its own. +//! let agent_key = KeyPair::generate(); +//! let agent = Identity::builder("alice+claude") +//! .identity_type(IdentityType::Agent) +//! .public_key(agent_key.public.clone()) +//! .delegated_by(human.id) +//! .build()?; +//! +//! let scope = DelegationScope::builder() +//! .permission(DelegationPermission::Record) +//! .project("acme/*") +//! .build(); +//! let terms = Delegation::new(&human, &agent, scope); +//! +//! let certificate = delegation::mint(&human, &human_key, &terms); +//! +//! // Anyone holding the human's public key can check it — no server needed. +//! let parsed = delegation::verify(&certificate, &human.public_key)?; +//! assert_eq!(parsed.delegate_name, "alice+claude"); +//! +//! // And it yields the agent's public key, for verifying the agent's own work. +//! let agent_public = delegation::delegate_public_key(&parsed)?; +//! assert_eq!(agent_public, agent.public_key); +//! # Ok::<(), atomic_canonical::CanonicalError>(()) +//! ``` + +use atomic_identity::delegation::{Delegation, DelegationId}; +use atomic_identity::identity::{Identity, IdentityId}; +use atomic_identity::keypair::{KeyPair, PublicKey}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +use crate::error::{CanonicalError, Result}; +use crate::jcs; +use crate::node::CONTEXT_URL; +use crate::proof; + +/// `@type` of a delegation certificate. +pub const TYPE_DELEGATION: &str = "AgentDelegation"; +/// `@type` of an agent's self-signed request to be delegated to. +pub const TYPE_REQUEST: &str = "AgentDelegationRequest"; +/// `@type` of a delegator's revocation of a certificate. +pub const TYPE_REVOCATION: &str = "DelegationRevocation"; + +/// URN prefix for the software-agent label (`urn:atomic:agent:`). +pub const AGENT_URN_PREFIX: &str = "urn:atomic:agent:"; + +// --------------------------------------------------------------------------- +// Certificate +// --------------------------------------------------------------------------- + +/// Mint a signed delegation certificate. +/// +/// The delegator's keypair signs; the returned value is the complete document, +/// including `contentHash` and `proof`. Persist and transmit these exact bytes +/// — re-serializing a parsed struct risks producing different bytes than the +/// proof covers. +pub fn mint(delegator: &Identity, delegator_key: &KeyPair, terms: &Delegation) -> Value { + let mut doc = Map::new(); + doc.insert("@context".into(), json!(CONTEXT_URL)); + doc.insert("@type".into(), json!(TYPE_DELEGATION)); + doc.insert("@id".into(), json!(terms.id.to_urn())); + + doc.insert("delegator".into(), json!(terms.delegator)); + doc.insert("delegatorKey".into(), json!(terms.delegator_key)); + doc.insert("delegatorName".into(), json!(terms.delegator_name)); + doc.insert("delegate".into(), json!(terms.delegate)); + doc.insert("delegateKey".into(), json!(terms.delegate_key)); + doc.insert("delegateName".into(), json!(terms.delegate_name)); + if let Some(agent) = &terms.software_agent { + doc.insert("softwareAgent".into(), json!(agent)); + } + + doc.insert( + "scope".into(), + serde_json::to_value(&terms.scope).expect("scope serialization is infallible"), + ); + doc.insert("issued".into(), json!(rfc3339(terms.issued))); + if let Some(expires) = terms.expires { + doc.insert("expires".into(), json!(rfc3339(expires))); + } + + proof::attest_value(Value::Object(doc), delegator, delegator_key) +} + +/// Verify a certificate against the delegator's public key and return the +/// typed view. +/// +/// Checks, in order: +/// +/// 1. the document is an `AgentDelegation`; +/// 2. content hash recomputes, the proof verifies, and its +/// `verificationMethod` belongs to `delegator_public_key` +/// ([`proof::verify_value`]); +/// 3. the `delegator` DID is that same key — otherwise a certificate signed by +/// one key could name another as the delegator; +/// 4. `delegateKey` and `delegate` describe the same key; +/// 5. `@id` recomputes from the body, so the identifier cannot be swapped for +/// one belonging to a different (perhaps revoked) certificate. +pub fn verify(document: &Value, delegator_public_key: &PublicKey) -> Result { + expect_type(document, TYPE_DELEGATION)?; + proof::verify_value(document, delegator_public_key)?; + + let parsed = parse(document)?; + + // 3. The signer must be the delegator the document names. + let delegator_id = IdentityId::from_did(&parsed.delegator) + .map_err(|e| CanonicalError::Verification(format!("delegator DID is malformed: {e}")))?; + if !delegator_id.matches_public_key(delegator_public_key) { + return Err(CanonicalError::Verification( + "delegator DID does not match the verifying key".into(), + )); + } + + // 3b. The delegator's own two renderings must agree too, so a + // self-contained verifier that starts from `delegatorKey` reaches the + // same conclusion as one that starts from a key it already trusts. + let stated_delegator_key = self::delegator_public_key(&parsed)?; + if &stated_delegator_key != delegator_public_key { + return Err(CanonicalError::Verification( + "delegatorKey does not match the verifying key".into(), + )); + } + + // 4. The two renderings of the delegate's key must agree. Without this a + // certificate could name agent A in `delegate` while handing out agent + // B's key in `delegateKey`. + let delegate_key = delegate_public_key(&parsed)?; + let delegate_id = IdentityId::from_did(&parsed.delegate) + .map_err(|e| CanonicalError::Verification(format!("delegate DID is malformed: {e}")))?; + if !delegate_id.matches_public_key(&delegate_key) { + return Err(CanonicalError::Verification( + "delegateKey does not match the delegate DID".into(), + )); + } + + // 5. The id is a claim like any other; recompute it. + if !parsed.id_matches(&delegator_id, &delegate_id) { + return Err(CanonicalError::Verification( + "delegation @id does not match its delegator, delegate and issue time".into(), + )); + } + + Ok(parsed) +} + +/// Parse a certificate into its typed view **without** verifying the proof. +/// +/// For displaying a document whose trust has not been established, or that is +/// about to be verified by a caller that already holds the key. Never make an +/// authorization decision on the result of this function alone. +pub fn parse(document: &Value) -> Result { + expect_type(document, TYPE_DELEGATION)?; + let obj = as_object(document)?; + + let id_urn = string_field(obj, "@id")?; + let id = DelegationId::from_base32(&id_urn) + .ok_or_else(|| CanonicalError::Proof(format!("malformed delegation @id: {id_urn}")))?; + + let scope = obj + .get("scope") + .ok_or_else(|| CanonicalError::Proof("delegation carries no scope".into()))?; + let scope = serde_json::from_value(scope.clone()) + .map_err(|e| CanonicalError::Proof(format!("malformed delegation scope: {e}")))?; + + Ok(Delegation { + id, + delegator: string_field(obj, "delegator")?, + delegator_key: string_field(obj, "delegatorKey")?, + delegator_name: string_field(obj, "delegatorName")?, + delegate: string_field(obj, "delegate")?, + delegate_key: string_field(obj, "delegateKey")?, + delegate_name: string_field(obj, "delegateName")?, + software_agent: obj + .get("softwareAgent") + .and_then(Value::as_str) + .map(str::to_string), + scope, + issued: timestamp_field(obj, "issued")?, + expires: optional_timestamp_field(obj, "expires")?, + }) +} + +/// Recover the delegate's Ed25519 public key from a parsed certificate. +/// +/// This is the payoff of carrying `did:key` alongside `did:atomic`: given a +/// certificate you trust, you can verify the agent's own signatures. +pub fn delegate_public_key(delegation: &Delegation) -> Result { + PublicKey::from_did_key(&delegation.delegate_key) + .map_err(|e| CanonicalError::Proof(format!("malformed delegateKey: {e}"))) +} + +/// Recover the delegator's Ed25519 public key from a parsed certificate. +pub fn delegator_public_key(delegation: &Delegation) -> Result { + PublicKey::from_did_key(&delegation.delegator_key) + .map_err(|e| CanonicalError::Proof(format!("malformed delegatorKey: {e}"))) +} + +/// Verify a certificate using the delegator key the document itself carries. +/// +/// This checks **integrity**, not trust: it proves the document was signed by +/// whoever holds the key it names and has not been altered since. It cannot +/// tell you that key belongs to the person you think — that is settled by +/// comparing the recovered DID against a key you already trust (the server's +/// registered identity, or a `delegator` DID you pinned). +/// +/// Use it where the trusted key is not to hand: a CI runner holding only the +/// agent's key, or rendering an unfamiliar certificate before deciding what to +/// do with it. Where the delegator's key *is* available, prefer [`verify`]. +pub fn verify_self_contained(document: &Value) -> Result { + let parsed = parse(document)?; + let key = delegator_public_key(&parsed)?; + verify(document, &key) +} + +// --------------------------------------------------------------------------- +// Transport +// --------------------------------------------------------------------------- + +/// The HTTP header a request carries its delegation certificate in. +/// +/// The certificate travels **with the request** rather than being registered +/// in advance. It is signed by a key the server already trusts (the +/// delegator's, from registration), so presenting it is proof enough — the +/// same shape as JOSE's `x5c`, SPIFFE SVIDs, or a macaroon. +/// +/// This is what keeps issuing cheap: extending an agent's time or widening its +/// scope is you signing a new certificate and handing it over, with no server +/// round trip. Only *withdrawal* needs to reach the server, because a +/// credential the holder possesses cannot prove its own revocation. +pub const DELEGATION_HEADER: &str = "Atomic-Delegation"; + +/// Hard cap on an encoded certificate, enforced **before** parsing. +/// +/// The verify path now handles caller-supplied JSON on every request, so the +/// size check has to come first: rejecting a 10MB body after canonicalizing it +/// is not a rejection. A real certificate is ~1–2KB; 16KB leaves generous room +/// for long project lists without letting anything interesting through. +pub const MAX_ENCODED_DELEGATION: usize = 16 * 1024; + +/// Encode a certificate for transport: JCS-canonical bytes, base64url, no pad. +/// +/// Canonical rather than "whatever bytes we happened to store" so the encoding +/// is deterministic — which is what lets a server cache a verified certificate +/// by content hash and recognise the same one next request. +pub fn encode_for_transport(document: &Value) -> String { + let canonical = jcs::canonicalize(document); + data_encoding::BASE64URL_NOPAD.encode(canonical.as_bytes()) +} + +/// Decode a certificate presented in a request header. +/// +/// Checks the size cap first, then base64, then JSON. Does **not** verify — +/// [`verify`] against the delegator's registered key is a separate, mandatory +/// step, and keeping them apart means no call site can accidentally treat a +/// well-formed certificate as a trusted one. +pub fn decode_from_transport(encoded: &str) -> Result { + if encoded.len() > MAX_ENCODED_DELEGATION { + return Err(CanonicalError::Proof(format!( + "delegation is {} bytes, over the {MAX_ENCODED_DELEGATION}-byte limit", + encoded.len() + ))); + } + + let bytes = data_encoding::BASE64URL_NOPAD + .decode(encoded.trim().as_bytes()) + .map_err(|e| CanonicalError::Proof(format!("delegation is not valid base64url: {e}")))?; + + serde_json::from_slice(&bytes) + .map_err(|e| CanonicalError::Proof(format!("delegation is not valid JSON: {e}"))) +} + +/// A stable fingerprint of an encoded certificate, for caching a verified +/// result without re-running the Ed25519 check on every request. +/// +/// Keyed on the encoded bytes, so a cache hit means *this exact certificate* +/// — a tampered one hashes differently and can never collide with a verified +/// entry. Only ever populated after a full verification passes, so the cached +/// value is the output of validation, never a substitute for it. +pub fn transport_fingerprint(encoded: &str) -> String { + data_encoding::BASE32_NOPAD.encode(blake3::hash(encoded.as_bytes()).as_bytes()) +} + +// --------------------------------------------------------------------------- +// Request — proof of possession, for keys the human never holds +// --------------------------------------------------------------------------- + +/// An agent's self-signed request to be delegated to. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DelegationRequest { + /// The requesting agent's `did:atomic`. + pub delegate: String, + /// The requesting agent's `did:key` — the key it is proving possession of. + pub delegate_key: String, + /// The name the agent wants to be known by. + pub delegate_name: String, + /// Which software agent this key belongs to. + pub software_agent: Option, + /// When the request was made. + pub requested: DateTime, +} + +/// Mint an agent's self-signed delegation request. +/// +/// Used when the agent's key is generated somewhere the human never sees — a CI +/// runner, a hosted agent. The self-signature is proof of possession: the human +/// countersigning a request knows the key on the other end is real, and is not +/// tricked into delegating to a key nobody holds. +pub fn mint_request(agent: &Identity, agent_key: &KeyPair, software_agent: Option<&str>) -> Value { + let mut doc = Map::new(); + doc.insert("@context".into(), json!(CONTEXT_URL)); + doc.insert("@type".into(), json!(TYPE_REQUEST)); + doc.insert("delegate".into(), json!(agent.id.to_did())); + doc.insert("delegateKey".into(), json!(agent.public_key.to_did_key())); + doc.insert("delegateName".into(), json!(agent.name)); + if let Some(sa) = software_agent { + doc.insert("softwareAgent".into(), json!(sa)); + } + doc.insert("requested".into(), json!(rfc3339(Utc::now()))); + + proof::attest_value(Value::Object(doc), agent, agent_key) +} + +/// Verify a delegation request's self-signature. +/// +/// The verifying key comes out of the document itself (`delegateKey`), which is +/// exactly what makes this proof of *possession* and nothing more: it shows +/// whoever produced the document holds the private half of the key it names. It +/// carries no authority on its own — the human's countersignature does. +pub fn verify_request(document: &Value) -> Result { + expect_type(document, TYPE_REQUEST)?; + let obj = as_object(document)?; + + let delegate_key_did = string_field(obj, "delegateKey")?; + let public_key = PublicKey::from_did_key(&delegate_key_did) + .map_err(|e| CanonicalError::Proof(format!("malformed delegateKey: {e}")))?; + + proof::verify_value(document, &public_key)?; + + let delegate = string_field(obj, "delegate")?; + let delegate_id = IdentityId::from_did(&delegate) + .map_err(|e| CanonicalError::Verification(format!("delegate DID is malformed: {e}")))?; + if !delegate_id.matches_public_key(&public_key) { + return Err(CanonicalError::Verification( + "delegateKey does not match the delegate DID".into(), + )); + } + + Ok(DelegationRequest { + delegate, + delegate_key: delegate_key_did, + delegate_name: string_field(obj, "delegateName")?, + software_agent: obj + .get("softwareAgent") + .and_then(Value::as_str) + .map(str::to_string), + requested: timestamp_field(obj, "requested")?, + }) +} + +// --------------------------------------------------------------------------- +// Revocation +// --------------------------------------------------------------------------- + +/// A delegator's signed withdrawal of a certificate. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DelegationRevocation { + /// The delegation being revoked, as a URN. + pub delegation: String, + /// The revoking delegator's `did:atomic`. + pub delegator: String, + /// When the revocation was issued. + pub revoked_at: DateTime, + /// Why, if the human said. + pub reason: Option, +} + +/// Mint a signed revocation. +/// +/// Revocation is a signed document rather than a bare API call so that it +/// replicates, audits and verifies like everything else — and so a revocation +/// issued offline is still provable when it reaches a server later. +pub fn mint_revocation( + delegator: &Identity, + delegator_key: &KeyPair, + delegation: &DelegationId, + reason: Option<&str>, +) -> Value { + let mut doc = Map::new(); + doc.insert("@context".into(), json!(CONTEXT_URL)); + doc.insert("@type".into(), json!(TYPE_REVOCATION)); + doc.insert("delegation".into(), json!(delegation.to_urn())); + doc.insert("delegator".into(), json!(delegator.id.to_did())); + doc.insert("revokedAt".into(), json!(rfc3339(Utc::now()))); + if let Some(reason) = reason { + doc.insert("reason".into(), json!(reason)); + } + + proof::attest_value(Value::Object(doc), delegator, delegator_key) +} + +/// Verify a revocation against the delegator's public key. +/// +/// Only the delegator may revoke, so the signature must be theirs and the +/// `delegator` field must name that same key. +pub fn verify_revocation( + document: &Value, + delegator_public_key: &PublicKey, +) -> Result { + expect_type(document, TYPE_REVOCATION)?; + proof::verify_value(document, delegator_public_key)?; + + let obj = as_object(document)?; + let delegator = string_field(obj, "delegator")?; + let delegator_id = IdentityId::from_did(&delegator) + .map_err(|e| CanonicalError::Verification(format!("delegator DID is malformed: {e}")))?; + if !delegator_id.matches_public_key(delegator_public_key) { + return Err(CanonicalError::Verification( + "revocation delegator DID does not match the verifying key".into(), + )); + } + + Ok(DelegationRevocation { + delegation: string_field(obj, "delegation")?, + delegator, + revoked_at: timestamp_field(obj, "revokedAt")?, + reason: obj + .get("reason") + .and_then(Value::as_str) + .map(str::to_string), + }) +} + +// --------------------------------------------------------------------------- +// Store-backed lookup +// --------------------------------------------------------------------------- + +/// A stored certificate, verified, with its local status. +#[derive(Clone, Debug)] +pub struct StoredDelegation { + /// Base32 id — the key it is filed under. + pub id: String, + /// The verified certificate. + pub delegation: Delegation, + /// The document as stored, byte-for-byte what the proof covers. + pub document: Value, + /// Whether a revocation is recorded on this machine. + pub revoked_locally: bool, +} + +impl StoredDelegation { + /// Usable as far as this machine can tell: verified, unexpired, not + /// locally revoked. Says nothing about a revocation issued elsewhere — + /// the server is the authority there. + pub fn is_usable(&self) -> bool { + !self.revoked_locally && !self.delegation.is_expired() + } +} + +/// Every verified certificate in `store` naming `delegate` as its subject, +/// newest first. +/// +/// Certificates that fail to parse or verify are **skipped**, not returned as +/// errors. This is the one place a corrupt or foreign file in the store could +/// otherwise take down every agent operation, and a certificate that does not +/// verify has no authority to convey in any case. Each skip is logged at warn. +/// +/// Verification is self-contained — it uses the delegator key the certificate +/// carries — so this works on a machine that holds only the agent's key. +pub fn load_for_delegate( + store: &atomic_identity::IdentityStore, + delegate: &Identity, +) -> Result> { + let delegate_did = delegate.id.to_did(); + let stored = store + .list_delegations() + .map_err(|e| CanonicalError::Proof(format!("failed to list delegations: {e}")))?; + + let mut out = Vec::new(); + for (id, raw) in stored { + let Ok(value) = serde_json::from_str::(&raw) else { + continue; + }; + // Cheap discriminator before the Ed25519 verify: most certificates in + // a store belong to some other agent. + match parse(&value) { + Ok(parsed) if parsed.delegate == delegate_did => {} + _ => continue, + } + let Ok(delegation) = verify_self_contained(&value) else { + continue; + }; + + out.push(StoredDelegation { + revoked_locally: store.is_revoked_locally(&id), + id, + delegation, + document: value, + }); + } + + // Newest first: when several certificates cover the same ground, the most + // recently issued is the one the human meant. + out.sort_by_key(|d| std::cmp::Reverse(d.delegation.issued)); + Ok(out) +} + +/// The certificate currently in force for `delegate`, if any. +pub fn active_for_delegate( + store: &atomic_identity::IdentityStore, + delegate: &Identity, +) -> Option { + load_for_delegate(store, delegate) + .ok()? + .into_iter() + .find(|d| d.is_usable()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// RFC 3339 with second precision — stable bytes for canonicalization. +fn rfc3339(ts: DateTime) -> String { + ts.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +fn as_object(value: &Value) -> Result<&Map> { + value + .as_object() + .ok_or_else(|| CanonicalError::Proof("document is not a JSON object".into())) +} + +fn expect_type(value: &Value, expected: &str) -> Result<()> { + let actual = as_object(value)?.get("@type").and_then(Value::as_str); + match actual { + Some(t) if t == expected => Ok(()), + Some(t) => Err(CanonicalError::Proof(format!( + "expected a {expected} document, got {t}" + ))), + None => Err(CanonicalError::Proof(format!( + "document has no @type (expected {expected})" + ))), + } +} + +fn string_field(obj: &Map, key: &str) -> Result { + obj.get(key) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| CanonicalError::Proof(format!("document is missing '{key}'"))) +} + +fn timestamp_field(obj: &Map, key: &str) -> Result> { + let raw = string_field(obj, key)?; + DateTime::parse_from_rfc3339(&raw) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(|e| CanonicalError::Proof(format!("'{key}' is not a valid RFC 3339 time: {e}"))) +} + +fn optional_timestamp_field(obj: &Map, key: &str) -> Result>> { + match obj.get(key) { + None | Some(Value::Null) => Ok(None), + Some(_) => timestamp_field(obj, key).map(Some), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use atomic_identity::delegation::{DelegationPermission, DelegationScope}; + use atomic_identity::IdentityType; + use chrono::Duration; + + struct Party { + identity: Identity, + keypair: KeyPair, + } + + fn human(name: &str) -> Party { + let keypair = KeyPair::generate(); + let identity = Identity::new(name, &keypair); + Party { identity, keypair } + } + + fn agent(name: &str, parent: &Identity) -> Party { + let keypair = KeyPair::generate(); + let identity = Identity::builder(name) + .identity_type(IdentityType::Agent) + .public_key(keypair.public.clone()) + .delegated_by(parent.id) + .build() + .unwrap(); + Party { identity, keypair } + } + + fn scope() -> DelegationScope { + DelegationScope::builder() + .permission(DelegationPermission::Read) + .permission(DelegationPermission::Record) + .server("https://atomic.storage") + .project("acme/*") + .build() + } + + fn certificate() -> (Party, Party, Value) { + let alice = human("alice"); + let claude = agent("alice+claude", &alice.identity); + let terms = Delegation::new(&alice.identity, &claude.identity, scope()) + .with_software_agent("urn:atomic:agent:claude-code") + .expires_in(Duration::days(30)); + let doc = mint(&alice.identity, &alice.keypair, &terms); + (alice, claude, doc) + } + + #[test] + fn mint_then_verify_round_trips() { + let (alice, claude, doc) = certificate(); + + let parsed = verify(&doc, &alice.identity.public_key).unwrap(); + assert_eq!(parsed.delegator_name, "alice"); + assert_eq!(parsed.delegate_name, "alice+claude"); + assert_eq!(parsed.delegate, claude.identity.id.to_did()); + assert_eq!( + parsed.software_agent.as_deref(), + Some("urn:atomic:agent:claude-code") + ); + assert!(parsed.scope.has_permission(DelegationPermission::Record)); + assert!(!parsed.scope.has_permission(DelegationPermission::Push)); + assert!(parsed.expires.is_some()); + } + + #[test] + fn verified_certificate_yields_the_agents_public_key() { + let (alice, claude, doc) = certificate(); + let parsed = verify(&doc, &alice.identity.public_key).unwrap(); + + // This is what makes offline attribution work: the human's key verifies + // the certificate, and the certificate hands you the agent's key. + assert_eq!( + delegate_public_key(&parsed).unwrap(), + claude.identity.public_key + ); + } + + #[test] + fn a_different_key_cannot_verify() { + let (_alice, _claude, doc) = certificate(); + let mallory = human("mallory"); + assert!(verify(&doc, &mallory.identity.public_key).is_err()); + } + + #[test] + fn widening_the_scope_breaks_the_proof() { + let (alice, _claude, mut doc) = certificate(); + + // The whole point: an agent that edits its own certificate to add + // `push` must not be able to use it. + doc["scope"]["permissions"] = json!(["read", "record", "push"]); + let err = verify(&doc, &alice.identity.public_key).unwrap_err(); + assert!( + matches!(err, CanonicalError::HashMismatch { .. }), + "expected a hash mismatch, got {err:?}" + ); + } + + #[test] + fn swapping_the_delegate_key_is_rejected() { + let (alice, _claude, mut doc) = certificate(); + let mallory = human("mallory"); + + // Re-sign a document that keeps the honest `delegate` DID but hands out + // Mallory's key. Only reachable by someone holding alice's key, but the + // internal consistency check should catch it regardless. + doc.as_object_mut().unwrap().remove("proof"); + doc.as_object_mut().unwrap().remove("contentHash"); + doc["delegateKey"] = json!(mallory.identity.public_key.to_did_key()); + let doc = proof::attest_value(doc, &alice.identity, &alice.keypair); + + let err = verify(&doc, &alice.identity.public_key).unwrap_err(); + assert!( + matches!(err, CanonicalError::Verification(ref m) if m.contains("delegateKey")), + "expected a delegateKey mismatch, got {err:?}" + ); + } + + #[test] + fn swapping_the_id_is_rejected() { + let (alice, _claude, mut doc) = certificate(); + + // Point the certificate at some other delegation's id, then re-sign. + doc.as_object_mut().unwrap().remove("proof"); + doc.as_object_mut().unwrap().remove("contentHash"); + doc["@id"] = json!(DelegationId::from_bytes([7u8; 32]).to_urn()); + let doc = proof::attest_value(doc, &alice.identity, &alice.keypair); + + let err = verify(&doc, &alice.identity.public_key).unwrap_err(); + assert!( + matches!(err, CanonicalError::Verification(ref m) if m.contains("@id")), + "expected an @id mismatch, got {err:?}" + ); + } + + #[test] + fn transport_round_trips_and_stays_verifiable() { + let (alice, _claude, doc) = certificate(); + + let encoded = encode_for_transport(&doc); + let decoded = decode_from_transport(&encoded).unwrap(); + + // The whole premise: a certificate that travelled over the wire still + // verifies against the delegator's key. + assert!(verify(&decoded, &alice.identity.public_key).is_ok()); + } + + #[test] + fn transport_encoding_is_deterministic() { + // Content-hash caching on the server depends on this: the same + // certificate must encode identically every time, whatever key order + // it happened to be serialized in. + let (_alice, _claude, doc) = certificate(); + let shuffled: Value = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); + + assert_eq!(encode_for_transport(&doc), encode_for_transport(&shuffled)); + assert_eq!( + transport_fingerprint(&encode_for_transport(&doc)), + transport_fingerprint(&encode_for_transport(&shuffled)) + ); + } + + #[test] + fn a_tampered_certificate_survives_transport_but_fails_verification() { + // Decoding must not be mistaken for trust — this is why they are two + // functions and the size/format check never implies a valid proof. + let (alice, _claude, mut doc) = certificate(); + doc["scope"]["permissions"] = json!(["full"]); + + let decoded = decode_from_transport(&encode_for_transport(&doc)).unwrap(); + assert!(verify(&decoded, &alice.identity.public_key).is_err()); + } + + #[test] + fn an_oversized_payload_is_refused_before_parsing() { + let huge = "A".repeat(MAX_ENCODED_DELEGATION + 1); + let err = decode_from_transport(&huge).unwrap_err(); + assert!(err.to_string().contains("over the"), "{err}"); + } + + #[test] + fn malformed_transport_input_is_an_error_not_a_panic() { + assert!(decode_from_transport("not base64url!!").is_err()); + // Valid base64url, not JSON. + assert!(decode_from_transport(&data_encoding::BASE64URL_NOPAD.encode(b"nope")).is_err()); + assert!(decode_from_transport("").is_err()); + } + + #[test] + fn different_certificates_fingerprint_differently() { + let (_a, _b, one) = certificate(); + let (_c, _d, two) = certificate(); + assert_ne!( + transport_fingerprint(&encode_for_transport(&one)), + transport_fingerprint(&encode_for_transport(&two)) + ); + } + + #[test] + fn self_contained_verify_needs_no_external_key() { + // A CI runner holds the agent key and the certificate, and nothing else. + let (alice, _claude, doc) = certificate(); + let parsed = verify_self_contained(&doc).unwrap(); + assert_eq!(parsed.delegator_name, "alice"); + assert_eq!( + delegator_public_key(&parsed).unwrap(), + alice.identity.public_key + ); + } + + #[test] + fn self_contained_verify_still_catches_tampering() { + let (_alice, _claude, mut doc) = certificate(); + doc["scope"]["permissions"] = json!(["full"]); + assert!(verify_self_contained(&doc).is_err()); + } + + #[test] + fn a_certificate_resigned_by_another_key_fails_against_the_named_delegator() { + // Mallory re-signs alice's certificate with her own key but leaves the + // `delegator`/`delegatorKey` fields naming alice. Integrity checks must + // catch the mismatch rather than accepting Mallory's signature. + let (alice, _claude, mut doc) = certificate(); + let mallory = human("mallory"); + doc.as_object_mut().unwrap().remove("proof"); + doc.as_object_mut().unwrap().remove("contentHash"); + doc.as_object_mut().unwrap().remove("attributedTo"); + let doc = proof::attest_value(doc, &mallory.identity, &mallory.keypair); + + // Against alice's key: the signature is not hers. + assert!(verify(&doc, &alice.identity.public_key).is_err()); + // Against mallory's key: the document names alice as delegator. + assert!(verify(&doc, &mallory.identity.public_key).is_err()); + // Self-contained: same conclusion, no external key needed. + assert!(verify_self_contained(&doc).is_err()); + } + + #[test] + fn a_certificate_is_not_a_revocation() { + let (alice, _claude, doc) = certificate(); + assert!(verify_revocation(&doc, &alice.identity.public_key).is_err()); + assert!(verify_request(&doc).is_err()); + } + + #[test] + fn request_self_signature_proves_possession() { + let alice = human("alice"); + let runner = agent("ci-runner", &alice.identity); + + let request = mint_request( + &runner.identity, + &runner.keypair, + Some("urn:atomic:agent:ci"), + ); + let parsed = verify_request(&request).unwrap(); + + assert_eq!(parsed.delegate_name, "ci-runner"); + assert_eq!(parsed.delegate, runner.identity.id.to_did()); + assert_eq!( + parsed.software_agent.as_deref(), + Some("urn:atomic:agent:ci") + ); + } + + #[test] + fn a_request_naming_someone_elses_key_is_rejected() { + let alice = human("alice"); + let runner = agent("ci-runner", &alice.identity); + let mallory = human("mallory"); + + // Mallory signs a request but claims the runner's DID. + let mut doc = mint_request(&mallory.identity, &mallory.keypair, None); + doc.as_object_mut().unwrap().remove("proof"); + doc.as_object_mut().unwrap().remove("contentHash"); + doc["delegate"] = json!(runner.identity.id.to_did()); + let doc = proof::attest_value(doc, &mallory.identity, &mallory.keypair); + + assert!(verify_request(&doc).is_err()); + } + + #[test] + fn revocation_round_trips() { + let (alice, _claude, doc) = certificate(); + let parsed = verify(&doc, &alice.identity.public_key).unwrap(); + + let revocation = mint_revocation( + &alice.identity, + &alice.keypair, + &parsed.id, + Some("laptop lost"), + ); + let checked = verify_revocation(&revocation, &alice.identity.public_key).unwrap(); + + assert_eq!(checked.delegation, parsed.id.to_urn()); + assert_eq!(checked.reason.as_deref(), Some("laptop lost")); + } + + #[test] + fn only_the_delegator_can_revoke() { + let (alice, _claude, doc) = certificate(); + let parsed = verify(&doc, &alice.identity.public_key).unwrap(); + let mallory = human("mallory"); + + // Mallory signs a revocation for alice's delegation. + let revocation = mint_revocation(&mallory.identity, &mallory.keypair, &parsed.id, None); + + // It verifies as Mallory's own document... + assert!(verify_revocation(&revocation, &mallory.identity.public_key).is_ok()); + // ...but a server checking it against the delegator's key rejects it. + assert!(verify_revocation(&revocation, &alice.identity.public_key).is_err()); + } + + #[test] + fn parse_does_not_require_a_valid_proof() { + let (_alice, _claude, mut doc) = certificate(); + doc["scope"]["permissions"] = json!(["full"]); + + // parse is for display; it must not be mistaken for authorization. + let parsed = parse(&doc).unwrap(); + assert!(parsed.scope.has_permission(DelegationPermission::Full)); + } + + #[test] + fn timestamps_are_second_precision_for_stable_bytes() { + let (alice, _claude, doc) = certificate(); + let issued = doc["issued"].as_str().unwrap(); + assert!(issued.ends_with('Z'), "issued should be UTC: {issued}"); + assert!( + !issued.contains('.'), + "sub-second precision makes canonical bytes fragile: {issued}" + ); + // And the document still verifies after a JSON round trip. + let round_tripped: Value = + serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); + assert!(verify(&round_tripped, &alice.identity.public_key).is_ok()); + } +} diff --git a/atomic-canonical/src/did.rs b/atomic-canonical/src/did.rs index 57665472..f6fe9d68 100644 --- a/atomic-canonical/src/did.rs +++ b/atomic-canonical/src/did.rs @@ -18,31 +18,27 @@ //! Data-Integrity tooling that resolves `did:key` natively. Verification //! accepts either method for the same key. +use atomic_identity::identity::IdentityId; use atomic_identity::keypair::PublicKey; -pub const DID_ATOMIC_PREFIX: &str = "did:atomic:"; -pub const DID_KEY_PREFIX: &str = "did:key:"; -/// Multicodec prefix for an Ed25519 public key (varint 0xed01). -const MULTICODEC_ED25519_PUB: [u8; 2] = [0xed, 0x01]; +pub use atomic_identity::identity::DID_ATOMIC_PREFIX; +pub use atomic_identity::keypair::DID_KEY_PREFIX; /// Build the `did:atomic:...` identifier for a public key. +/// +/// Both DID renderings are derived in `atomic-identity` — the DID *is* the +/// identity's identifier, so it belongs with the type that owns identity, and +/// having one derivation means a `did:atomic` here can never disagree with an +/// `IdentityId` there. pub fn did_for_public_key(public_key: &PublicKey) -> String { - let fingerprint = blake3::hash(public_key.as_bytes()); - format!( - "{}{}", - DID_ATOMIC_PREFIX, - data_encoding::BASE32_NOPAD.encode(fingerprint.as_bytes()) - ) + IdentityId::from_public_key(public_key).to_did() } /// Build the standard `did:key` identifier for an Ed25519 public key /// (multicodec `ed25519-pub` + key bytes, base58btc multibase). Always /// starts `did:key:z6Mk` for Ed25519 keys. pub fn did_key_for_public_key(public_key: &PublicKey) -> String { - let mut bytes = Vec::with_capacity(2 + public_key.as_bytes().len()); - bytes.extend_from_slice(&MULTICODEC_ED25519_PUB); - bytes.extend_from_slice(public_key.as_bytes()); - format!("{}z{}", DID_KEY_PREFIX, bs58::encode(bytes).into_string()) + public_key.to_did_key() } /// The verification method id used in a proof (`#key-1`). diff --git a/atomic-canonical/src/lib.rs b/atomic-canonical/src/lib.rs index e3be828d..8b862f9a 100644 --- a/atomic-canonical/src/lib.rs +++ b/atomic-canonical/src/lib.rs @@ -29,6 +29,7 @@ //! any attested node is trusted in a real/shared setting. pub mod context; +pub mod delegation; pub mod did; pub mod directive; pub mod error; diff --git a/atomic-cli/src/commands/auth.rs b/atomic-cli/src/commands/auth.rs index 3873e198..359226ae 100644 --- a/atomic-cli/src/commands/auth.rs +++ b/atomic-cli/src/commands/auth.rs @@ -33,6 +33,13 @@ use url::Url; use crate::error::{CliError, CliResult}; +/// Environment override naming the agent identity to authenticate as. +/// +/// For machines with no config to bind — CI runners, containers — where the +/// agent's key is delivered as a secret and there is no interactive step in +/// which to write a profile. +pub const AGENT_IDENTITY_ENV: &str = "ATOMIC_AGENT_IDENTITY"; + /// One `[server]`/`[servers.*]` profile that declares an identity: its host, /// its bound identity, and whether it is the active profile (the one /// `default_server` selects, or the legacy block when no name is set). @@ -40,6 +47,14 @@ use crate::error::{CliError, CliResult}; struct ServerBinding { host: String, identity: String, + /// The agent identity bound to this profile, when one has been created. + /// + /// Only the *repository* protocol (push/pull/clone) prefers it. Management + /// commands resolve through `client.rs` and stay on the human, because + /// enrolling, renewing and revoking a delegation are things only the human + /// may do — an agent authorized to widen its own certificate would defeat + /// the point of having one. + agent_identity: Option, active: bool, } @@ -66,6 +81,16 @@ fn resolve_identity_name_with_override( if let Some(name) = identity_override { return Some(name.to_string()); } + // A CI runner has no config to bind and no interactive step to write one; + // an env var is the only handle it has. Behind --identity, ahead of config, + // because a runner setting it means it. + if let Ok(name) = std::env::var(AGENT_IDENTITY_ENV) { + let name = name.trim(); + if !name.is_empty() { + log::debug!("Using agent identity from {AGENT_IDENTITY_ENV}: {name}"); + return Some(name.to_string()); + } + } resolve_identity_from_url(remote_url, &configured_server_identity_bindings()) } @@ -100,6 +125,7 @@ fn configured_server_identity_bindings() -> Vec { bindings.push(ServerBinding { host, identity: identity.clone(), + agent_identity: server.agent_identity.clone(), active, }); } @@ -174,7 +200,15 @@ fn match_server_identity(remote_host: &str, servers: &[ServerBinding]) -> Option .iter() .filter(|b| host_is_under(remote_host, &b.host)) .max_by_key(|b| (b.host.len(), b.active)) - .map(|b| b.identity.clone()) + // An agent bound to this profile is the one that should be signing + // repository traffic: that is the whole reason it was created. The + // human binding remains the fallback, so a profile with no agent + // behaves exactly as before. + .map(|b| { + b.agent_identity + .clone() + .unwrap_or_else(|| b.identity.clone()) + }) } /// Whether `remote_host` is the server host itself or a subdomain of it, @@ -373,7 +407,18 @@ pub async fn attach_identity( match crate::commands::token::get_token(&server, &identity).await { Ok(jwt) => { log::debug!("Attaching Bearer JWT for identity '{}'", identity_name); - config.with_header("Authorization", format!("Bearer {}", jwt)) + let config = config.with_header("Authorization", format!("Bearer {}", jwt)); + + // An agent also presents the certificate it acts under. The server + // verifies it per request against the delegator's registered key, + // which is what lets a grant be issued without telling the server + // first. + match delegation_header(&store, &identity, &server) { + Some(encoded) => { + config.with_header(atomic_canonical::delegation::DELEGATION_HEADER, encoded) + } + None => config, + } } Err(e) => { // Non-fatal: a server that doesn't require auth still works for @@ -385,6 +430,32 @@ pub async fn attach_identity( } } +/// The encoded certificate an agent identity should present, if any. +/// +/// `None` for a human — they have no certificate and need none. `None` too when +/// an agent has no usable certificate, because the resulting 401 from the +/// server ("carries no certificate") names the problem better than anything we +/// could raise here, and a read against a public project may not need one at +/// all. +fn delegation_header(store: &IdentityStore, identity: &Identity, server: &str) -> Option { + if !identity.identity_type.is_delegated() { + return None; + } + + match crate::commands::delegation::active_for(store, identity, Some(server)) { + Ok(resolved) => Some(atomic_canonical::delegation::encode_for_transport( + &resolved.document, + )), + Err(e) => { + log::debug!( + "No usable delegation for agent '{}' against {server}: {e}", + identity.name + ); + None + } + } +} + /// What went wrong (if anything) when checking for usable push credentials. /// /// Authentication for atomic-storage is a *self-signed* EdDSA JWT: the CLI @@ -856,10 +927,54 @@ mod tests { // -- configured server-host identity binding -- + /// Repository traffic signs as the agent when one is bound: that is the + /// entire reason `atomic identity agent create` writes the binding. + #[test] + fn an_agent_binding_wins_over_the_human_for_repository_traffic() { + let bindings = vec![ServerBinding { + host: "atomic.storage".to_string(), + identity: "alice".to_string(), + agent_identity: Some("alice+claude".to_string()), + active: true, + }]; + assert_eq!( + resolve_identity_from_url("https://acme.atomic.storage/x", &bindings).as_deref(), + Some("alice+claude") + ); + } + + /// A profile with no agent behaves exactly as it did before agents existed. + #[test] + fn without_an_agent_binding_the_human_is_used() { + let bindings = vec![ServerBinding { + host: "atomic.storage".to_string(), + identity: "alice".to_string(), + agent_identity: None, + active: true, + }]; + assert_eq!( + resolve_identity_from_url("https://acme.atomic.storage/x", &bindings).as_deref(), + Some("alice") + ); + } + + /// An explicit --identity still beats everything, agent binding included — + /// otherwise there would be no way to push as yourself from a machine where + /// an agent is configured. + #[test] + fn an_explicit_identity_overrides_the_agent_binding() { + assert_eq!( + resolve_identity_name_with_override("https://acme.atomic.storage/x", Some("alice")) + .as_deref(), + Some("alice") + ); + } + fn binding(host: &str, identity: &str) -> ServerBinding { ServerBinding { host: host.to_string(), identity: identity.to_string(), + agent_identity: None, active: false, } } @@ -922,6 +1037,7 @@ mod tests { active_legacy.push(ServerBinding { host: "localhost".to_string(), identity: "legacy-identity".to_string(), + agent_identity: None, active: true, }); active_legacy.push(binding("localhost", "named-identity")); @@ -939,6 +1055,7 @@ mod tests { active_named.push(ServerBinding { host: "localhost".to_string(), identity: "named-identity".to_string(), + agent_identity: None, active: true, }); assert_eq!( @@ -972,6 +1089,7 @@ mod tests { let bindings = vec![ServerBinding { host: "localhost".to_string(), identity: "leefaus".to_string(), + agent_identity: None, active: true, }]; let url = "http://localhost:8444/workspaces/w/projects/p/code"; diff --git a/atomic-cli/src/commands/client.rs b/atomic-cli/src/commands/client.rs index 51a329d3..59fcaf57 100644 --- a/atomic-cli/src/commands/client.rs +++ b/atomic-cli/src/commands/client.rs @@ -103,13 +103,82 @@ pub async fn build_apex_client(server_override: Option<&str>) -> CliResult Option { + if !identity.identity_type.is_delegated() { + return None; + } + let store = IdentityStore::open_default().ok()?; + let resolved = crate::commands::delegation::active_for(&store, identity, Some(server)).ok()?; + Some(atomic_canonical::delegation::encode_for_transport( + &resolved.document, + )) +} + +/// Resolve the active server's apex URL without building a client. +/// +/// Agent enrollment needs the URL before it can mint a certificate — the +/// certificate is *bound* to the server it is valid against, so the URL is an +/// input to signing, not just to the request that follows. +pub fn resolve_apex_url(server_override: Option<&str>) -> CliResult { + let config = GlobalConfig::load() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load global config: {}", e)))?; + + let server = config + .resolve_server(server_override) + .map_err(|e| CliError::Internal(anyhow::anyhow!("{}", e)))? + .0; + + server.url.clone().ok_or_else(|| { + let hint = if let Some(name) = server_override { + format!("Server profile '{}' has no URL configured.", name) + } else { + "Server not configured. Run 'atomic identity register ' first.".to_string() + }; + CliError::Internal(anyhow::anyhow!("{}", hint)) + }) +} + +/// Build an apex-scoped [`StorageClient`] authenticating as a **named** +/// identity rather than whichever one the server profile resolves to. +/// +/// Agent enrollment is the reason this exists: the caller must be the human who +/// signs the certificate, and that is not necessarily the identity bound to the +/// profile — nor, once agents are in play, the default. Making the identity +/// explicit keeps "who is enrolling" a decision at the call site instead of a +/// side effect of configuration. +pub async fn build_apex_client_as( + identity: &atomic_identity::Identity, + server_override: Option<&str>, +) -> CliResult<(StorageClient, String)> { + let apex_url = resolve_apex_url(server_override)?; + let bearer_token = crate::commands::token::get_token(&apex_url, identity).await?; + let delegation = delegation_for(identity, &apex_url); + + let client = + StorageClient::with_delegation(&apex_url, "", &bearer_token, delegation.as_deref()) + .map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to create storage client: {}", e)) + })?; + + Ok((client, apex_url)) +} + /// Build a [`StorageClient`] and return the resolved org slug alongside it. /// /// Useful for commands that also need to resolve org-scoped state (e.g. diff --git a/atomic-cli/src/commands/delegation.rs b/atomic-cli/src/commands/delegation.rs new file mode 100644 index 00000000..64711131 --- /dev/null +++ b/atomic-cli/src/commands/delegation.rs @@ -0,0 +1,579 @@ +//! Resolving which delegation authorizes an agent right now. +//! +//! An agent identity is only half the story — on its own it proves possession +//! of a key and nothing else. The certificate signed by the human is what says +//! the key is theirs and what it may do. Everything that acts as an agent +//! (minting a token, pre-flighting a push, listing agents) needs the same +//! question answered: *for this identity, against this server, which +//! certificate applies, and is it still good?* +//! +//! This module is that one answer, so the CLI cannot end up with a permissive +//! check on one path and a strict one on another. +//! +//! # What "still good" means locally +//! +//! Locally we can check three of the four things that matter — the proof +//! verifies, it has not expired, and it is in scope — plus a locally recorded +//! revocation. We cannot check a revocation issued from another machine; the +//! server is the authority there and re-checks on every request. So a local +//! pass means "worth sending", never "the server will accept it". + +use atomic_canonical::delegation as cert; +use atomic_identity::delegation::{ + Delegation, DelegationPermission, DelegationStatus, ResourceRef, +}; +use atomic_identity::{Identity, IdentityStore}; +use serde_json::Value; + +use crate::error::{CliError, CliResult}; + +/// Environment variable carrying an encoded grant. +/// +/// The delivery mechanism for anywhere there is no interactive step and no +/// config to write: CI runners, containers, a sandbox handed a fresh short-lived +/// grant each session. Set it and the agent presents that certificate, with +/// nothing else to install. +/// +/// It takes precedence over the store, because a caller that set it meant it — +/// and because the common shape is a runner given a grant minted seconds ago +/// while the store may hold something older. +pub const DELEGATION_ENV: &str = "ATOMIC_DELEGATION"; + +/// A stored certificate together with everything the CLI knows about it. +#[derive(Debug, Clone)] +pub struct ResolvedDelegation { + /// The verified, typed certificate. + pub delegation: Delegation, + /// The document as stored — the exact bytes the proof covers. + pub document: Value, + /// Status from local facts: expiry plus any locally recorded revocation. + pub status: DelegationStatus, +} + +impl ResolvedDelegation { + /// Base32 id, the form used for filenames and API paths. + pub fn id(&self) -> String { + self.delegation.id.to_base32() + } + + /// Is this usable, as far as this machine can tell? + pub fn is_usable(&self) -> bool { + self.status == DelegationStatus::Active + } +} + +/// Every stored certificate naming `identity` as the delegate, verified. +/// +/// A thin adapter over [`atomic_canonical::delegation::load_for_delegate`] so +/// the CLI, the agent recording path, and anything else asking this question +/// get the same answer from the same code. +pub fn load_for_delegate( + store: &IdentityStore, + identity: &Identity, +) -> CliResult> { + let stored = cert::load_for_delegate(store, identity) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load delegations: {e}")))?; + + Ok(stored + .into_iter() + .map(|s| { + let status = if s.revoked_locally { + DelegationStatus::Revoked + } else { + s.delegation.status() + }; + ResolvedDelegation { + delegation: s.delegation, + document: s.document, + status, + } + }) + .collect()) +} + +/// The certificate to use for `identity` against `server`. +/// +/// Picks the most recently issued certificate that is active, unrevoked, and +/// scoped to that server. Fails with a message naming the command that fixes +/// it, because every reason this can fail is something the human who issued the +/// delegation can put right. +pub fn active_for( + store: &IdentityStore, + identity: &Identity, + server: Option<&str>, +) -> CliResult { + // A grant handed over out of band wins. It is the mechanism for machines + // with no store to populate, and the freshest thing the caller has. + if let Some(resolved) = from_environment(identity)? { + return Ok(resolved); + } + + let all = load_for_delegate(store, identity)?; + + if all.is_empty() { + return Err(CliError::DelegationError { + message: format!( + "'{}' is an agent identity with no delegation certificate on this machine.\n \ + Issue one with: atomic identity delegate {} --can read,record,push", + identity.name, identity.name + ), + }); + } + + let resource = server + .map(|s| ResourceRef::new().server(s)) + .unwrap_or_default(); + + let usable = all.iter().find(|d| { + d.is_usable() + && d.delegation + .scope + .allows(DelegationPermission::Read, &resource) + }); + + if let Some(found) = usable { + return Ok(found.clone()); + } + + // Nothing usable: say precisely why, using the freshest certificate as the + // subject. Reporting "no delegation" when one is merely expired sends the + // human looking for the wrong problem. + let newest = &all[0]; + let message = match newest.status { + DelegationStatus::Revoked => format!( + "The delegation for '{}' has been revoked.\n \ + Issue a new one with: atomic identity delegate {} --can read,record,push", + identity.name, identity.name + ), + DelegationStatus::Expired => format!( + "The delegation for '{}' expired {}.\n \ + Renew it with: atomic identity agent renew {}", + identity.name, + newest + .delegation + .expires + .map(|e| e.format("on %Y-%m-%d").to_string()) + .unwrap_or_else(|| "some time ago".to_string()), + identity.name + ), + DelegationStatus::Active => match server { + Some(server) => format!( + "The delegation for '{}' is not valid against {server}.\n \ + It is scoped to: {}\n \ + Issue one for this server with: atomic identity delegate {} --server {server}", + identity.name, + if newest.delegation.scope.servers.is_empty() { + "(no server — this should not happen)".to_string() + } else { + newest.delegation.scope.servers.join(", ") + }, + identity.name + ), + None => format!( + "No usable delegation for '{}'.\n \ + Issue one with: atomic identity delegate {}", + identity.name, identity.name + ), + }, + }; + + Err(CliError::DelegationError { message }) +} + +/// A grant supplied through [`DELEGATION_ENV`], verified and checked to belong +/// to this agent. +/// +/// Returns `Err` rather than `Ok(None)` when the variable is set but unusable. +/// Falling back to the store there would be worse than failing: the operator +/// asked for a specific grant, and silently using a different one is how you +/// get an agent acting under a scope nobody intended. +fn from_environment(identity: &Identity) -> CliResult> { + let Ok(encoded) = std::env::var(DELEGATION_ENV) else { + return Ok(None); + }; + let encoded = encoded.trim(); + if encoded.is_empty() { + return Ok(None); + } + + let document = cert::decode_from_transport(encoded).map_err(|e| CliError::DelegationError { + message: format!("{DELEGATION_ENV} is not a usable grant: {e}"), + })?; + + let delegation = + cert::verify_self_contained(&document).map_err(|e| CliError::DelegationError { + message: format!("The grant in {DELEGATION_ENV} does not verify: {e}"), + })?; + + if delegation.delegate != identity.id.to_did() { + // Compare DIDs, and say so. Display names are not unique — two agents + // called `alice+claude` on different machines are different keys, and + // an error reading "issued to 'alice+claude', not 'alice+claude'" tells + // the reader nothing. + return Err(CliError::DelegationError { + message: format!( + "The grant in {DELEGATION_ENV} was issued to a different key.\n \ + Grant is for {} ({})\n \ + Running as {} ({})", + delegation.delegate_name, + delegation.delegate, + identity.name, + identity.id.to_did() + ), + }); + } + + if delegation.is_expired() { + return Err(CliError::DelegationError { + message: format!( + "The grant in {DELEGATION_ENV} expired {}. Ask for a fresh one.", + delegation + .expires + .map(|e| e.format("on %Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_else(|| "some time ago".to_string()) + ), + }); + } + + Ok(Some(ResolvedDelegation { + delegation, + document, + status: DelegationStatus::Active, + })) +} + +/// Pre-flight a specific operation before paying for a network round trip. +/// +/// The server is the authority and re-checks everything; this exists so an +/// out-of-scope push fails with "your agent may not push to acme/api" instead +/// of an opaque 403 after the upload. +pub fn check_permission( + resolved: &ResolvedDelegation, + permission: DelegationPermission, + resource: &ResourceRef<'_>, + identity_name: &str, +) -> CliResult<()> { + if resolved.delegation.allows(permission, resource) { + return Ok(()); + } + + let granted = resolved + .delegation + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", "); + + let mut message = format!( + "Agent '{identity_name}' is not authorized to {permission} here.\n \ + Granted: {granted}" + ); + if let Some(project) = resource.project { + if !resolved.delegation.scope.allows_project(project) { + message.push_str(&format!( + "\n Scoped to projects: {}\n Requested: {project}", + if resolved.delegation.scope.projects.is_empty() { + "(all)".to_string() + } else { + resolved.delegation.scope.projects.join(", ") + } + )); + } + } + message.push_str(&format!( + "\n Widen it with: atomic identity delegate {identity_name} --can {permission}" + )); + + Err(CliError::DelegationError { message }) +} + +#[cfg(test)] +mod tests { + use super::*; + use atomic_identity::delegation::DelegationScope; + use atomic_identity::{IdentityType, KeyPair}; + use chrono::Duration; + use tempfile::TempDir; + + struct Fixture { + _dir: TempDir, + store: IdentityStore, + human: Identity, + human_key: KeyPair, + agent: Identity, + } + + fn fixture() -> Fixture { + let dir = TempDir::new().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + + let human_key = KeyPair::generate(); + let human = Identity::new("alice", &human_key); + + let agent_key = KeyPair::generate(); + let agent = Identity::builder("alice+claude") + .identity_type(IdentityType::Agent) + .public_key(agent_key.public.clone()) + .delegated_by(human.id) + .build() + .unwrap(); + + Fixture { + _dir: dir, + store, + human, + human_key, + agent, + } + } + + fn issue(f: &Fixture, scope: DelegationScope, expires_in: Duration) -> String { + let terms = Delegation::new(&f.human, &f.agent, scope).expires_in(expires_in); + let doc = cert::mint(&f.human, &f.human_key, &terms); + let id = terms.id.to_base32(); + f.store + .save_delegation(&id, &serde_json::to_string_pretty(&doc).unwrap()) + .unwrap(); + id + } + + fn scope_for(server: &str) -> DelegationScope { + DelegationScope::builder() + .permission(DelegationPermission::Read) + .permission(DelegationPermission::Push) + .server(server) + .project("acme/*") + .build() + } + + /// The automation path: a runner is handed a grant in the environment and + /// needs nothing installed. + #[test] + #[serial_test::serial] + fn a_grant_in_the_environment_is_used() { + let f = fixture(); + let terms = Delegation::new(&f.human, &f.agent, scope_for("https://atomic.storage")) + .expires_in(Duration::days(1)); + let doc = cert::mint(&f.human, &f.human_key, &terms); + + std::env::set_var(DELEGATION_ENV, cert::encode_for_transport(&doc)); + let found = active_for(&f.store, &f.agent, Some("https://atomic.storage")); + std::env::remove_var(DELEGATION_ENV); + + let found = found.unwrap(); + assert_eq!(found.delegation.id, terms.id); + // Nothing was ever written to the store. + assert!(f.store.list_delegations().unwrap().is_empty()); + } + + /// A grant issued to someone else must not be usable just because it is in + /// the environment. + #[test] + #[serial_test::serial] + fn a_grant_for_another_agent_is_refused() { + let f = fixture(); + let other = Identity::builder("alice+gemini") + .identity_type(IdentityType::Agent) + .delegated_by(f.human.id) + .build() + .unwrap(); + let terms = Delegation::new(&f.human, &other, DelegationScope::full()); + let doc = cert::mint(&f.human, &f.human_key, &terms); + + std::env::set_var(DELEGATION_ENV, cert::encode_for_transport(&doc)); + let result = active_for(&f.store, &f.agent, None); + std::env::remove_var(DELEGATION_ENV); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("was issued to"), "{err}"); + } + + /// A set-but-broken variable must fail loudly rather than falling back to + /// the store — silently using a different grant than the one asked for is + /// how an agent ends up with a scope nobody intended. + #[test] + #[serial_test::serial] + fn a_broken_environment_grant_does_not_fall_back() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + std::env::set_var(DELEGATION_ENV, "not-a-grant"); + let result = active_for(&f.store, &f.agent, Some("https://atomic.storage")); + std::env::remove_var(DELEGATION_ENV); + + assert!( + result.is_err(), + "should not have silently used the stored grant" + ); + } + + /// An empty variable is treated as unset, so `ATOMIC_DELEGATION=` in a + /// shell profile does not break an otherwise working setup. + #[test] + #[serial_test::serial] + fn an_empty_environment_variable_is_ignored() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + std::env::set_var(DELEGATION_ENV, ""); + let result = active_for(&f.store, &f.agent, Some("https://atomic.storage")); + std::env::remove_var(DELEGATION_ENV); + + assert!(result.is_ok()); + } + + #[test] + #[serial_test::serial] + fn finds_the_certificate_for_this_agent() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + let found = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap(); + assert_eq!(found.delegation.delegate_name, "alice+claude"); + assert!(found.is_usable()); + } + + #[test] + #[serial_test::serial] + fn ignores_certificates_belonging_to_another_agent() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + let other = Identity::builder("alice+gemini") + .identity_type(IdentityType::Agent) + .delegated_by(f.human.id) + .build() + .unwrap(); + + assert!(load_for_delegate(&f.store, &other).unwrap().is_empty()); + } + + #[test] + #[serial_test::serial] + fn no_certificate_says_how_to_issue_one() { + let f = fixture(); + let err = active_for(&f.store, &f.agent, None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("no delegation certificate"), "{msg}"); + assert!(msg.contains("atomic identity delegate"), "{msg}"); + } + + #[test] + #[serial_test::serial] + fn an_expired_certificate_says_renew_not_issue() { + let f = fixture(); + // expires_in with a negative duration puts expiry in the past. + issue(&f, scope_for("https://atomic.storage"), Duration::days(-1)); + + let err = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("expired"), "{msg}"); + assert!(msg.contains("agent renew"), "{msg}"); + } + + #[test] + #[serial_test::serial] + fn a_locally_revoked_certificate_is_refused() { + let f = fixture(); + let id = issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + f.store.save_revocation(&id, "{}").unwrap(); + + let err = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap_err(); + assert!(err.to_string().contains("revoked"), "{err}"); + } + + #[test] + #[serial_test::serial] + fn a_certificate_for_another_server_is_refused_with_the_scope_shown() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + let err = active_for(&f.store, &f.agent, Some("https://staging.example")).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("not valid against https://staging.example"), + "{msg}" + ); + assert!(msg.contains("https://atomic.storage"), "{msg}"); + } + + #[test] + #[serial_test::serial] + fn a_tampered_certificate_is_skipped_not_trusted() { + let f = fixture(); + let id = issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + // Widen the scope on disk, exactly what a compromised agent would try. + let mut doc: Value = serde_json::from_str(&f.store.load_delegation(&id).unwrap()).unwrap(); + doc["scope"]["permissions"] = serde_json::json!(["full"]); + f.store + .save_delegation(&id, &serde_json::to_string(&doc).unwrap()) + .unwrap(); + + assert!(load_for_delegate(&f.store, &f.agent).unwrap().is_empty()); + assert!(active_for(&f.store, &f.agent, None).is_err()); + } + + #[test] + #[serial_test::serial] + fn permission_check_names_the_missing_permission() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + let resolved = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap(); + + // Push is granted... + check_permission( + &resolved, + DelegationPermission::Push, + &ResourceRef::new().project("acme/api"), + "alice+claude", + ) + .unwrap(); + + // ...admin is not. + let err = check_permission( + &resolved, + DelegationPermission::Admin, + &ResourceRef::new().project("acme/api"), + "alice+claude", + ) + .unwrap_err(); + assert!(err.to_string().contains("not authorized to admin"), "{err}"); + } + + #[test] + #[serial_test::serial] + fn permission_check_names_the_out_of_scope_project() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + let resolved = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap(); + + let err = check_permission( + &resolved, + DelegationPermission::Push, + &ResourceRef::new().project("other/api"), + "alice+claude", + ) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("other/api"), "{msg}"); + assert!(msg.contains("acme/*"), "{msg}"); + } + + #[test] + #[serial_test::serial] + fn the_freshest_certificate_wins() { + let f = fixture(); + issue(&f, DelegationScope::read_only(), Duration::days(30)); + std::thread::sleep(std::time::Duration::from_millis(1100)); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + let found = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap(); + assert!(found + .delegation + .scope + .has_permission(DelegationPermission::Push)); + } +} diff --git a/atomic-cli/src/commands/identity/agent/create.rs b/atomic-cli/src/commands/identity/agent/create.rs new file mode 100644 index 00000000..a0d3d895 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/create.rs @@ -0,0 +1,453 @@ +//! `atomic identity agent create` — issue an agent identity in one command. +//! +//! Six things have to happen for an agent to be able to work on your behalf, +//! and none of them is useful alone: generate a keypair, create a delegated +//! identity, sign a certificate binding the two, store it, enroll it with the +//! server, and bind it in config so hooks find it. This command does all six +//! and reports what it did. +//! +//! # Usage +//! +//! ```text +//! atomic identity agent create [OPTIONS] +//! +//! Options: +//! --agent-type Software agent (claude-code, gemini-cli, ...) +//! --can Comma-separated: read,record,push,pull,... +//! --projects Comma-separated project patterns +//! --workspaces Comma-separated workspace patterns +//! --views Comma-separated view patterns +//! --expires 30d, 12h, 2w (default: 30d) +//! --max-changes Cap on changes this agent may create +//! -i, --identity Delegating identity (default: your default) +//! --server Server profile to enroll with +//! --local Skip server enrollment +//! ``` + +use chrono::Utc; +use clap::Parser; + +use atomic_canonical::delegation as cert; +use atomic_identity::delegation::{Delegation, DelegationScope}; +use atomic_identity::{Identity, IdentityStore, IdentityType, IdentityUsage, KeyPair}; +use atomic_remote::storage_types::EnrollAgentRequest; + +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_success, print_warning}; + +use super::{ + humanize_remaining, parse_duration, parse_patterns, parse_permissions, software_agent_urn, + DEFAULT_EXPIRY_DAYS, +}; + +/// Create an agent identity and delegate to it. +#[derive(Debug, Parser)] +pub struct Create { + /// Short name for the agent. + /// + /// The stored identity is named `+` — the plus-tag convention + /// the agent recording path already uses, so `log` and `blame` read as an + /// agent at a glance and the email still routes to you. + #[arg(required = true)] + pub name: String, + + /// Software agent slug (`claude-code`, `gemini-cli`, `codex`, ...). + /// + /// Recorded as `urn:atomic:agent:` on the certificate. Descriptive + /// only: it says what kind of software holds the key, not what it may do. + #[arg(long = "agent-type")] + pub agent_type: Option, + + /// Permissions to grant, comma-separated. + #[arg(long, default_value = "read,record,push")] + pub can: String, + + /// Project patterns the agent may touch, comma-separated globs. + /// + /// Omit to leave projects unrestricted — which still does not widen + /// anything, since the agent is capped by your own access regardless. + #[arg(long)] + pub projects: Option, + + /// Workspace patterns, comma-separated globs. + #[arg(long)] + pub workspaces: Option, + + /// View patterns, comma-separated globs. + #[arg(long)] + pub views: Option, + + /// How long the delegation lasts (`30d`, `12h`, `2w`). + #[arg(long)] + pub expires: Option, + + /// Cap the number of changes this agent may create. + #[arg(long)] + pub max_changes: Option, + + /// Which of your identities delegates. Defaults to your default identity. + #[arg(short, long)] + pub identity: Option, + + /// Server profile to enroll with. + #[arg(long)] + pub server: Option, + + /// Create and sign locally without contacting a server. + /// + /// The certificate is still valid and still verifiable; it just is not + /// known to any server yet. `atomic identity delegation push` enrolls it + /// later. + #[arg(long)] + pub local: bool, + + /// Print the certificate as JSON instead of a summary. + #[arg(long)] + pub json: bool, +} + +impl Command for Create { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Create { + async fn execute(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + // 1. The delegator. An agent cannot delegate — allowing it would make + // the chain of custody a graph and the revocation story unbounded. + let delegator = load_delegator(&store, self.identity.as_deref())?; + if delegator.identity_type.is_delegated() || delegator.identity_type.is_agent() { + return Err(CliError::InvalidArgument { + message: format!( + "'{}' is itself an agent identity and cannot delegate.\n \ + Pass a human identity with --identity .", + delegator.name + ), + }); + } + + let agent_name = format!("{}+{}", delegator.name, self.name); + if store.exists_by_name(&agent_name) { + return Err(CliError::IdentityAlreadyExists(agent_name)); + } + + // 2. The server the certificate will be bound to. Resolved before + // signing because it is part of what gets signed. + let server_url = if self.local { + None + } else { + Some(crate::commands::client::resolve_apex_url( + self.server.as_deref(), + )?) + }; + + // 3. The agent's own keypair. + let keypair = KeyPair::generate(); + let mut agent_builder = Identity::builder(&agent_name) + .identity_type(IdentityType::Agent) + .usage(IdentityUsage::Bot) + .public_key(keypair.public.clone()) + .delegated_by(delegator.id) + .description(format!( + "Agent identity acting on behalf of {}", + delegator.name + )); + if let Some(email) = delegator.email.as_deref() { + agent_builder = agent_builder.email(plus_tag_email(email, &self.name)); + } + let agent = agent_builder + .build() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to build identity: {e}")))?; + + // 4. The scope, then the certificate. + let scope = self.build_scope(server_url.as_deref())?; + let expires = self + .expires + .as_deref() + .map(parse_duration) + .transpose()? + .unwrap_or_else(|| chrono::Duration::days(DEFAULT_EXPIRY_DAYS)); + + let mut terms = Delegation::new(&delegator, &agent, scope).expires_in(expires); + if let Some(slug) = &self.agent_type { + terms = terms.with_software_agent(software_agent_urn(slug)); + } + + let delegator_keypair = store.load_keypair(&delegator.id, None).map_err(|e| { + CliError::Internal(anyhow::anyhow!( + "Failed to load the signing key for '{}': {e}", + delegator.name + )) + })?; + let certificate = cert::mint(&delegator, &delegator_keypair, &terms); + + // 5. Persist. The identity and its key first — a certificate naming a + // key that was never stored is worse than no certificate. + store + .save_with_keypair(&agent, &keypair, None) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to save identity: {e}")))?; + + let document = serde_json::to_string_pretty(&certificate).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to encode certificate: {e}")) + })?; + let delegation_id = terms.id.to_base32(); + store + .save_delegation(&delegation_id, &document) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to store certificate: {e}")))?; + + if self.json { + println!("{document}"); + return Ok(()); + } + + // 6. Enroll, and bind in config so hooks find the key without flags. + let enrolled = match &server_url { + Some(url) => self.enroll(&delegator, &agent, &certificate, url).await, + None => Ok(false), + }; + + self.report(&delegator, &agent, &terms, server_url.as_deref(), &enrolled); + + if server_url.is_some() && matches!(enrolled, Ok(true)) { + bind_agent_in_config(self.server.as_deref(), &agent.name); + } + + Ok(()) + } + + /// Assemble the scope from the flags. + fn build_scope(&self, server_url: Option<&str>) -> CliResult { + let mut builder = DelegationScope::builder().permissions(parse_permissions(&self.can)?); + + // Bind to the server unless this is a local-only certificate. A + // certificate with no server is valid everywhere, which is exactly what + // we do not want once one is in play. + if let Some(url) = server_url { + builder = builder.server(url); + } + for pattern in self + .projects + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.project(pattern); + } + for pattern in self + .workspaces + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.workspace(pattern); + } + for pattern in self + .views + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.view(pattern); + } + if let Some(max) = self.max_changes { + builder = builder.max_changes(max); + } + Ok(builder.build()) + } + + /// Enroll the agent key and certificate with the server. + /// + /// Returns `Ok(false)` when the server does not implement agent identities + /// yet — an older deployment is a reason to say so and carry on with a + /// locally valid certificate, not to fail after the key already exists. + async fn enroll( + &self, + delegator: &Identity, + agent: &Identity, + certificate: &serde_json::Value, + server_url: &str, + ) -> CliResult { + let (client, _) = + crate::commands::client::build_apex_client_as(delegator, self.server.as_deref()) + .await?; + + let request = EnrollAgentRequest { + name: agent.name.clone(), + email: agent.email.clone(), + public_key: agent.public_key_base32(), + certificate: certificate.clone(), + }; + + match client.enroll_agent(&request).await { + Ok(_) => Ok(true), + Err(e) if is_unsupported(&e) => { + print_warning(&format!( + "{server_url} does not support agent identities yet — the certificate is \ + valid locally but the server will not accept this key.\n \ + Enroll it once the server is upgraded: atomic identity delegation push" + )); + Ok(false) + } + Err(e) => Err(CliError::RemoteError { + message: format!("Failed to enroll agent: {e}"), + url: Some(server_url.to_string()), + }), + } + } + + fn report( + &self, + delegator: &Identity, + agent: &Identity, + terms: &Delegation, + server_url: Option<&str>, + enrolled: &CliResult, + ) { + print_success(&format!("Created agent identity {}", agent.name)); + println!(); + println!(" DID {}", agent.id.to_did()); + println!(" Key {}", agent.public_key.to_did_key()); + println!( + " Delegated by {} ({})", + delegator.name, + delegator.id.to_did() + ); + println!( + " Can {}", + terms + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + if !terms.scope.projects.is_empty() { + println!(" On {}", terms.scope.projects.join(", ")); + } + if let Some(max) = terms.scope.max_changes { + println!(" Change cap {max}"); + } + match terms.expires { + Some(expires) => println!( + " Expires {} ({})", + expires.format("%Y-%m-%d"), + humanize_remaining(Some(expires - Utc::now())) + ), + None => println!(" Expires never"), + } + println!(" Delegation {}", terms.id.to_urn()); + + println!(); + match (server_url, enrolled) { + (Some(url), Ok(true)) => { + println!("Registered with {url}"); + println!(" Bound in ~/.atomic/config.toml (agent_identity)"); + } + (Some(_), Ok(false)) => {} + (Some(url), Err(e)) => print_warning(&format!("Not registered with {url}: {e}")), + (None, _) => print_hint("Local only — run 'atomic identity delegation push' to enroll"), + } + + println!(); + println!("{}", crate::output::hint("Next steps:")); + println!( + " {} Inspect this agent", + crate::output::command(&format!("atomic identity agent show {}", agent.name)) + ); + println!( + " {} Withdraw it", + crate::output::command(&format!("atomic identity agent revoke {}", agent.name)) + ); + } +} + +/// Load the delegating identity: `--identity`, else the store default. +fn load_delegator(store: &IdentityStore, name: Option<&str>) -> CliResult { + match name { + Some(name) => store + .load_by_name(name) + .map_err(|_| CliError::IdentityNotFound(name.to_string())), + None => store + .get_default() + .map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to load default identity: {e}")) + })? + .ok_or_else(|| { + CliError::Internal(anyhow::anyhow!( + "No default identity set. Create one first:\n \ + atomic identity new --email --set-default" + )) + }), + } +} + +/// `alice@example.com` + `claude` → `alice+claude@example.com`. +/// +/// Plus-addressing is stripped by mail servers, so replies still reach the +/// human while the address itself says an agent produced the change. +fn plus_tag_email(email: &str, tag: &str) -> String { + match email.split_once('@') { + Some((local, domain)) => { + // Do not stack tags if the base address already carries one. + let local = local.split('+').next().unwrap_or(local); + format!("{local}+{tag}@{domain}") + } + None => email.to_string(), + } +} + +/// Is this the far end saying it has never heard of agent identities? +fn is_unsupported(error: &atomic_remote::RemoteError) -> bool { + let message = error.to_string(); + message.contains("404") || message.to_lowercase().contains("not found") +} + +/// Record the agent identity on the server profile so hooks use it by default. +/// +/// Best-effort: failing to write config must not undo a successful enrollment, +/// so this warns rather than erroring. +fn bind_agent_in_config(server_override: Option<&str>, agent_name: &str) { + if let Err(e) = crate::commands::identity::bind_agent_identity(server_override, agent_name) { + print_warning(&format!( + "Agent enrolled, but the config binding could not be written: {e}\n \ + Pass --identity {agent_name} explicitly, or set agent_identity by hand." + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plus_tag_email_routes_back_to_the_human() { + assert_eq!( + plus_tag_email("alice@example.com", "claude"), + "alice+claude@example.com" + ); + } + + #[test] + fn plus_tags_do_not_stack() { + // Delegating from an identity that already has a tag must not produce + // alice+work+claude@ — mail servers strip from the first '+'. + assert_eq!( + plus_tag_email("alice+work@example.com", "claude"), + "alice+claude@example.com" + ); + } + + #[test] + fn a_malformed_address_is_left_alone() { + assert_eq!(plus_tag_email("not-an-email", "claude"), "not-an-email"); + } +} diff --git a/atomic-cli/src/commands/identity/agent/list.rs b/atomic-cli/src/commands/identity/agent/list.rs new file mode 100644 index 00000000..b551d0c5 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/list.rs @@ -0,0 +1,215 @@ +//! `atomic identity agent list` — agents and the state of their delegations. + +use clap::Parser; +use serde_json::json; + +use atomic_identity::delegation::DelegationStatus; +use atomic_identity::{IdentityStore, IdentityType}; + +use crate::commands::delegation::{load_for_delegate, ResolvedDelegation}; +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::print_hint; + +use super::humanize_remaining; + +/// List agent identities and their delegations. +#[derive(Debug, Parser)] +pub struct List { + /// Include agents whose delegation has expired or been revoked. + #[arg(long)] + pub include_expired: bool, + + /// Output as JSON. + #[arg(long)] + pub json: bool, +} + +impl Command for List { + fn run(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let identities = store + .list() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to list identities: {e}")))?; + + let mut rows = Vec::new(); + for identity in identities { + if !matches!( + identity.identity_type, + IdentityType::Agent | IdentityType::Delegated + ) { + continue; + } + // The freshest certificate is the one that governs; older ones are + // history and would only add noise to a list. + let newest = load_for_delegate(&store, &identity)?.into_iter().next(); + let status = newest + .as_ref() + .map(|d| d.status) + .unwrap_or(DelegationStatus::Revoked); + + if !self.include_expired && status != DelegationStatus::Active { + continue; + } + rows.push((identity, newest, status)); + } + + if self.json { + let payload: Vec<_> = rows + .iter() + .map(|(identity, delegation, status)| { + json!({ + "name": identity.name, + "did": identity.id.to_did(), + "publicKey": identity.public_key.to_did_key(), + "type": super::super::format_identity_type(&identity.identity_type), + "status": status.to_string(), + "delegation": delegation.as_ref().map(describe_delegation), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&payload).unwrap()); + return Ok(()); + } + + if rows.is_empty() { + if self.include_expired { + println!("No agent identities."); + } else { + println!("No active agent identities."); + print_hint("Use --include-expired to see withdrawn or lapsed ones"); + } + println!(); + println!( + " {} Create one", + crate::output::command("atomic identity agent create ") + ); + return Ok(()); + } + + println!( + "{:<24} {:<14} {:<22} {:<18} {:<9} STATUS", + "NAME", "AGENT", "CAN", "ON", "EXPIRES" + ); + for (identity, delegation, status) in &rows { + let (can, on, expires) = match delegation { + Some(d) => ( + summarize( + &d.delegation + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>(), + 22, + ), + summarize(&d.delegation.scope.projects, 18), + humanize_remaining(d.delegation.time_remaining()), + ), + None => ("-".to_string(), "-".to_string(), "-".to_string()), + }; + let agent_kind = delegation + .as_ref() + .and_then(|d| d.delegation.software_agent.clone()) + .map(|urn| { + urn.trim_start_matches(atomic_canonical::delegation::AGENT_URN_PREFIX) + .to_string() + }) + .unwrap_or_else(|| "agent".to_string()); + + println!( + "{:<24} {:<14} {:<22} {:<18} {:<9} {}", + identity.name, agent_kind, can, on, expires, status + ); + } + + Ok(()) + } +} + +/// The JSON view of a delegation for `--json`. +fn describe_delegation(resolved: &ResolvedDelegation) -> serde_json::Value { + let d = &resolved.delegation; + json!({ + "id": d.id.to_urn(), + "delegator": d.delegator, + "delegatorName": d.delegator_name, + "softwareAgent": d.software_agent, + "permissions": d.scope.permissions.iter().map(|p| p.to_string()).collect::>(), + "servers": d.scope.servers, + "workspaces": d.scope.workspaces, + "projects": d.scope.projects, + "views": d.scope.views, + "maxChanges": d.scope.max_changes, + "issued": d.issued.to_rfc3339(), + "expires": d.expires.map(|e| e.to_rfc3339()), + "status": resolved.status.to_string(), + }) +} + +/// Render a list into a fixed width, eliding the tail as `first,+n`. +/// +/// Truncating mid-word would leave a project name that looks real but is not; +/// `+2` at least says how much is hidden and sends the reader to `show`. +fn summarize(items: &[String], width: usize) -> String { + if items.is_empty() { + return "(all)".to_string(); + } + let joined = items.join(","); + if joined.len() <= width { + return joined; + } + let mut out = String::new(); + let mut shown = 0; + for item in items { + let candidate = if out.is_empty() { + item.clone() + } else { + format!("{out},{item}") + }; + // Leave room for the ",+n" suffix. + if candidate.len() + 4 > width { + break; + } + out = candidate; + shown += 1; + } + if shown == 0 { + // Even one entry does not fit; show what we can rather than nothing. + return items[0].chars().take(width).collect(); + } + format!("{out},+{}", items.len() - shown) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_empty_scope_reads_as_unrestricted() { + assert_eq!(summarize(&[], 20), "(all)"); + } + + #[test] + fn a_short_list_is_shown_whole() { + assert_eq!(summarize(&["read".into(), "push".into()], 20), "read,push"); + } + + #[test] + fn a_long_list_says_how_much_is_hidden() { + let items: Vec = vec!["acme/api".into(), "acme/web".into(), "acme/docs".into()]; + let out = summarize(&items, 18); + assert!(out.ends_with("+2") || out.ends_with("+1"), "{out}"); + assert!(out.len() <= 18, "{out} is {} chars", out.len()); + } + + #[test] + fn a_single_oversized_entry_is_clipped_rather_than_dropped() { + let items = vec!["a-really-long-project-name/that-does-not-fit".to_string()]; + let out = summarize(&items, 10); + assert_eq!(out.len(), 10); + } +} diff --git a/atomic-cli/src/commands/identity/agent/mod.rs b/atomic-cli/src/commands/identity/agent/mod.rs new file mode 100644 index 00000000..984a2af8 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/mod.rs @@ -0,0 +1,325 @@ +//! Agent identity management — `atomic identity agent`. +//! +//! An agent identity is a keypair of its own, bound to a human by a signed +//! delegation certificate. This module is the porcelain over that: one command +//! to issue one, and the lifecycle commands to inspect, renew and withdraw it. +//! +//! The plumbing each of these composes is separately available — +//! `atomic identity new --delegated-by`, `atomic identity delegate`, +//! `atomic identity delegation push` — for the cases the porcelain does not +//! cover, notably enrolling a key generated on a machine the human never +//! touches. +//! +//! # Usage +//! +//! ```text +//! atomic identity agent +//! +//! Commands: +//! create Create an agent identity and delegate to it +//! list List agent identities and their delegations +//! show Show one agent's identity and delegation in full +//! renew Issue a fresh certificate for an existing agent key +//! revoke Withdraw an agent's delegation +//! ``` +//! +//! # Example +//! +//! ```text +//! $ atomic identity agent create claude \ +//! --agent-type claude-code \ +//! --projects acme/api,acme/web \ +//! --can read,record,push \ +//! --expires 30d +//! ``` + +pub mod create; +pub mod list; +pub mod renew; +pub mod revoke; +pub mod show; + +pub use create::Create; +pub use list::List; +pub use renew::Renew; +pub use revoke::Revoke; +pub use show::Show; + +use chrono::Duration; +use clap::Subcommand; + +use atomic_identity::delegation::DelegationPermission; + +use crate::commands::Command; +use crate::error::{CliError, CliResult}; + +/// Default lifetime of a delegation when the caller does not say. +/// +/// Thirty days is the compromise the whole design leans on: agent secret keys +/// sit unencrypted at `0600` (the store's password path is an unimplemented +/// `TODO`), so the defence is that a leaked key stops working soon and costs +/// one command to replace. Shorter is safer and more annoying; renewal is +/// `atomic identity agent renew`. +pub const DEFAULT_EXPIRY_DAYS: i64 = 30; + +/// Agent identity commands. +#[derive(Debug, clap::Args)] +pub struct Agent { + /// The agent subcommand to run. + #[command(subcommand)] + pub command: AgentCommands, +} + +/// Available agent subcommands. +#[derive(Debug, Subcommand)] +pub enum AgentCommands { + /// Create an agent identity and delegate to it. + /// + /// Generates a keypair, creates a delegated identity, signs a certificate + /// with your key, enrolls it with the server, and binds it in config so + /// hooks pick it up. One command, because every one of those steps is + /// useless without the others. + /// + /// # Examples + /// + /// ```text + /// # An agent that can record and push to two projects for 30 days + /// atomic identity agent create claude \ + /// --agent-type claude-code \ + /// --projects acme/api,acme/web \ + /// --can read,record,push + /// + /// # A read-only agent, no server enrollment + /// atomic identity agent create reviewer --can read --local + /// ``` + Create(Create), + + /// List agent identities and the state of their delegations. + /// + /// # Examples + /// + /// ```text + /// atomic identity agent list + /// atomic identity agent list --include-expired + /// atomic identity agent list --json + /// ``` + List(List), + + /// Show one agent in full: identity, certificate, scope, status. + Show(Show), + + /// Reissue an agent's current scope with a new expiry. + /// + /// A convenience over `atomic identity grant new`: it carries the existing + /// `--can` and `--projects` forward so you need only say how long. The key + /// does not change, so nothing is re-enrolled, and like every issuance it + /// reaches no server. + Renew(Renew), + + /// Withdraw an agent's authority. + /// + /// Bumps the agent's epoch — killing every grant issued to it so far, + /// including ones this machine has never seen — and deny-lists the grants + /// it does know, each with a signed revocation. + /// + /// Unlike issuing, this must reach the server: a credential the holder + /// possesses cannot prove its own withdrawal. The identity and its past + /// work stay, so attribution for changes already recorded does not + /// evaporate. + Revoke(Revoke), +} + +impl Command for Agent { + fn run(&self) -> CliResult<()> { + match &self.command { + AgentCommands::Create(cmd) => cmd.run(), + AgentCommands::List(cmd) => cmd.run(), + AgentCommands::Show(cmd) => cmd.run(), + AgentCommands::Renew(cmd) => cmd.run(), + AgentCommands::Revoke(cmd) => cmd.run(), + } + } +} + +// --------------------------------------------------------------------------- +// Shared argument parsing +// --------------------------------------------------------------------------- + +/// Parse a comma-separated permission list (`read,record,push`). +pub fn parse_permissions(raw: &str) -> CliResult> { + let mut out = Vec::new(); + for part in raw.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let permission: DelegationPermission = + part.parse() + .map_err(|e: String| CliError::InvalidArgument { + message: format!("--can: {e}"), + })?; + if !out.contains(&permission) { + out.push(permission); + } + } + if out.is_empty() { + return Err(CliError::InvalidArgument { + message: "--can needs at least one permission (e.g. --can read,record,push)" + .to_string(), + }); + } + Ok(out) +} + +/// Parse a comma-separated pattern list, dropping blanks. +pub fn parse_patterns(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// Parse a duration like `30d`, `12h`, `90m`, or a bare number of days. +pub fn parse_duration(raw: &str) -> CliResult { + let raw = raw.trim(); + let invalid = || CliError::InvalidArgument { + message: format!( + "--expires: '{raw}' is not a duration (try 30d, 12h, 90m, or a number of days)" + ), + }; + + let (value, unit) = match raw.chars().last() { + Some(c) if c.is_ascii_alphabetic() => (&raw[..raw.len() - 1], c), + Some(_) => (raw, 'd'), + None => return Err(invalid()), + }; + + let value: i64 = value.trim().parse().map_err(|_| invalid())?; + if value <= 0 { + return Err(CliError::InvalidArgument { + message: "--expires must be a positive duration; a delegation that is already \ + expired would authorize nothing" + .to_string(), + }); + } + + match unit.to_ascii_lowercase() { + 'd' => Ok(Duration::days(value)), + 'h' => Ok(Duration::hours(value)), + 'm' => Ok(Duration::minutes(value)), + 'w' => Ok(Duration::weeks(value)), + _ => Err(invalid()), + } +} + +/// The `urn:atomic:agent:` label for a software agent. +/// +/// A URN, never a `did:` — the label names a *kind* of software, not a key. +/// The agent's key has its own DID; conflating the two would put a +/// non-resolvable identifier where a DID is expected. +pub fn software_agent_urn(slug: &str) -> String { + format!( + "{}{}", + atomic_canonical::delegation::AGENT_URN_PREFIX, + slug.trim().to_lowercase() + ) +} + +/// Render a duration as a short human phrase (`in 30d`, `3d ago`). +pub fn humanize_remaining(remaining: Option) -> String { + let Some(remaining) = remaining else { + return "never".to_string(); + }; + let days = remaining.num_days(); + let hours = remaining.num_hours(); + + if remaining.num_seconds() < 0 { + let ago = -remaining; + return if ago.num_days() > 0 { + format!("{}d ago", ago.num_days()) + } else { + format!("{}h ago", ago.num_hours().max(1)) + }; + } + if days > 0 { + format!("in {days}d") + } else if hours > 0 { + format!("in {hours}h") + } else { + "in <1h".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permissions_parse_and_dedupe() { + let parsed = parse_permissions("read, record ,push,read").unwrap(); + assert_eq!( + parsed, + vec![ + DelegationPermission::Read, + DelegationPermission::Record, + DelegationPermission::Push + ] + ); + } + + #[test] + fn an_unknown_permission_is_a_usage_error_not_a_silent_drop() { + let err = parse_permissions("read,teleport").unwrap_err(); + assert!(err.to_string().contains("teleport"), "{err}"); + assert_eq!(err.exit_code(), 2); + } + + #[test] + fn an_empty_permission_list_is_rejected() { + assert!(parse_permissions("").is_err()); + assert!(parse_permissions(" , ").is_err()); + } + + #[test] + fn durations_accept_the_common_units() { + assert_eq!(parse_duration("30d").unwrap(), Duration::days(30)); + assert_eq!(parse_duration("12h").unwrap(), Duration::hours(12)); + assert_eq!(parse_duration("90m").unwrap(), Duration::minutes(90)); + assert_eq!(parse_duration("2w").unwrap(), Duration::weeks(2)); + // A bare number means days, the unit anyone would assume. + assert_eq!(parse_duration("7").unwrap(), Duration::days(7)); + } + + #[test] + fn a_non_positive_duration_is_rejected_with_the_reason() { + let err = parse_duration("0d").unwrap_err(); + assert!(err.to_string().contains("positive"), "{err}"); + assert!(parse_duration("-5d").is_err()); + assert!(parse_duration("soon").is_err()); + } + + #[test] + fn patterns_split_and_trim() { + assert_eq!( + parse_patterns(" acme/api , acme/web ,"), + vec!["acme/api".to_string(), "acme/web".to_string()] + ); + assert!(parse_patterns("").is_empty()); + } + + #[test] + fn software_agent_label_is_a_urn_not_a_did() { + let urn = software_agent_urn("Claude-Code"); + assert_eq!(urn, "urn:atomic:agent:claude-code"); + assert!(!urn.starts_with("did:")); + } + + #[test] + fn remaining_time_reads_naturally_in_both_directions() { + assert_eq!(humanize_remaining(None), "never"); + assert_eq!(humanize_remaining(Some(Duration::days(30))), "in 30d"); + assert_eq!(humanize_remaining(Some(Duration::hours(5))), "in 5h"); + assert_eq!(humanize_remaining(Some(Duration::days(-3))), "3d ago"); + } +} diff --git a/atomic-cli/src/commands/identity/agent/renew.rs b/atomic-cli/src/commands/identity/agent/renew.rs new file mode 100644 index 00000000..3822645c --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/renew.rs @@ -0,0 +1,228 @@ +//! `atomic identity agent renew` — a fresh grant carrying the previous scope. +//! +//! A convenience over `atomic identity grant new`: it reads the agent's current +//! scope and reissues it with a new expiry, so you do not have to retype +//! `--can` and `--projects` to extend something that was already right. +//! +//! Renewal deliberately does *not* touch the keypair. Rotating the key would +//! mean re-enrolling with every server and would break attribution for work +//! already recorded; what expires is the authorization, so that is what gets +//! replaced. +//! +//! Like every other issuance, this reaches no server. The new grant is usable +//! the moment it is signed. `--publish` sends it up for visibility only. +//! +//! # Renewal does not withdraw the old grant +//! +//! The previous certificate stays valid until its own expiry. That is fine when +//! extending — the old one is strictly narrower in time — but it means renewing +//! with a *tighter* scope does not take the wider one away. To make a narrowing +//! bite immediately, bump the epoch: +//! +//! ```text +//! atomic identity agent revoke # bumps the epoch, then reissue +//! ``` + +use chrono::Duration; +use clap::Parser; + +use atomic_canonical::delegation as cert; +use atomic_identity::delegation::{Delegation, DelegationScope}; +use atomic_identity::{Identity, IdentityStore}; +use atomic_remote::storage_types::PushDelegationRequest; + +use crate::commands::delegation::load_for_delegate; +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_success, print_warning}; + +use super::{ + humanize_remaining, parse_duration, parse_patterns, parse_permissions, DEFAULT_EXPIRY_DAYS, +}; + +/// Issue a fresh certificate for an existing agent identity. +#[derive(Debug, Parser)] +pub struct Renew { + /// Agent identity name. + #[arg(required = true)] + pub name: String, + + /// New lifetime (`30d`, `12h`, `2w`). + #[arg(long)] + pub expires: Option, + + /// Replace the permission list. Omit to carry the current one forward. + #[arg(long)] + pub can: Option, + + /// Replace the project patterns. Omit to carry them forward. + #[arg(long)] + pub projects: Option, + + /// Server profile to publish to, with `--publish`. + #[arg(long)] + pub server: Option, + + /// Also publish the new grant so it appears in server listings. + /// + /// Optional and off by default: a grant works the moment it is signed, and + /// publishing only makes the server able to *show* it. + #[arg(long)] + pub publish: bool, +} + +impl Command for Renew { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Renew { + async fn execute(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let agent = store + .load_by_name(&self.name) + .map_err(|_| CliError::IdentityNotFound(self.name.clone()))?; + + // Renew from the newest certificate even when it is expired or revoked + // — its scope is the record of what was intended, and expiry is the + // very thing being fixed. + let previous = load_for_delegate(&store, &agent)? + .into_iter() + .next() + .ok_or_else(|| CliError::DelegationError { + message: format!( + "'{}' has no certificate to renew.\n Issue one with: atomic identity delegate {}", + self.name, self.name + ), + })?; + + let delegator = store + .load_by_name(&previous.delegation.delegator_name) + .map_err(|_| CliError::DelegationError { + message: format!( + "The delegating identity '{}' is not on this machine, so a renewal cannot \ + be signed here.\n Renew from the machine holding that key.", + previous.delegation.delegator_name + ), + })?; + + // Guard against a name collision resolving to a different key than the + // one that signed the original. + if delegator.id.to_did() != previous.delegation.delegator { + return Err(CliError::DelegationError { + message: format!( + "Identity '{}' on this machine is not the key that issued the current \ + certificate.\n Renew from the machine holding {}.", + previous.delegation.delegator_name, previous.delegation.delegator + ), + }); + } + + let scope = self.next_scope(&previous.delegation.scope)?; + let expires = self + .expires + .as_deref() + .map(parse_duration) + .transpose()? + .unwrap_or_else(|| Duration::days(DEFAULT_EXPIRY_DAYS)); + + let mut terms = Delegation::new(&delegator, &agent, scope).expires_in(expires); + if let Some(agent_urn) = &previous.delegation.software_agent { + terms = terms.with_software_agent(agent_urn.clone()); + } + + let keypair = store.load_keypair(&delegator.id, None).map_err(|e| { + CliError::Internal(anyhow::anyhow!( + "Failed to load the signing key for '{}': {e}", + delegator.name + )) + })?; + let certificate = cert::mint(&delegator, &keypair, &terms); + + let document = serde_json::to_string_pretty(&certificate).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to encode certificate: {e}")) + })?; + store + .save_delegation(&terms.id.to_base32(), &document) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to store certificate: {e}")))?; + + print_success(&format!("Renewed delegation for {}", agent.name)); + println!(); + println!(" Delegation {}", terms.id.to_urn()); + println!( + " Can {}", + terms + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + if let Some(expiry) = terms.expires { + println!( + " Expires {} ({})", + expiry.format("%Y-%m-%d"), + humanize_remaining(terms.time_remaining()) + ); + } + println!(" Key unchanged — nothing to re-enroll"); + println!(" Ready to use no server call needed"); + + if self.publish { + self.publish_grant(&delegator, &certificate).await?; + } + + Ok(()) + } + + /// Carry the previous scope forward, replacing only what was named. + fn next_scope(&self, previous: &DelegationScope) -> CliResult { + let mut scope = previous.clone(); + if let Some(can) = &self.can { + scope.permissions = parse_permissions(can)?; + } + if let Some(projects) = &self.projects { + scope.projects = parse_patterns(projects); + } + Ok(scope) + } + + /// Publish for visibility. Never required for the grant to work. + async fn publish_grant( + &self, + delegator: &Identity, + certificate: &serde_json::Value, + ) -> CliResult<()> { + let (client, url) = + crate::commands::client::build_apex_client_as(delegator, self.server.as_deref()) + .await?; + + let request = PushDelegationRequest { + certificate: certificate.clone(), + }; + match client.push_delegation(&request).await { + Ok(_) => { + println!(" Published to {url}"); + Ok(()) + } + Err(e) => { + // Publishing is cosmetic — the grant is already signed and + // usable — so a server that will not take it is a warning, not + // something that should discard the renewal. + print_warning(&format!( + "Renewed, but {url} would not list it: {e}\n \ + The grant still works; only the listing is missing.\n \ + Retry with: atomic identity grant publish" + )); + Ok(()) + } + } + } +} diff --git a/atomic-cli/src/commands/identity/agent/revoke.rs b/atomic-cli/src/commands/identity/agent/revoke.rs new file mode 100644 index 00000000..f955d4d7 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/revoke.rs @@ -0,0 +1,250 @@ +//! `atomic identity agent revoke` — withdraw an agent's authority. +//! +//! Because grants are presented rather than registered, the server holds no +//! list of what this agent has been issued — so revoking one certificate at a +//! time cannot be the whole story. Two things happen: +//! +//! 1. **The epoch is bumped.** Every grant issued to this agent before now is +//! dead, including ones this machine has never seen. This is the part that +//! actually stops the agent, and the only mechanism that covers grants +//! issued from a laptop you no longer have. +//! 2. **Known grants are deny-listed**, with a signed revocation each, so the +//! withdrawal is individually auditable and provable rather than a bare +//! timestamp. +//! +//! The identity is kept. Deleting it would orphan the attribution on every +//! change the agent already recorded, turning a clean audit trail into a set of +//! unresolvable keys. +//! +//! Unlike issuing, revoking **must** reach the server. A credential the holder +//! possesses cannot prove its own withdrawal, so this is the one operation +//! where a failed network call leaves real work outstanding — and it says so. + +use clap::Parser; + +use atomic_canonical::delegation as cert; +use atomic_identity::IdentityStore; +use atomic_remote::storage_types::RevokeDelegationRequest; + +use crate::commands::delegation::load_for_delegate; +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_success, print_warning}; + +/// Revoke an agent's delegation. +#[derive(Debug, Parser)] +pub struct Revoke { + /// Agent identity name. + #[arg(required = true)] + pub name: String, + + /// Why, recorded on the signed revocation. + #[arg(long)] + pub reason: Option, + + /// Also delete the agent's secret key from this machine. + /// + /// The identity record and its history are kept either way. + #[arg(long)] + pub retire: bool, + + /// Server profile to notify. + #[arg(long)] + pub server: Option, + + /// Revoke locally without contacting a server. + /// + /// Stops *this machine* from using the agent. The server keeps honouring + /// every outstanding grant until told, so this is a stopgap, not a + /// revocation. + #[arg(long)] + pub local: bool, +} + +impl Command for Revoke { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Revoke { + async fn execute(&self) -> CliResult<()> { + let mut store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let agent = store + .load_by_name(&self.name) + .map_err(|_| CliError::IdentityNotFound(self.name.clone()))?; + + let delegations = load_for_delegate(&store, &agent)?; + if delegations.is_empty() { + return Err(CliError::DelegationError { + message: format!("'{}' has no delegation to revoke", self.name), + }); + } + + // Revoke every live certificate, not just the newest. Leaving an older + // one standing would make revocation look done while the agent kept + // working under a certificate nobody was looking at. + let mut revoked = Vec::new(); + for resolved in delegations.iter().filter(|d| d.is_usable()) { + let delegator = store + .load_by_name(&resolved.delegation.delegator_name) + .map_err(|_| CliError::DelegationError { + message: format!( + "The delegating identity '{}' is not on this machine, so a signed \ + revocation cannot be produced here.", + resolved.delegation.delegator_name + ), + })?; + let keypair = store.load_keypair(&delegator.id, None).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to load signing key: {e}")) + })?; + + let revocation = cert::mint_revocation( + &delegator, + &keypair, + &resolved.delegation.id, + self.reason.as_deref(), + ); + let document = serde_json::to_string_pretty(&revocation).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to encode revocation: {e}")) + })?; + + // Local first: this machine stops using the delegation immediately, + // even if the network call below fails. + store + .save_revocation(&resolved.id(), &document) + .map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to record revocation: {e}")) + })?; + + revoked.push((resolved.delegation.id.to_urn(), delegator, revocation)); + } + + if revoked.is_empty() { + print_hint(&format!( + "'{}' has no active delegation — nothing to revoke.", + self.name + )); + return Ok(()); + } + + print_success(&format!( + "Revoked {} delegation(s) for {}", + revoked.len(), + agent.name + )); + for (urn, _, _) in &revoked { + println!(" {urn}"); + } + if let Some(reason) = &self.reason { + println!(" Reason {reason}"); + } + + if !self.local { + self.notify_server(&agent, &revoked).await; + } + + if self.retire { + match store.delete(&agent.id) { + Ok(()) => println!(" Key deleted from this machine"), + Err(e) => print_warning(&format!("Could not delete the agent key: {e}")), + } + } + + println!(); + print_hint("Past changes stay attributable — the identity and its history are kept."); + + Ok(()) + } + + /// Tell the server: bump the epoch, then deny-list each known grant. + /// + /// The epoch first, because it is the part that actually stops the agent + /// and covers grants nobody has a copy of. Deny-listing the ones we do know + /// is the audit trail on top. + async fn notify_server( + &self, + agent: &atomic_identity::Identity, + revoked: &[(String, atomic_identity::Identity, serde_json::Value)], + ) { + let Some((_, delegator, _)) = revoked.first() else { + return; + }; + + let Ok((client, url)) = + crate::commands::client::build_apex_client_as(delegator, self.server.as_deref()).await + else { + print_warning( + "Revoked locally, but no server could be reached. Outstanding grants stay \ + valid until the server is told.\n \ + Retry with: atomic identity agent revoke ", + ); + return; + }; + + match self.bump_epoch(&client, agent).await { + Ok(()) => println!(" Epoch bumped {url} — every outstanding grant is now dead"), + Err(e) => print_warning(&format!( + "Could not bump the epoch at {url}: {e}\n \ + Grants this machine has never seen REMAIN VALID until it succeeds.\n \ + Retry with: atomic identity agent revoke {}", + agent.name + )), + } + + for (urn, _, revocation) in revoked { + let request = RevokeDelegationRequest { + revocation: revocation.clone(), + }; + match client.revoke_delegation(urn, &request).await { + Ok(_) => println!(" Deny-listed {urn}"), + Err(e) => print_warning(&format!("Could not deny-list {urn}: {e}")), + } + } + } + + /// Find the agent's server-side id and bump its epoch. + /// + /// The lookup is by DID rather than name: names are not unique across + /// machines, and bumping the epoch on the wrong agent would silently do + /// nothing while reporting success. + async fn bump_epoch( + &self, + client: &atomic_remote::StorageClient, + agent: &atomic_identity::Identity, + ) -> CliResult<()> { + let did = agent.id.to_did(); + let agents = client + .list_agents() + .await + .map_err(|e| CliError::RemoteError { + message: format!("Could not list agents: {e}"), + url: None, + })?; + + let found = agents + .iter() + .find(|a| a.did == did) + .ok_or_else(|| CliError::RemoteError { + message: format!( + "'{}' is not enrolled with this server, so there is no epoch to bump", + agent.name + ), + url: None, + })?; + + client + .set_agent_epoch(&found.id.to_string()) + .await + .map(|_| ()) + .map_err(|e| CliError::RemoteError { + message: e.to_string(), + url: None, + }) + } +} diff --git a/atomic-cli/src/commands/identity/agent/show.rs b/atomic-cli/src/commands/identity/agent/show.rs new file mode 100644 index 00000000..7c1a56f7 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/show.rs @@ -0,0 +1,165 @@ +//! `atomic identity agent show` — one agent in full. + +use clap::Parser; + +use atomic_identity::IdentityStore; + +use crate::commands::delegation::load_for_delegate; +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_warning}; + +use super::humanize_remaining; + +/// Show an agent identity and its delegations. +#[derive(Debug, Parser)] +pub struct Show { + /// Agent identity name (e.g. `alice+claude`). + #[arg(required = true)] + pub name: String, + + /// Show every certificate, not only the one in force. + #[arg(long)] + pub history: bool, + + /// Print the signed certificate as JSON. + #[arg(long)] + pub certificate: bool, +} + +impl Command for Show { + fn run(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let identity = store + .load_by_name(&self.name) + .map_err(|_| CliError::IdentityNotFound(self.name.clone()))?; + + if !identity.identity_type.is_delegated() && !identity.identity_type.is_agent() { + return Err(CliError::InvalidArgument { + message: format!( + "'{}' is not an agent identity.\n Use 'atomic identity show {}' instead.", + self.name, self.name + ), + }); + } + + let delegations = load_for_delegate(&store, &identity)?; + + if self.certificate { + let Some(newest) = delegations.first() else { + return Err(CliError::DelegationError { + message: format!("No certificate stored for '{}'", self.name), + }); + }; + println!( + "{}", + serde_json::to_string_pretty(&newest.document).unwrap() + ); + return Ok(()); + } + + println!("Agent: {}", identity.name); + println!(); + println!(" DID {}", identity.id.to_did()); + println!(" Key {}", identity.public_key.to_did_key()); + if let Some(email) = &identity.email { + println!(" Email {email}"); + } + println!( + " Type {}", + super::super::format_identity_type(&identity.identity_type) + ); + + if delegations.is_empty() { + println!(); + print_warning("No delegation certificate — this key can prove who it is, but is authorized for nothing."); + print_hint(&format!( + "Issue one: atomic identity delegate {}", + identity.name + )); + return Ok(()); + } + + let shown = if self.history { + &delegations[..] + } else { + &delegations[..1] + }; + + for resolved in shown { + let d = &resolved.delegation; + println!(); + println!(" Delegation {}", d.id.to_urn()); + println!(" Status {}", resolved.status); + println!(" From {} ({})", d.delegator_name, d.delegator); + if let Some(agent) = &d.software_agent { + println!(" Software {agent}"); + } + println!( + " Can {}", + d.scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + println!(" Servers {}", or_all(&d.scope.servers)); + println!(" Workspaces {}", or_all(&d.scope.workspaces)); + println!(" Projects {}", or_all(&d.scope.projects)); + println!(" Views {}", or_all(&d.scope.views)); + if let Some(max) = d.scope.max_changes { + println!(" Change cap {max}"); + } + println!(" Issued {}", d.issued.format("%Y-%m-%d %H:%M UTC")); + match d.expires { + Some(expires) => println!( + " Expires {} ({})", + expires.format("%Y-%m-%d %H:%M UTC"), + humanize_remaining(d.time_remaining()) + ), + None => println!(" Expires never"), + } + } + + if !self.history && delegations.len() > 1 { + println!(); + print_hint(&format!( + "{} older certificate(s) not shown — use --history", + delegations.len() - 1 + )); + } + + println!(); + print_hint( + "Scope narrows; it never widens. This agent's effective access is whatever \ + the delegator has, intersected with the scope above.", + ); + + Ok(()) + } +} + +/// An empty pattern list means unrestricted, which is worth saying explicitly +/// rather than rendering as a blank the reader has to interpret. +fn or_all(items: &[String]) -> String { + if items.is_empty() { + "(all)".to_string() + } else { + items.join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_scope_dimensions_say_all() { + assert_eq!(or_all(&[]), "(all)"); + assert_eq!(or_all(&["acme/api".to_string()]), "acme/api"); + } +} diff --git a/atomic-cli/src/commands/identity/delegate.rs b/atomic-cli/src/commands/identity/delegate.rs new file mode 100644 index 00000000..65b02dcd --- /dev/null +++ b/atomic-cli/src/commands/identity/delegate.rs @@ -0,0 +1,390 @@ +//! `atomic identity grant new` — issue a grant. +//! +//! **This is the operation you run often.** Issuing a grant is you signing a +//! document: no server round trip, nothing to register, nothing to wait for. +//! That is what makes narrow, short-lived grants the cheap default rather than +//! a chore — a four-hour grant scoped to one project costs exactly as much as a +//! year-long one scoped to everything, so there is no reason to reach for the +//! latter. +//! +//! The output is designed to be handed to an agent, by hand or by script: +//! +//! ```text +//! atomic identity grant new alice+claude --can record,push --expires 4h +//! atomic identity grant new alice+claude --expires 1h --export # base64, for $ATOMIC_DELEGATION +//! atomic identity grant new alice+claude --expires 1h -o grant.json +//! ``` +//! +//! `--request` covers the case where the agent generated its own key somewhere +//! you will never see it — a CI runner, a hosted agent. The request is +//! self-signed by that key, proving the far end really holds it, so you are not +//! talked into granting to a key nobody has. + +use std::path::PathBuf; + +use clap::Parser; + +use atomic_canonical::delegation as cert; +use atomic_identity::delegation::{Delegation, DelegationScope}; +use atomic_identity::{Identity, IdentityStore, IdentityType}; + +use crate::commands::identity::agent::{ + parse_duration, parse_patterns, parse_permissions, software_agent_urn, DEFAULT_EXPIRY_DAYS, +}; +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_success}; + +/// Issue a grant to an agent. +#[derive(Debug, Parser)] +pub struct Delegate { + /// Agent identity to grant to. Omit when using `--request`. + pub agent: Option, + + /// Countersign a self-signed `AgentDelegationRequest` from a file. + /// + /// Use `-` to read the request from stdin. + #[arg(long, conflicts_with = "agent")] + pub request: Option, + + /// Permissions, comma-separated. + #[arg(long, default_value = "read,record,push")] + pub can: String, + + /// Project patterns, comma-separated globs. + #[arg(long)] + pub projects: Option, + + /// Workspace patterns, comma-separated globs. + #[arg(long)] + pub workspaces: Option, + + /// View patterns, comma-separated globs. + #[arg(long)] + pub views: Option, + + /// Bind the grant to a server URL. Repeat for several. + /// + /// Defaults to the active server profile, so a grant is bound to the + /// deployment you are working against without having to say so. A grant + /// minted for staging must not authenticate against production, and an + /// unbound grant is valid everywhere — so binding has to be what happens + /// when you say nothing. + #[arg(long = "server-url")] + pub server_urls: Vec, + + /// Issue a grant valid against **any** server. + /// + /// Only for a grant you genuinely intend to be portable across + /// deployments. It removes the check that stops a staging grant working + /// against production. + #[arg(long, conflicts_with = "server_urls")] + pub any_server: bool, + + /// Server profile whose URL the grant is bound to. + #[arg(long)] + pub server: Option, + + /// Lifetime (`30d`, `12h`, `2w`). + #[arg(long)] + pub expires: Option, + + /// Cap the number of changes the agent may create. + #[arg(long)] + pub max_changes: Option, + + /// Delegating identity. Defaults to your default identity. + #[arg(short, long)] + pub identity: Option, + + /// Write the grant here instead of storing it locally. + /// + /// The right choice when countersigning a request: the grant belongs on the + /// requesting machine, not this one. + #[arg(short, long)] + pub output: Option, + + /// Print the grant in its wire form — base64url, one line, nothing else. + /// + /// The automation path. Pipe it straight into the environment variable the + /// agent reads, with no file to place or clean up: + /// + /// ```text + /// export ATOMIC_DELEGATION=$(atomic identity grant new alice+claude --expires 1h --export) + /// ``` + /// + /// Implies `--quiet`: nothing but the grant reaches stdout, so command + /// substitution captures exactly the value and not a banner with it. + #[arg(long, conflicts_with = "output")] + pub export: bool, + + /// Suppress the human-readable summary. + #[arg(long)] + pub quiet: bool, +} + +impl Command for Delegate { + fn run(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let delegator = super::load_identity_or_default(&store, self.identity.as_deref())?; + if delegator.identity_type.is_delegated() || delegator.identity_type.is_agent() { + return Err(CliError::InvalidArgument { + message: format!("'{}' is an agent and cannot delegate", delegator.name), + }); + } + + // Resolve the delegate: either an identity in the local store, or the + // subject of a countersigned request. + let delegate = match (&self.request, &self.agent) { + (Some(path), _) => self.delegate_from_request(path)?, + (None, Some(name)) => store + .load_by_name(name) + .map_err(|_| CliError::IdentityNotFound(name.clone()))?, + (None, None) => { + return Err(CliError::InvalidArgument { + message: "name an agent identity, or pass --request to countersign \ + a request" + .to_string(), + }) + } + }; + + let scope = self.build_scope()?; + let expires = self + .expires + .as_deref() + .map(parse_duration) + .transpose()? + .unwrap_or_else(|| chrono::Duration::days(DEFAULT_EXPIRY_DAYS)); + + let mut terms = Delegation::new(&delegator, &delegate, scope).expires_in(expires); + if let Some(agent_urn) = delegate + .metadata + .description + .as_deref() + .and_then(software_agent_from_description) + { + terms = terms.with_software_agent(agent_urn); + } + + let keypair = store.load_keypair(&delegator.id, None).map_err(|e| { + CliError::Internal(anyhow::anyhow!( + "Failed to load the signing key for '{}': {e}", + delegator.name + )) + })?; + let certificate = cert::mint(&delegator, &keypair, &terms); + let document = serde_json::to_string_pretty(&certificate).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to encode certificate: {e}")) + })?; + + // `--export` writes the wire form and nothing else, so the output is + // safe to capture in a shell substitution. + if self.export { + println!("{}", cert::encode_for_transport(&certificate)); + if !self.quiet { + eprintln!( + "Issued {} to {} ({})", + terms.id.to_urn(), + delegate.name, + terms + .expires + .map(|e| format!("expires {}", e.format("%Y-%m-%d %H:%M UTC"))) + .unwrap_or_else(|| "no expiry".to_string()) + ); + } + return Ok(()); + } + + match &self.output { + Some(path) if path.as_os_str() == "-" => { + println!("{document}"); + return Ok(()); + } + Some(path) => { + std::fs::write(path, &document)?; + print_success(&format!("Wrote certificate to {}", path.display())); + println!(" Delegation {}", terms.id.to_urn()); + println!(); + print_hint("Install it on the agent's machine: atomic identity grant load "); + } + None => { + store + .save_delegation(&terms.id.to_base32(), &document) + .map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to store certificate: {e}")) + })?; + print_success(&format!("Delegated to {}", delegate.name)); + println!(" Delegation {}", terms.id.to_urn()); + println!( + " Can {}", + terms + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + if let Some(expiry) = terms.expires { + println!(" Expires {}", expiry.format("%Y-%m-%d")); + } + println!(); + print_hint( + "Ready to use — grants are presented, not registered, so there is \ + nothing to tell the server.", + ); + } + } + + Ok(()) + } +} + +impl Delegate { + fn build_scope(&self) -> CliResult { + let mut builder = DelegationScope::builder().permissions(parse_permissions(&self.can)?); + + for url in self.bound_servers()? { + builder = builder.server(url); + } + for pattern in self + .projects + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.project(pattern); + } + for pattern in self + .workspaces + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.workspace(pattern); + } + for pattern in self + .views + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.view(pattern); + } + if let Some(max) = self.max_changes { + builder = builder.max_changes(max); + } + Ok(builder.build()) + } + + /// Which servers this grant is valid against. + /// + /// Explicit `--server-url` wins; otherwise the active profile, so the safe + /// thing happens by default. `--any-server` is the deliberate opt-out, and + /// an unresolvable profile is *not* silently treated as "any" — that would + /// turn a misconfiguration into a grant broader than anyone asked for. + fn bound_servers(&self) -> CliResult> { + if self.any_server { + return Ok(Vec::new()); + } + if !self.server_urls.is_empty() { + return Ok(self.server_urls.clone()); + } + + match crate::commands::client::resolve_apex_url(self.server.as_deref()) { + Ok(url) => Ok(vec![url]), + Err(_) => Err(CliError::InvalidArgument { + message: "No server configured, so this grant cannot be bound to one.\n \ + Pass --server-url , or --any-server if you really mean a \ + grant valid against every deployment." + .to_string(), + }), + } + } + + /// Verify a self-signed request and turn it into a delegate identity. + /// + /// The verification is the point: it proves whoever produced the request + /// holds the private half of the key it names. Without it, `--request` + /// would be a way to talk someone into signing a certificate for a key + /// chosen by an attacker. + fn delegate_from_request(&self, path: &str) -> CliResult { + let raw = if path == "-" { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + buf + } else { + std::fs::read_to_string(path)? + }; + + let value: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| CliError::InvalidArgument { + message: format!("{path} is not valid JSON: {e}"), + })?; + + let request = cert::verify_request(&value).map_err(|e| CliError::DelegationError { + message: format!( + "The request in {path} does not verify: {e}\n \ + Only countersign a request whose self-signature checks out — it is the \ + only evidence the far end actually holds that key." + ), + })?; + + let public_key = + atomic_identity::PublicKey::from_did_key(&request.delegate_key).map_err(|e| { + CliError::DelegationError { + message: format!("Request carries a malformed key: {e}"), + } + })?; + + print_hint(&format!( + "Countersigning a verified request from '{}' ({})", + request.delegate_name, request.delegate + )); + + let mut builder = Identity::builder(&request.delegate_name) + .identity_type(IdentityType::Agent) + .public_key(public_key); + if let Some(agent) = &request.software_agent { + builder = builder.description(format!("software-agent:{agent}")); + } + builder + .build() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to build delegate: {e}"))) + } +} + +/// Recover the software-agent URN we stash in an identity description. +fn software_agent_from_description(description: &str) -> Option { + description + .strip_prefix("software-agent:") + .map(str::to_string) + .or_else(|| { + description + .strip_prefix("agent-type:") + .map(software_agent_urn) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn software_agent_is_recovered_from_the_description_marker() { + assert_eq!( + software_agent_from_description("software-agent:urn:atomic:agent:ci"), + Some("urn:atomic:agent:ci".to_string()) + ); + assert_eq!( + software_agent_from_description("agent-type:Claude-Code"), + Some("urn:atomic:agent:claude-code".to_string()) + ); + assert_eq!(software_agent_from_description("just a description"), None); + } +} diff --git a/atomic-cli/src/commands/identity/delegation.rs b/atomic-cli/src/commands/identity/delegation.rs new file mode 100644 index 00000000..121e24b2 --- /dev/null +++ b/atomic-cli/src/commands/identity/delegation.rs @@ -0,0 +1,651 @@ +//! `atomic identity grant` — managing grants after they are issued. +//! +//! Load one that was issued elsewhere, list what this machine holds, verify +//! one, revoke one, or publish one for visibility. +//! +//! # Loading is the common one +//! +//! A grant is issued on the human's machine and used on the agent's. Three ways +//! across, in rough order of how often you will want them: +//! +//! | | | +//! |---|---| +//! | `ATOMIC_DELEGATION=` | nothing to install; the automation path | +//! | `atomic identity grant load ` | a file dropped on the agent's machine | +//! | `atomic identity grant load -` | piped in over stdin | +//! +//! # Publishing is optional +//! +//! `publish` sends a grant to the server so it shows up in listings. It is +//! **not** required to use one: the server verifies a presented grant against +//! your registered key, so a grant works the moment you sign it. Publish when +//! you want a dashboard to know, not to make something function. + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +use atomic_canonical::delegation as cert; +use atomic_identity::delegation::DelegationId; +use atomic_identity::IdentityStore; +use atomic_remote::storage_types::{PushDelegationRequest, RevokeDelegationRequest}; + +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_success, print_warning}; + +/// Grant management. +#[derive(Debug, clap::Args)] +pub struct DelegationCmd { + #[command(subcommand)] + pub command: DelegationCommands, +} + +/// Available delegation subcommands. +#[derive(Debug, Subcommand)] +pub enum DelegationCommands { + /// Issue a grant to an agent. The operation you run often. + /// + /// Boxed because it carries every scope flag and dwarfs the other variants + /// — unboxed, each of them would pay for its size. + New(Box), + /// Load a grant issued on another machine. + #[command(alias = "install")] + Load(Install), + /// Publish a grant so it appears in server listings. + /// + /// Optional: a grant works without this. Grants are presented with each + /// request and verified against your registered key, so the server needs no + /// advance notice. + #[command(alias = "push")] + Publish(Push), + /// List grants held on this machine. + List(List), + /// Verify a grant's proof, expiry and revocation. + Verify(Verify), + /// Revoke a grant by id, or every grant you have issued. + Revoke(Revoke), +} + +impl Command for DelegationCmd { + fn run(&self) -> CliResult<()> { + match &self.command { + DelegationCommands::New(c) => c.run(), + DelegationCommands::Load(c) => c.run(), + DelegationCommands::Publish(c) => c.run(), + DelegationCommands::List(c) => c.run(), + DelegationCommands::Verify(c) => c.run(), + DelegationCommands::Revoke(c) => c.run(), + } + } +} + +// --------------------------------------------------------------------------- +// install +// --------------------------------------------------------------------------- + +/// Load a grant from a file or stdin. +#[derive(Debug, Parser)] +pub struct Install { + /// Path to the grant, or `-` for stdin. + #[arg(required = true)] + pub path: String, +} + +impl Command for Install { + fn run(&self) -> CliResult<()> { + let raw = read_input(&self.path)?; + let value: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| CliError::InvalidArgument { + message: format!("{} is not valid JSON: {e}", self.path), + })?; + + // Verify before storing. A certificate that does not verify is not + // something to keep "in case" — it would be silently skipped at use + // time and leave the operator wondering why the agent has no authority. + let delegation = + cert::verify_self_contained(&value).map_err(|e| CliError::DelegationError { + message: format!("Refusing to install: the certificate does not verify: {e}"), + })?; + + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + store + .save_delegation(&delegation.id.to_base32(), &raw) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to store certificate: {e}")))?; + + print_success(&format!("Installed {}", delegation.id.to_urn())); + println!(" Delegate {}", delegation.delegate_name); + println!(" From {}", delegation.delegator_name); + println!( + " Can {}", + delegation + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + if let Some(expires) = delegation.expires { + println!(" Expires {}", expires.format("%Y-%m-%d")); + } + + println!(); + print_hint( + "Ready to use. The signature checks out, which proves the grant was not \ + altered; that the delegator is who you think is settled by the server's \ + registered key when you use it.", + ); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// push +// --------------------------------------------------------------------------- + +/// Upload a certificate to a server. +#[derive(Debug, Parser)] +pub struct Push { + /// Delegation id (base32 or `urn:atomic:delegation:...`). + /// + /// Omit to push every locally held certificate that is still active. + pub id: Option, + + /// Server profile to push to. + #[arg(long)] + pub server: Option, +} + +impl Command for Push { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Push { + async fn execute(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let documents = match &self.id { + Some(id) => vec![(normalize_id(id)?, load_document(&store, id)?)], + None => store + .list_delegations() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to list: {e}")))?, + }; + + if documents.is_empty() { + print_hint("No certificates to push."); + return Ok(()); + } + + let mut pushed = 0; + for (id, raw) in documents { + let Ok(value) = serde_json::from_str::(&raw) else { + print_warning(&format!("Skipping {id}: not valid JSON")); + continue; + }; + let Ok(delegation) = cert::verify_self_contained(&value) else { + print_warning(&format!("Skipping {id}: does not verify")); + continue; + }; + if delegation.is_expired() { + continue; + } + + // The delegator must be on this machine — the server authenticates + // the *human*, since only they may enroll on their own behalf. + let Ok(delegator) = store.load_by_name(&delegation.delegator_name) else { + print_warning(&format!( + "Skipping {id}: the delegating identity '{}' is not on this machine", + delegation.delegator_name + )); + continue; + }; + + let (client, url) = + crate::commands::client::build_apex_client_as(&delegator, self.server.as_deref()) + .await?; + let request = PushDelegationRequest { certificate: value }; + match client.push_delegation(&request).await { + Ok(_) => { + println!(" {} → {url}", delegation.id.to_urn()); + pushed += 1; + } + Err(e) => print_warning(&format!("Failed to push {id}: {e}")), + } + } + + if pushed > 0 { + print_success(&format!("Pushed {pushed} certificate(s)")); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// list +// --------------------------------------------------------------------------- + +/// List locally held certificates. +#[derive(Debug, Parser)] +pub struct List { + /// Include expired and revoked certificates. + #[arg(long)] + pub include_expired: bool, +} + +impl Command for List { + fn run(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let stored = store + .list_delegations() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to list: {e}")))?; + + let mut any = false; + for (id, raw) in stored { + let Ok(value) = serde_json::from_str::(&raw) else { + continue; + }; + // Show what parses even if it does not verify — a certificate that + // fails verification is exactly what the operator needs to see. + let Ok(delegation) = cert::parse(&value) else { + continue; + }; + let verifies = cert::verify_self_contained(&value).is_ok(); + let status = if !verifies { + "INVALID".to_string() + } else if store.is_revoked_locally(&id) { + "revoked".to_string() + } else { + delegation.status().to_string() + }; + + if !self.include_expired && status != "active" { + continue; + } + + if !any { + println!( + "{:<28} {:<22} {:<22} STATUS", + "DELEGATION", "DELEGATE", "DELEGATOR" + ); + any = true; + } + println!( + "{:<28} {:<22} {:<22} {}", + delegation.id.short(), + delegation.delegate_name, + delegation.delegator_name, + status + ); + } + + if !any { + println!("No delegation certificates."); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// verify +// --------------------------------------------------------------------------- + +/// Verify a certificate. +#[derive(Debug, Parser)] +pub struct Verify { + /// Delegation id, or a path to a certificate file. + #[arg(required = true)] + pub target: String, + + /// Skip the revocation check, which is the only step needing a network. + #[arg(long)] + pub offline: bool, + + /// Server profile to check revocation against. + #[arg(long)] + pub server: Option, +} + +impl Command for Verify { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Verify { + async fn execute(&self) -> CliResult<()> { + let path = PathBuf::from(&self.target); + let raw = if path.exists() { + std::fs::read_to_string(&path)? + } else { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + load_document(&store, &self.target)? + }; + + let value: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| CliError::InvalidArgument { + message: format!("not valid JSON: {e}"), + })?; + + let delegation = match cert::verify_self_contained(&value) { + Ok(d) => { + println!("✓ Proof valid signed by {}", d.delegator); + println!("✓ Delegate key matches {}", d.delegate); + d + } + Err(e) => { + return Err(CliError::DelegationError { + message: format!("✗ Verification failed: {e}"), + }) + } + }; + + if delegation.is_expired() { + return Err(CliError::DelegationError { + message: format!( + "✗ Expired on {}", + delegation + .expires + .map(|e| e.format("%Y-%m-%d").to_string()) + .unwrap_or_default() + ), + }); + } + match delegation.time_remaining() { + Some(remaining) => println!( + "✓ Not expired {} days remaining", + remaining.num_days() + ), + None => { + println!("⚠ No expiry an agent key with no expiry is a standing risk") + } + } + + if self.offline { + println!("- Revocation not checked (--offline)"); + } else { + self.check_revocation(&delegation).await; + } + + println!( + " Scope {} on {}", + delegation + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", "), + if delegation.scope.projects.is_empty() { + "(all projects)".to_string() + } else { + delegation.scope.projects.join(", ") + } + ); + + Ok(()) + } + + async fn check_revocation(&self, delegation: &atomic_identity::delegation::Delegation) { + let store = match IdentityStore::open_default() { + Ok(s) => s, + Err(_) => return, + }; + if store.is_revoked_locally(&delegation.id.to_base32()) { + println!("✗ Revoked recorded locally"); + return; + } + + let Ok(delegator) = store.load_by_name(&delegation.delegator_name) else { + println!("- Revocation not checked (delegator not on this machine)"); + return; + }; + let Ok((client, url)) = + crate::commands::client::build_apex_client_as(&delegator, self.server.as_deref()).await + else { + println!("- Revocation not checked (no server reachable)"); + return; + }; + + match client.delegation_status(&delegation.id.to_urn()).await { + Ok(status) if status.revoked => { + println!("✗ Revoked {url} reports this grant revoked") + } + Ok(_) => println!("✓ Not revoked (checked {url})"), + Err(e) => println!("- Revocation not checked ({e})"), + } + } +} + +// --------------------------------------------------------------------------- +// revoke +// --------------------------------------------------------------------------- + +/// Revoke a grant by id, or every grant you have issued. +#[derive(Debug, Parser)] +pub struct Revoke { + /// Grant id (base32 or URN). Omit when using `--all-mine`. + #[arg(required_unless_present = "all_mine")] + pub id: Option, + + /// Invalidate **every** grant you have ever issued, to every agent. + /// + /// The key-compromise button. If your signing key leaks, an attacker can + /// mint grants the server has never seen and there is no list to revoke — + /// this is the one action that reaches them, because it works on time + /// rather than on identifiers. + /// + /// Your agents stop working until you issue fresh grants. That is the + /// intended effect. + #[arg(long, conflicts_with = "id")] + pub all_mine: bool, + + /// Reason, recorded on the signed revocation. + #[arg(long)] + pub reason: Option, + + /// Server profile to notify. + #[arg(long)] + pub server: Option, + + /// Revoke locally without contacting a server. + #[arg(long)] + pub local: bool, +} + +impl Command for Revoke { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Revoke { + async fn execute(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + if self.all_mine { + return self.revoke_everything(&store).await; + } + + let raw_id = self + .id + .as_deref() + .expect("clap requires one of id/--all-mine"); + let id = normalize_id(raw_id)?; + let raw = load_document(&store, raw_id)?; + let value: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| CliError::InvalidArgument { + message: format!("stored grant is not valid JSON: {e}"), + })?; + let delegation = cert::parse(&value).map_err(|e| CliError::DelegationError { + message: format!("stored grant is malformed: {e}"), + })?; + + let delegator = store + .load_by_name(&delegation.delegator_name) + .map_err(|_| CliError::DelegationError { + message: format!( + "The delegating identity '{}' is not on this machine, so a signed \ + revocation cannot be produced here.", + delegation.delegator_name + ), + })?; + let keypair = store + .load_keypair(&delegator.id, None) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load signing key: {e}")))?; + + let revocation = + cert::mint_revocation(&delegator, &keypair, &delegation.id, self.reason.as_deref()); + let document = serde_json::to_string_pretty(&revocation) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to encode: {e}")))?; + + // Local first: from here this machine will not present the grant, + // whether or not the network call below succeeds. + store + .save_revocation(&id, &document) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to record revocation: {e}")))?; + + print_success(&format!("Revoked {}", delegation.id.to_urn())); + + if self.local { + print_warning("Local only. The server keeps honouring this grant until it is told."); + return Ok(()); + } + + let (client, url) = + crate::commands::client::build_apex_client_as(&delegator, self.server.as_deref()) + .await?; + let request = RevokeDelegationRequest { + revocation: revocation.clone(), + }; + match client + .revoke_delegation(&delegation.id.to_urn(), &request) + .await + { + Ok(_) => println!(" Deny-listed {url}"), + Err(e) => print_warning(&format!( + "Revoked locally, but {url} did not accept it: {e}\n \ + The grant stays usable against that server until it does.\n \ + Retry with: atomic identity grant revoke {}", + delegation.id.to_urn() + )), + } + + Ok(()) + } + + /// Bump the delegator's epoch: everything they have issued, to anyone, dies. + /// + /// There is nothing to sign per-grant here and nothing to record locally, + /// because the whole point is to reach grants this machine has never seen. + /// It is purely a server-side statement about time, so unlike a targeted + /// revocation it is useless offline — and says so rather than pretending. + async fn revoke_everything(&self, store: &IdentityStore) -> CliResult<()> { + if self.local { + return Err(CliError::InvalidArgument { + message: "--all-mine cannot be done locally: it is a statement the server \ + makes about every grant you have issued, including ones this \ + machine has never seen." + .to_string(), + }); + } + + let delegator = super::load_identity_or_default(store, None)?; + let (client, url) = + crate::commands::client::build_apex_client_as(&delegator, self.server.as_deref()) + .await?; + + let result = client + .set_delegator_epoch() + .await + .map_err(|e| CliError::RemoteError { + message: format!("Could not set the epoch: {e}"), + url: Some(url.clone()), + })?; + + print_success(&format!( + "Every grant issued by '{}' is now invalid at {url}", + delegator.name + )); + if let Some(at) = result.delegations_valid_from { + println!(" Epoch {}", at.format("%Y-%m-%d %H:%M:%S UTC")); + } + println!(); + print_hint( + "Your agents will stop working until you issue fresh grants: \ + atomic identity grant new ", + ); + + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Accept either the base32 id or the full URN. +fn normalize_id(raw: &str) -> CliResult { + DelegationId::from_base32(raw) + .map(|id| id.to_base32()) + .ok_or_else(|| CliError::InvalidArgument { + message: format!("'{raw}' is not a delegation id"), + }) +} + +fn load_document(store: &IdentityStore, raw: &str) -> CliResult { + let id = normalize_id(raw)?; + store + .load_delegation(&id) + .map_err(|_| CliError::DelegationError { + message: format!("No certificate stored under {raw}"), + }) +} + +fn read_input(path: &str) -> CliResult { + if path == "-" { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + Ok(buf) + } else { + Ok(std::fs::read_to_string(path)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_accepted_in_both_renderings() { + let id = DelegationId::from_bytes([3u8; 32]); + assert_eq!(normalize_id(&id.to_base32()).unwrap(), id.to_base32()); + assert_eq!(normalize_id(&id.to_urn()).unwrap(), id.to_base32()); + } + + #[test] + fn a_non_id_is_a_usage_error() { + let err = normalize_id("nonsense!").unwrap_err(); + assert_eq!(err.exit_code(), 2); + } +} diff --git a/atomic-cli/src/commands/identity/mod.rs b/atomic-cli/src/commands/identity/mod.rs index ba3110b2..cf45750d 100644 --- a/atomic-cli/src/commands/identity/mod.rs +++ b/atomic-cli/src/commands/identity/mod.rs @@ -38,6 +38,9 @@ //! atomic identity whoami //! ``` +pub mod agent; +pub mod delegate; +pub mod delegation; pub mod delete; pub mod list; pub mod new; @@ -48,6 +51,9 @@ pub mod verify; pub mod whoami; // Re-export command structs +pub use agent::Agent; +pub use delegate::Delegate; +pub use delegation::DelegationCmd; pub use delete::Delete; pub use list::List; pub use new::New; @@ -235,6 +241,51 @@ pub enum IdentityCommands { /// < file.bin /// ``` Verify(Verify), + + /// Manage agent identities that act on your behalf. + /// + /// An agent gets a keypair of its own and a certificate you sign saying + /// what it may do. Its effective access is always your access intersected + /// with that certificate — an agent can never exceed the human who issued + /// it, so revoking your access revokes the agent's with it. + /// + /// # Examples + /// + /// ```text + /// atomic identity agent create claude --can read,record,push --projects acme/* + /// atomic identity agent list + /// atomic identity agent revoke alice+claude --reason "laptop lost" + /// ``` + #[command(subcommand_help_heading = "Agent identity")] + Agent(Agent), + + /// Issue and manage grants — what an agent is allowed to do, and until when. + /// + /// Issuing a grant is you signing a document: no server round trip, nothing + /// to register. That is what makes short, narrow grants the cheap default. + /// + /// ```text + /// # issue — the operation you run often + /// atomic identity grant new alice+claude --can record,push --expires 4h + /// + /// # hand it to an agent with no file to place + /// export ATOMIC_DELEGATION=$(atomic identity grant new alice+claude --expires 1h --export) + /// + /// # on the agent's machine + /// atomic identity grant load grant.json + /// ``` + #[command(name = "grant", alias = "delegation")] + Grant(DelegationCmd), + + /// Countersign a grant request from a key you do not hold (plumbing). + /// + /// The same as `grant new`, kept under its own name for the request flow: + /// + /// ```text + /// atomic identity delegate --request request.json --can record,push -o grant.json + /// ``` + #[command(hide = true)] + Delegate(Delegate), } impl Command for Identity { @@ -249,6 +300,9 @@ impl Command for Identity { IdentityCommands::Register(cmd) => cmd.run(), IdentityCommands::Sign(cmd) => cmd.run(), IdentityCommands::Verify(cmd) => cmd.run(), + IdentityCommands::Agent(cmd) => cmd.run(), + IdentityCommands::Grant(cmd) => cmd.run(), + IdentityCommands::Delegate(cmd) => cmd.run(), } } } @@ -389,6 +443,69 @@ pub fn activate_server_for_identity(identity_name: &str) { } } +/// Load a named identity, or the store default when no name is given. +/// +/// The one place that decides what "no `--identity`" means, so the agent +/// commands cannot drift from the rest of the CLI on it. +pub fn load_identity_or_default( + store: &atomic_identity::IdentityStore, + name: Option<&str>, +) -> crate::error::CliResult { + use crate::error::CliError; + match name { + Some(name) => store + .load_by_name(name) + .map_err(|_| CliError::IdentityNotFound(name.to_string())), + None => store + .get_default() + .map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to load default identity: {e}")) + })? + .ok_or_else(|| { + CliError::Internal(anyhow::anyhow!( + "No default identity set. Create one first:\n \ + atomic identity new --email --set-default" + )) + }), + } +} + +/// Record an agent identity on a server profile so hooks use it by default. +/// +/// Writes `agent_identity` on the named profile, or on the active one when no +/// name is given. The human binding (`identity`) is left alone: enrollment and +/// revocation still authenticate as the human. +pub fn bind_agent_identity( + server_override: Option<&str>, + agent_name: &str, +) -> crate::error::CliResult<()> { + use crate::error::CliError; + + let mut config = GlobalConfig::load() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load config: {e}")))?; + + let profile = server_override + .map(str::to_string) + .or_else(|| config.default_server.clone()); + + match profile { + Some(name) => match config.servers.get_mut(&name) { + Some(server) => server.agent_identity = Some(agent_name.to_string()), + None => { + return Err(CliError::InvalidArgument { + message: format!("No server profile named '{name}'"), + }) + } + }, + // No named profiles yet — the legacy [server] block is the active one. + None => config.server.agent_identity = Some(agent_name.to_string()), + } + + config + .save() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to save config: {e}"))) +} + /// Format an identity type for display. pub fn format_identity_type(identity_type: &atomic_identity::IdentityType) -> &'static str { match identity_type { diff --git a/atomic-cli/src/commands/identity/register.rs b/atomic-cli/src/commands/identity/register.rs index 7b1988ec..86fb8b98 100644 --- a/atomic-cli/src/commands/identity/register.rs +++ b/atomic-cli/src/commands/identity/register.rs @@ -135,6 +135,23 @@ impl Register { })? }; + // 2b. An agent must never mint a tenant. Registration creates one + // whose slug is the identity name, so letting an agent through + // here would give a delegated key its own top-level namespace — + // the opposite of "an agent can never exceed its human". + if identity.identity_type.is_delegated() || identity.identity_type.is_agent() { + return Err(CliError::InvalidArgument { + message: format!( + "'{}' is an agent identity, and registering creates a tenant.\n \ + Enroll it under a human instead:\n \ + atomic identity agent create \n \ + or, for a key generated elsewhere:\n \ + atomic identity delegation push", + identity.name + ), + }); + } + // 3. Load the keypair (needs the secret key for signing). let keypair = store.load_keypair(&identity.id, None).map_err(|e| { CliError::Internal(anyhow::anyhow!( @@ -367,6 +384,9 @@ fn apply_registration( default_org: Some(slug.to_string()), default_workspaces: std::collections::BTreeMap::new(), identity: Some(identity_name.to_string()), + // Registration binds the human; `atomic identity agent create` + // binds an agent later, if one is ever created for this server. + agent_identity: None, single_tenant, }; config.servers.insert(name.to_string(), profile); diff --git a/atomic-cli/src/commands/intent/list.rs b/atomic-cli/src/commands/intent/list.rs index af768179..7733d7f9 100644 --- a/atomic-cli/src/commands/intent/list.rs +++ b/atomic-cli/src/commands/intent/list.rs @@ -141,6 +141,9 @@ impl Verifies { /// A fully-computed intent row. struct Row { human_key: String, + /// The intent's title, carried into `--json` so machine consumers can + /// name an intent without a second lookup. The table stays id-only. + title: String, status: String, /// Classification tag, read from the manifest `IntentSummary.kind` (no lift). kind: String, @@ -245,6 +248,7 @@ fn compute_row( }; Row { human_key: info.id.clone(), + title: info.title.clone(), status: info.status.clone(), kind, attested, @@ -318,6 +322,10 @@ impl Command for IntentList { .map(|r| { serde_json::json!({ "id": r.human_key, + // Omitted entirely before, so every API consumer + // reported an intent's title as null however well + // the frontmatter named it. + "title": r.title, "status": r.status, "kind": r.kind, "attested": r.attested.json(), diff --git a/atomic-cli/src/commands/intent/new.rs b/atomic-cli/src/commands/intent/new.rs index 12d0dd56..97714519 100644 --- a/atomic-cli/src/commands/intent/new.rs +++ b/atomic-cli/src/commands/intent/new.rs @@ -139,9 +139,17 @@ fn create_intent( ) -> CliResult<(IntentCreateResult, String)> { // Resolve the effective kind + which scaffold to emit. let (kind, scaffold) = if let Some(target) = review_target { + // Emit the canonical URN even when the caller named the target + // bare. A bare ULID reads fine but projects an edge that nothing + // can match, so the review silently fails to cover its target. + let target = if target.trim().starts_with("urn:") { + target.trim().to_string() + } else { + format!("urn:atomic:intent:{}", target.trim()) + }; ( "review".to_string(), - REVIEW_SCAFFOLD.to_string().replace("{target}", target), + REVIEW_SCAFFOLD.to_string().replace("{target}", &target), ) } else { if !is_known_intent_kind(kind) { diff --git a/atomic-cli/src/commands/mod.rs b/atomic-cli/src/commands/mod.rs index 913bdb31..01ea1a24 100644 --- a/atomic-cli/src/commands/mod.rs +++ b/atomic-cli/src/commands/mod.rs @@ -120,6 +120,7 @@ pub mod query; // Storage management commands (always available) pub mod client; +pub mod delegation; pub mod project; pub mod token; pub mod workspace; diff --git a/atomic-cli/src/commands/org/set.rs b/atomic-cli/src/commands/org/set.rs index 8d0fdb5c..899df4a4 100644 --- a/atomic-cli/src/commands/org/set.rs +++ b/atomic-cli/src/commands/org/set.rs @@ -329,6 +329,7 @@ mod tests { default_org: None, default_workspaces: std::collections::BTreeMap::new(), identity: Some("continuouslee".to_string()), + agent_identity: None, single_tenant: false, }, ); diff --git a/atomic-cli/src/commands/server/mod.rs b/atomic-cli/src/commands/server/mod.rs index 8b9e9e5b..b9e0138a 100644 --- a/atomic-cli/src/commands/server/mod.rs +++ b/atomic-cli/src/commands/server/mod.rs @@ -253,6 +253,8 @@ impl ServerCmd { default_org: add.org.clone(), default_workspaces: std::collections::BTreeMap::new(), identity: add.identity.clone(), + // Bound when an agent is created against this profile, not here. + agent_identity: None, // Auto-detected at registration; manual profiles default to // multi-tenant URL semantics. single_tenant: false, diff --git a/atomic-cli/src/commands/token.rs b/atomic-cli/src/commands/token.rs index e49ce559..8ff725cb 100644 --- a/atomic-cli/src/commands/token.rs +++ b/atomic-cli/src/commands/token.rs @@ -14,6 +14,27 @@ //! - claims: `{ sub, iat, exp, jti }` (`sub` mirrors the `kid` public key) //! - signature: `Ed25519_sign(private_key, "header.claims")` //! +//! # Acting on behalf of someone (agent identities) +//! +//! When the identity is a delegated **agent**, the token additionally carries +//! the RFC 8693 actor claim, and the subject *inverts*: +//! +//! ```text +//! kid = the agent's public key (the signer — an agent is the one holding a key) +//! sub = the human's public key (the effective subject: whose access is being used) +//! act = { sub: the agent's public key } (the actual actor) +//! dlg = urn:atomic:delegation:... (which certificate authorizes this) +//! ``` +//! +//! So the server's binding rule is `kid == act.sub` when `act` is present, and +//! `kid == sub` when it is not. `dlg` pins *which* certificate was used when an +//! agent holds several, so the audit record is unambiguous rather than +//! reconstructed by guesswork. +//! +//! A token is only minted once a locally valid certificate has been found, so +//! an expired or out-of-scope delegation fails here — before the network — with +//! a message naming the command that fixes it. +//! //! # Keyed by the public key //! //! The JWT is keyed by the caller's Ed25519 **public key**, carried in the @@ -49,11 +70,29 @@ struct JwtHeader { #[derive(Serialize)] struct Claims { - /// The caller's base32 Ed25519 public key (same value as the header `kid`). + /// The *effective subject*: the base32 public key whose access is being + /// exercised. For a human that is their own key (and equals `kid`); for an + /// agent it is the human's key that the agent acts on behalf of. sub: String, iat: i64, exp: i64, jti: String, + + /// RFC 8693 actor claim — present only when an agent is acting. + #[serde(skip_serializing_if = "Option::is_none")] + act: Option, + + /// The delegation certificate authorizing this call, as a URN. + #[serde(skip_serializing_if = "Option::is_none")] + dlg: Option, +} + +/// The actual actor behind a delegated call (RFC 8693 §4.1). +#[derive(Serialize)] +struct Actor { + /// The agent's base32 Ed25519 public key — the same value as `kid`, since + /// the agent is the party that signs. + sub: String, } // --------------------------------------------------------------------------- @@ -80,9 +119,9 @@ pub async fn refresh_token(server: &str, identity: &Identity) -> CliResult CliResult { - // The token is keyed by the identity's own public key — no server-assigned - // identifier to look up. +fn mint_token(server: &str, identity: &Identity) -> CliResult { + // The token is keyed by the signer's own public key — no server-assigned + // identifier to look up. For an agent the signer is the agent itself. let public_key_b32 = identity.public_key_base32(); // Load the keypair (needs the secret key to sign). @@ -95,12 +134,37 @@ fn mint_token(_server: &str, identity: &Identity) -> CliResult { )) })?; + // An agent acts on behalf of the human who delegated to it, so the subject + // becomes the human and the agent moves into `act`. Resolving the + // certificate here means an unusable delegation is reported before any + // request is made, with the command that fixes it. + let (sub, act, dlg) = if identity.identity_type.is_delegated() { + let resolved = crate::commands::delegation::active_for(&store, identity, Some(server))?; + let delegator_key = atomic_canonical::delegation::delegator_public_key( + &resolved.delegation, + ) + .map_err(|e| CliError::DelegationError { + message: format!("Delegation for '{}' is malformed: {e}", identity.name), + })?; + ( + delegator_key.to_base32(), + Some(Actor { + sub: public_key_b32.clone(), + }), + Some(resolved.delegation.id.to_urn()), + ) + } else { + (public_key_b32.clone(), None, None) + }; + let now = Utc::now(); let claims = Claims { - sub: public_key_b32.clone(), + sub, iat: now.timestamp(), exp: (now + TOKEN_TTL).timestamp(), jti: Uuid::new_v4().to_string(), + act, + dlg, }; let header = JwtHeader { @@ -121,7 +185,14 @@ fn mint_token(_server: &str, identity: &Identity) -> CliResult { let signature = keypair.sign(signing_input.as_bytes()); let sig_b64 = BASE64URL_NOPAD.encode(&signature); - log::debug!("Minted self-signed EdDSA JWT for '{}'", identity.name); + if claims.act.is_some() { + log::debug!( + "Minted delegated EdDSA JWT: '{}' acting on behalf of the delegator", + identity.name + ); + } else { + log::debug!("Minted self-signed EdDSA JWT for '{}'", identity.name); + } Ok(format!("{signing_input}.{sig_b64}")) } @@ -140,6 +211,59 @@ mod tests { assert_eq!(json, r#"{"alg":"EdDSA","typ":"JWT","kid":"ABCDEF"}"#); } + /// A human's token must not carry `act`/`dlg` at all — not `null`. The + /// server distinguishes "delegated" from "not" by the claim's presence, so + /// emitting explicit nulls would put every human on the delegated path. + #[test] + fn a_non_delegated_token_omits_the_actor_claims() { + let claims = Claims { + sub: "ABCDEF".to_string(), + iat: 0, + exp: 1, + jti: "j".to_string(), + act: None, + dlg: None, + }; + let json = serde_json::to_string(&claims).unwrap(); + assert!(!json.contains("act"), "{json}"); + assert!(!json.contains("dlg"), "{json}"); + } + + /// The delegated shape inverts `sub`: the human is the effective subject + /// and the agent — the signer, and therefore the `kid` — moves into `act`. + #[test] + fn a_delegated_token_puts_the_human_in_sub_and_the_agent_in_act() { + let human = "HUMANKEY"; + let agent = "AGENTKEY"; + let claims = Claims { + sub: human.to_string(), + iat: 0, + exp: 1, + jti: "j".to_string(), + act: Some(Actor { + sub: agent.to_string(), + }), + dlg: Some("urn:atomic:delegation:XYZ".to_string()), + }; + let value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&claims).unwrap()).unwrap(); + + assert_eq!(value["sub"], human); + assert_eq!(value["act"]["sub"], agent); + assert_eq!(value["dlg"], "urn:atomic:delegation:XYZ"); + + // The signer is the agent, so the header kid must match act.sub, not sub. + let header = JwtHeader { + alg: "EdDSA", + typ: "JWT", + kid: agent.to_string(), + }; + let header_value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&header).unwrap()).unwrap(); + assert_eq!(header_value["kid"], value["act"]["sub"]); + assert_ne!(header_value["kid"], value["sub"]); + } + /// A minted token must verify against the identity's public key, prove the /// three-segment shape, carry the public key as `kid` (and `sub`), and not /// verify once tampered. @@ -156,6 +280,8 @@ mod tests { iat: now.timestamp(), exp: (now + TOKEN_TTL).timestamp(), jti: Uuid::new_v4().to_string(), + act: None, + dlg: None, }; let header = JwtHeader { alg: "EdDSA", diff --git a/atomic-cli/src/commands/triage/project.rs b/atomic-cli/src/commands/triage/project.rs index bc6e10ab..bb5fd48f 100644 --- a/atomic-cli/src/commands/triage/project.rs +++ b/atomic-cli/src/commands/triage/project.rs @@ -40,6 +40,14 @@ fn kind_is(kind: &str, expected: &str) -> bool { } /// First 12 base32 chars — the short id used for `change:` KG nodes. +/// True for paths the vault owns — intent files, attestations, audit +/// entries. Changes confined to these are lifecycle bookkeeping, not work +/// a task is expected to have declared. +fn is_vault_path(path: &str) -> bool { + let p = path.strip_prefix("./").unwrap_or(path); + p == ".vault" || p.starts_with(".vault/") +} + fn short12(hash_b32: &str) -> String { hash_b32.chars().take(12).collect() } @@ -401,7 +409,7 @@ pub fn build_report( message, ) .with_query(format!( - "atomic intent new --review {bare} --reviews {bare}" + "atomic intent new \"Review: \" --review {bare}" )) .with_remedy( "have a different identity/model author and attest a review intent \ @@ -592,6 +600,21 @@ pub fn build_report( for full in &set.only_in_feature { if !reached_any_intent.get(full).copied().unwrap_or(false) { let paths = change_raw_paths.get(full).cloned().unwrap_or_default(); + + // Vault bookkeeping is self-explaining. Creating an intent, + // enriching it and attesting a review each record a change that + // touches only `.vault/` — the intent file, its attestation, an + // audit entry. No task's `::file-ref` names those paths, and it + // would be circular to demand one: the intent cannot cite the + // change that created it. + // + // Left blocking, every intent-driven merge was unreachable, and + // the only way through was to make a review's task claim + // `.vault/` paths it never really "worked on". + if !paths.is_empty() && paths.iter().all(|p| is_vault_path(p)) { + continue; + } + let message = if paths.is_empty() { "candidate change has no task/intent link — nothing explains why it exists" .to_string() @@ -1117,6 +1140,18 @@ mod tests { repo.record(header, options).unwrap(); } + #[test] + fn vault_paths_are_recognised_as_lifecycle() { + assert!(is_vault_path(".vault/intents/01ABC/intent.md")); + assert!(is_vault_path(".vault/attestations/P__a__1/attested.md")); + assert!(is_vault_path("./.vault/audit/x.json")); + assert!(is_vault_path(".vault")); + // Real work is never exempt. + assert!(!is_vault_path("src/main.rs")); + assert!(!is_vault_path("vault/notes.md")); + assert!(!is_vault_path(".vaultish/x")); + } + /// A change recorded only on `feature` that reaches no intent (the KG has /// no task/intent join) is a candidate, is flagged ORPHAN_CHANGE, and blocks /// the verdict. diff --git a/atomic-cli/src/error.rs b/atomic-cli/src/error.rs index 7540e52b..e0afeba3 100644 --- a/atomic-cli/src/error.rs +++ b/atomic-cli/src/error.rs @@ -301,6 +301,20 @@ pub enum CliError { #[error("Identity already exists: '{0}'")] IdentityAlreadyExists(String), + /// A delegation is missing, expired, revoked, or does not cover the + /// requested operation. + /// + /// Distinct from [`Self::AuthenticationFailed`]: the caller proved who they + /// are, but the certificate authorizing them to act on someone's behalf + /// does not (or no longer) permits it. Every one of these is fixable by the + /// human who issued the delegation, so the message should say which command + /// fixes it. + #[error("Delegation error: {message}")] + DelegationError { + /// What is wrong, and what to run to fix it. + message: String, + }, + // Remote Errors /// Failed to connect to or communicate with the remote. /// @@ -668,6 +682,7 @@ impl CliError { Self::RemoteError { .. } | Self::RemoteNotFound { .. } | Self::AuthenticationFailed { .. } + | Self::DelegationError { .. } | Self::GitError { .. } => 4, // IO and config errors diff --git a/atomic-config/src/lib.rs b/atomic-config/src/lib.rs index 6a25da38..b39a3cb2 100644 --- a/atomic-config/src/lib.rs +++ b/atomic-config/src/lib.rs @@ -109,6 +109,19 @@ pub struct ServerConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub identity: Option, + /// Agent identity that recording hooks sign as against this server. + /// + /// Set by `atomic identity agent create`. Deliberately separate from + /// `identity`, which stays the *human* the agent acts on behalf of: + /// enrollment, renewal and revocation all authenticate as the human, while + /// day-to-day recording and pushing authenticate as the agent. One field + /// could not express "this machine holds both keys", which is the normal + /// case on a developer laptop. + /// + /// Example: `agent_identity = "alice+claude"` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_identity: Option, + /// Whether the server is a single-tenant deployment. /// /// Single-tenant servers (reported by the registration response's @@ -545,6 +558,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert!(config.is_configured()); @@ -555,6 +569,7 @@ mod tests { default_org: None, default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert!(!partial.is_configured()); @@ -565,6 +580,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert!(!partial.is_configured()); @@ -577,6 +593,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert_eq!( @@ -596,6 +613,7 @@ mod tests { default_org: None, default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert_eq!( @@ -611,6 +629,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert_eq!( @@ -624,6 +643,7 @@ mod tests { default_org: None, default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert!(config.default_org_base_url().is_none()); @@ -636,6 +656,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; @@ -661,6 +682,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }, ..GlobalConfig::default() @@ -686,6 +708,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; let toml_str = toml::to_string_pretty(&config).unwrap(); @@ -703,6 +726,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: workspaces, identity: None, + agent_identity: None, single_tenant: false, }; @@ -740,6 +764,7 @@ mod tests { default_org: None, default_workspaces: BTreeMap::new(), identity: Some("continuouslee".to_string()), + agent_identity: None, single_tenant: false, }, ); @@ -839,6 +864,7 @@ email = "test@example.com" default_org: Some("acme".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: true, }; // Single-tenant: the bare host is already tenant-scoped — no org prefix. @@ -864,6 +890,7 @@ email = "test@example.com" default_org: Some("acme".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: true, }; assert_eq!( diff --git a/atomic-identity/Cargo.toml b/atomic-identity/Cargo.toml index 0ba962d2..e6882188 100644 --- a/atomic-identity/Cargo.toml +++ b/atomic-identity/Cargo.toml @@ -13,6 +13,7 @@ rust-version.workspace = true ed25519-dalek = { workspace = true } rand = { workspace = true } blake3 = { workspace = true } +bs58 = { workspace = true } # Serialization serde = { workspace = true } diff --git a/atomic-identity/src/delegation.rs b/atomic-identity/src/delegation.rs index 13e399fa..246c2ef7 100644 --- a/atomic-identity/src/delegation.rs +++ b/atomic-identity/src/delegation.rs @@ -1,63 +1,71 @@ -//! Delegation support for agent on-behalf-of operations +//! Agent delegation — authorizing an agent to act on behalf of a human. //! -//! This module provides structures and utilities for managing delegated -//! identities - AI agents or automated systems that act on behalf of -//! a human user. +//! A delegation is the answer to "does this agent key belong to that person, +//! and what may it do?". It names a delegator (a human identity), a delegate +//! (an agent identity with its own keypair), a [`DelegationScope`], and an +//! expiry. On the wire and on disk it is a signed **certificate** — a canonical +//! JSON-LD node carrying an `eddsa-jcs-2022` Data Integrity proof, minted and +//! verified by `atomic-canonical`'s `delegation` module. //! -//! # Overview +//! This module owns the *data model* only. It deliberately does no signing: +//! there is exactly one signature format in Atomic (JCS + Data Integrity), and +//! it lives one layer up, in the crate that owns canonicalization. That keeps +//! the certificate's bytes identical in the CLI, on disk, and in the server's +//! database. //! -//! Delegation allows users to authorize agents to perform actions in their -//! name. This is essential for: -//! -//! - **AI Assistants**: Claude, Copilot, etc. making changes on user's behalf -//! - **CI/CD Systems**: Automated builds and deployments -//! - **Bots**: Automated maintenance, dependency updates -//! -//! # Delegation Model +//! # Delegation model //! //! ```text -//! ┌─────────────────┐ delegates to ┌─────────────────┐ -//! │ User Identity │ ──────────────────▶ │ Agent Identity │ -//! │ (delegator) │ │ (delegate) │ -//! └─────────────────┘ └─────────────────┘ -//! │ │ -//! │ owns │ has -//! ▼ ▼ -//! ┌─────────────────┐ ┌─────────────────┐ -//! │ Delegation │◀─────────────────────│ DelegationScope │ -//! │ Certificate │ defines │ (permissions) │ -//! └─────────────────┘ └─────────────────┘ +//! ┌─────────────────┐ signs a certificate ┌─────────────────┐ +//! │ User Identity │ ─────────────────────────▶ │ Agent Identity │ +//! │ (delegator) │ naming the agent's DID │ (delegate) │ +//! └─────────────────┘ + scope + expiry └─────────────────┘ +//! │ │ +//! │ holds grants on the server │ holds none +//! ▼ ▼ +//! effective permissions = delegator's grants ∩ delegation scope //! ``` //! +//! The intersection is the invariant that makes the whole thing safe: an agent +//! can never do more than the human who issued it, so revoking the human's +//! access revokes the agent's with no extra bookkeeping. +//! //! # Example //! //! ```rust //! use atomic_identity::{Identity, IdentityType}; -//! use atomic_identity::delegation::{Delegation, DelegationScope, DelegationPermission}; +//! use atomic_identity::delegation::{ +//! Delegation, DelegationPermission, DelegationScope, ResourceRef, +//! }; //! -//! // Create a user identity //! let user = Identity::generate("alice"); -//! -//! // Create an agent identity -//! let agent = Identity::builder("alice-assistant") +//! let agent = Identity::builder("alice+claude") //! .identity_type(IdentityType::Agent) //! .delegated_by(user.id) //! .build()?; //! -//! // Create a delegation with specific scope //! let scope = DelegationScope::builder() //! .permission(DelegationPermission::Record) //! .permission(DelegationPermission::Push) -//! .repository_pattern("alice/*") +//! .server("https://atomic.storage") +//! .project("alice/*") //! .build(); //! -//! let delegation = Delegation::new(&user, &agent, scope)?; +//! let delegation = Delegation::new(&user, &agent, scope); +//! +//! assert!(delegation.allows( +//! DelegationPermission::Push, +//! &ResourceRef::new().server("https://atomic.storage").project("alice/api"), +//! )); +//! // Out of scope: a different project namespace. +//! assert!(!delegation.allows( +//! DelegationPermission::Push, +//! &ResourceRef::new().project("bob/api"), +//! )); //! # Ok::<(), atomic_identity::IdentityError>(()) //! ``` use crate::identity::{Identity, IdentityId}; -use crate::signing::Signature; -use crate::IdentityError; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use std::fmt; @@ -78,8 +86,8 @@ pub enum DelegationPermission { /// Permission to pull changes from remotes. Pull, - /// Permission to create/delete stacks. - ManageStacks, + /// Permission to create/delete views. + ManageViews, /// Permission to create/delete tags. ManageTags, @@ -99,7 +107,7 @@ impl DelegationPermission { DelegationPermission::Record => "Record (commit) changes", DelegationPermission::Push => "Push changes to remotes", DelegationPermission::Pull => "Pull changes from remotes", - DelegationPermission::ManageStacks => "Create and delete stacks", + DelegationPermission::ManageViews => "Create and delete views", DelegationPermission::ManageTags => "Create and delete tags", DelegationPermission::Admin => "Manage repository settings", DelegationPermission::Full => "Full access (all permissions)", @@ -113,7 +121,7 @@ impl DelegationPermission { DelegationPermission::Admin => matches!( other, DelegationPermission::Read - | DelegationPermission::ManageStacks + | DelegationPermission::ManageViews | DelegationPermission::ManageTags | DelegationPermission::Admin ), @@ -128,7 +136,7 @@ impl DelegationPermission { DelegationPermission::Record, DelegationPermission::Push, DelegationPermission::Pull, - DelegationPermission::ManageStacks, + DelegationPermission::ManageViews, DelegationPermission::ManageTags, DelegationPermission::Admin, ] @@ -142,7 +150,7 @@ impl fmt::Display for DelegationPermission { DelegationPermission::Record => write!(f, "record"), DelegationPermission::Push => write!(f, "push"), DelegationPermission::Pull => write!(f, "pull"), - DelegationPermission::ManageStacks => write!(f, "manage_stacks"), + DelegationPermission::ManageViews => write!(f, "manage_views"), DelegationPermission::ManageTags => write!(f, "manage_tags"), DelegationPermission::Admin => write!(f, "admin"), DelegationPermission::Full => write!(f, "full"), @@ -150,30 +158,113 @@ impl fmt::Display for DelegationPermission { } } +impl std::str::FromStr for DelegationPermission { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.trim().to_lowercase().replace('-', "_").as_str() { + "read" => Ok(DelegationPermission::Read), + "record" => Ok(DelegationPermission::Record), + "push" => Ok(DelegationPermission::Push), + "pull" => Ok(DelegationPermission::Pull), + "manage_views" | "manage_stacks" => Ok(DelegationPermission::ManageViews), + "manage_tags" => Ok(DelegationPermission::ManageTags), + "admin" => Ok(DelegationPermission::Admin), + "full" => Ok(DelegationPermission::Full), + other => Err(format!( + "unknown permission '{other}' (expected one of: read, record, push, pull, \ + manage_views, manage_tags, admin, full)" + )), + } + } +} + +/// The resource an authorization decision is being made about. +/// +/// Every field is optional: an absent field means "this dimension is not being +/// constrained by the caller", and the corresponding scope patterns are not +/// consulted. A server MUST populate the fields it can derive from the request +/// path — never from a client-supplied value — since these are what the scope +/// is matched against. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ResourceRef<'a> { + /// Canonical server URL, e.g. `https://atomic.storage`. + pub server: Option<&'a str>, + /// Workspace slug. + pub workspace: Option<&'a str>, + /// Project path, e.g. `acme/api`. + pub project: Option<&'a str>, + /// View name. + pub view: Option<&'a str>, +} + +impl<'a> ResourceRef<'a> { + /// An unconstrained resource reference. + pub fn new() -> Self { + Self::default() + } + + /// Constrain the server. + pub fn server(mut self, server: &'a str) -> Self { + self.server = Some(server); + self + } + + /// Constrain the workspace. + pub fn workspace(mut self, workspace: &'a str) -> Self { + self.workspace = Some(workspace); + self + } + + /// Constrain the project. + pub fn project(mut self, project: &'a str) -> Self { + self.project = Some(project); + self + } + + /// Constrain the view. + pub fn view(mut self, view: &'a str) -> Self { + self.view = Some(view); + self + } +} + /// The scope of a delegation, defining what the delegate can do. +/// +/// Every pattern list follows the same rule: **empty means unrestricted on that +/// dimension**, a non-empty list means the value must match one of the globs. +/// Scope only ever narrows — it is intersected with the delegator's own +/// permissions, never unioned. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DelegationScope { /// Permissions granted to the delegate. pub permissions: Vec, - /// Repository patterns the delegation applies to (glob patterns). + /// Server URLs this delegation is valid against. /// - /// Empty means all repositories. + /// Empty means all servers. A certificate minted for staging must not + /// authenticate against production, so the CLI always populates this. #[serde(default)] - pub repository_patterns: Vec, + pub servers: Vec, - /// View patterns the delegation applies to (glob patterns). - /// - /// Empty means all views. - #[serde(default, alias = "stack_patterns")] - pub view_patterns: Vec, + /// Workspace slugs (glob patterns) the delegation applies to. + #[serde(default)] + pub workspaces: Vec, + + /// Project paths (glob patterns) the delegation applies to. + #[serde(default, alias = "repository_patterns")] + pub projects: Vec, + + /// View names (glob patterns) the delegation applies to. + #[serde(default, alias = "view_patterns", alias = "stack_patterns")] + pub views: Vec, /// Maximum number of changes the delegate can create. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_changes: Option, /// Human-readable description of the scope. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -181,8 +272,10 @@ impl Default for DelegationScope { fn default() -> Self { Self { permissions: vec![DelegationPermission::Read], - repository_patterns: Vec::new(), - view_patterns: Vec::new(), + servers: Vec::new(), + workspaces: Vec::new(), + projects: Vec::new(), + views: Vec::new(), max_changes: None, description: None, } @@ -199,25 +292,19 @@ impl DelegationScope { pub fn full() -> Self { Self { permissions: vec![DelegationPermission::Full], - repository_patterns: Vec::new(), - view_patterns: Vec::new(), - max_changes: None, - description: Some("Full access".to_string()), + ..Default::default() } } - /// Create a scope for read-only access. + /// Create a read-only scope. pub fn read_only() -> Self { Self { - permissions: vec![DelegationPermission::Read], - repository_patterns: Vec::new(), - view_patterns: Vec::new(), - max_changes: None, - description: Some("Read-only access".to_string()), + permissions: vec![DelegationPermission::Read, DelegationPermission::Pull], + ..Default::default() } } - /// Create a scope for typical CI/CD operations. + /// Create a scope suited to a CI/CD agent: read, record, push, pull. pub fn ci_cd() -> Self { Self { permissions: vec![ @@ -226,10 +313,7 @@ impl DelegationScope { DelegationPermission::Push, DelegationPermission::Pull, ], - repository_patterns: Vec::new(), - view_patterns: Vec::new(), - max_changes: None, - description: Some("CI/CD operations".to_string()), + ..Default::default() } } @@ -243,57 +327,108 @@ impl DelegationScope { self.permissions.iter().any(|p| p.implies(&permission)) } - /// Check if this scope allows access to a repository. - pub fn allows_repository(&self, repo_path: &str) -> bool { - if self.repository_patterns.is_empty() { + /// Check if this scope is valid against a server URL. + /// + /// Comparison ignores a trailing slash and is case-insensitive on the host, + /// so `https://Atomic.Storage/` and `https://atomic.storage` are the same + /// server. Unlike the other dimensions this is an exact match, not a glob: + /// a wildcard server would defeat the point of binding a certificate to + /// the deployment it was issued for. + pub fn allows_server(&self, server_url: &str) -> bool { + if self.servers.is_empty() { return true; } - - self.repository_patterns + let wanted = normalize_server_url(server_url); + self.servers .iter() - .any(|pattern| Self::matches_pattern(pattern, repo_path)) + .any(|s| normalize_server_url(s) == wanted) } - /// Check if this scope allows access to a view. - pub fn allows_view(&self, view_name: &str) -> bool { - if self.view_patterns.is_empty() { - return true; - } + /// Check if this scope allows access to a workspace. + pub fn allows_workspace(&self, workspace: &str) -> bool { + matches_any(&self.workspaces, workspace) + } - self.view_patterns - .iter() - .any(|pattern| Self::matches_pattern(pattern, view_name)) + /// Check if this scope allows access to a project. + pub fn allows_project(&self, project: &str) -> bool { + matches_any(&self.projects, project) } - /// Simple glob pattern matching (supports * and ?). - fn matches_pattern(pattern: &str, value: &str) -> bool { - let pattern_chars: Vec = pattern.chars().collect(); - let value_chars: Vec = value.chars().collect(); - Self::matches_pattern_recursive(&pattern_chars, &value_chars) + /// Check if this scope allows access to a view. + pub fn allows_view(&self, view_name: &str) -> bool { + matches_any(&self.views, view_name) } - fn matches_pattern_recursive(pattern: &[char], value: &[char]) -> bool { - match (pattern.first(), value.first()) { - (None, None) => true, - (Some('*'), _) => { - // Try matching zero or more characters - Self::matches_pattern_recursive(&pattern[1..], value) - || (!value.is_empty() && Self::matches_pattern_recursive(pattern, &value[1..])) + /// Check a permission against a resource in one call. + pub fn allows(&self, permission: DelegationPermission, resource: &ResourceRef<'_>) -> bool { + if !self.has_permission(permission) { + return false; + } + if let Some(server) = resource.server { + if !self.allows_server(server) { + return false; + } + } + if let Some(workspace) = resource.workspace { + if !self.allows_workspace(workspace) { + return false; + } + } + if let Some(project) = resource.project { + if !self.allows_project(project) { + return false; } - (Some('?'), Some(_)) => Self::matches_pattern_recursive(&pattern[1..], &value[1..]), - (Some(p), Some(v)) if p == v => { - Self::matches_pattern_recursive(&pattern[1..], &value[1..]) + } + if let Some(view) = resource.view { + if !self.allows_view(view) { + return false; } - _ => false, } + true + } +} + +/// Normalize a server URL for comparison: lowercased, no trailing slash. +fn normalize_server_url(url: &str) -> String { + url.trim().trim_end_matches('/').to_lowercase() +} + +/// An empty pattern list is unrestricted; otherwise the value must match one. +fn matches_any(patterns: &[String], value: &str) -> bool { + if patterns.is_empty() { + return true; + } + patterns.iter().any(|p| matches_pattern(p, value)) +} + +/// Simple glob pattern matching (supports `*` and `?`). +fn matches_pattern(pattern: &str, value: &str) -> bool { + let pattern_chars: Vec = pattern.chars().collect(); + let value_chars: Vec = value.chars().collect(); + matches_pattern_recursive(&pattern_chars, &value_chars) +} + +fn matches_pattern_recursive(pattern: &[char], value: &[char]) -> bool { + match (pattern.first(), value.first()) { + (None, None) => true, + (Some('*'), _) => { + matches_pattern_recursive(&pattern[1..], value) + || (!value.is_empty() && matches_pattern_recursive(pattern, &value[1..])) + } + (Some('?'), Some(_)) => matches_pattern_recursive(&pattern[1..], &value[1..]), + (Some(p), Some(v)) if p == v => matches_pattern_recursive(&pattern[1..], &value[1..]), + _ => false, } } /// Builder for creating delegation scopes. +#[derive(Debug, Default)] pub struct DelegationScopeBuilder { permissions: Vec, - repository_patterns: Vec, - view_patterns: Vec, + servers: Vec, + workspaces: Vec, + projects: Vec, + views: Vec, max_changes: Option, description: Option, } @@ -301,13 +436,7 @@ pub struct DelegationScopeBuilder { impl DelegationScopeBuilder { /// Create a new scope builder. pub fn new() -> Self { - Self { - permissions: Vec::new(), - repository_patterns: Vec::new(), - view_patterns: Vec::new(), - max_changes: None, - description: None, - } + Self::default() } /// Add a permission. @@ -318,38 +447,48 @@ impl DelegationScopeBuilder { self } - /// Add multiple permissions. + /// Add several permissions. pub fn permissions( mut self, permissions: impl IntoIterator, ) -> Self { - for p in permissions { - if !self.permissions.contains(&p) { - self.permissions.push(p); - } + for permission in permissions { + self = self.permission(permission); } self } - /// Add a repository pattern. - pub fn repository_pattern(mut self, pattern: impl Into) -> Self { - self.repository_patterns.push(pattern.into()); + /// Bind the delegation to a server URL. + pub fn server(mut self, url: impl Into) -> Self { + self.servers.push(url.into()); + self + } + + /// Restrict the delegation to a workspace pattern. + pub fn workspace(mut self, pattern: impl Into) -> Self { + self.workspaces.push(pattern.into()); + self + } + + /// Restrict the delegation to a project pattern. + pub fn project(mut self, pattern: impl Into) -> Self { + self.projects.push(pattern.into()); self } - /// Add a view pattern. - pub fn view_pattern(mut self, pattern: impl Into) -> Self { - self.view_patterns.push(pattern.into()); + /// Restrict the delegation to a view pattern. + pub fn view(mut self, pattern: impl Into) -> Self { + self.views.push(pattern.into()); self } - /// Set maximum number of changes. + /// Cap the number of changes the delegate may create. pub fn max_changes(mut self, max: u64) -> Self { self.max_changes = Some(max); self } - /// Set a description. + /// Describe the scope for humans. pub fn description(mut self, description: impl Into) -> Self { self.description = Some(description.into()); self @@ -357,214 +496,247 @@ impl DelegationScopeBuilder { /// Build the delegation scope. pub fn build(mut self) -> DelegationScope { - // Ensure at least read permission + // A scope with no permissions would authorize nothing; read is the + // floor, matching `DelegationScope::default()`. if self.permissions.is_empty() { self.permissions.push(DelegationPermission::Read); } DelegationScope { permissions: self.permissions, - repository_patterns: self.repository_patterns, - view_patterns: self.view_patterns, + servers: self.servers, + workspaces: self.workspaces, + projects: self.projects, + views: self.views, max_changes: self.max_changes, description: self.description, } } } -impl Default for DelegationScopeBuilder { - fn default() -> Self { - Self::new() - } -} - -/// A delegation certificate authorizing an agent to act on behalf of a user. +/// A delegation authorizing an agent to act on behalf of a user. +/// +/// This is the *typed view* of a certificate. The authoritative artifact is the +/// signed canonical document produced by `atomic_canonical::delegation::mint`; +/// this struct is what you get back from parsing one, and what you build before +/// minting. It deliberately carries no signature field and no revocation state: +/// the signature lives in the document's `proof`, and revocation is a separate +/// signed document plus server-side status. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Delegation { - /// Unique identifier for this delegation. + /// Deterministic identifier, derived from delegator + delegate + issue time. pub id: DelegationId, - /// The delegator's identity ID (the user granting permission). - pub delegator_id: IdentityId, + /// The delegating identity's DID (`did:atomic:...`). + pub delegator: String, - /// The delegator's name (for display). + /// The delegating identity's name at issue time (a label, not identity). pub delegator_name: String, - /// The delegate's identity ID (the agent receiving permission). - pub delegate_id: IdentityId, + /// The delegator's key in `did:key` form. + /// + /// Carried so a certificate is self-contained: a machine holding only the + /// agent's key (a CI runner, a fresh clone) can still check the signature. + /// Self-verification proves integrity, not trust — a verifier must still + /// decide whether it trusts this delegator, which is what the server's + /// registered-key lookup settles. + pub delegator_key: String, - /// The delegate's name (for display). - pub delegate_name: String, + /// The delegate identity's DID (`did:atomic:...`). + pub delegate: String, - /// The scope of the delegation. - pub scope: DelegationScope, + /// The delegate's key in `did:key` form, from which the public key is + /// recoverable — `did:atomic` is a blake3 fingerprint and is not. + pub delegate_key: String, - /// When the delegation was created. - pub created_at: DateTime, + /// The delegate identity's name at issue time. + pub delegate_name: String, - /// When the delegation expires (if set). - #[serde(default)] - pub expires_at: Option>, + /// Which software agent this key belongs to (`urn:atomic:agent:claude-code`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub software_agent: Option, - /// Whether the delegation has been revoked. - #[serde(default)] - pub revoked: bool, + /// What the delegate may do. + pub scope: DelegationScope, - /// When the delegation was revoked (if applicable). - #[serde(default)] - pub revoked_at: Option>, + /// When the delegation was issued. + pub issued: DateTime, - /// Signature from the delegator proving authenticity. - /// - /// This is the delegator's signature over the delegation data. - #[serde(default)] - pub signature: Option, + /// When the delegation expires. `None` means it never does — strongly + /// discouraged for agent keys, which are unattended by definition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires: Option>, } /// Unique identifier for a delegation. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct DelegationId([u8; 32]); impl DelegationId { - /// Create a delegation ID from the delegation data. + /// URN prefix for the rendered form. + pub const URN_PREFIX: &'static str = "urn:atomic:delegation:"; + + /// Derive the deterministic id for a delegation's defining triple. + /// + /// Deterministic so a verifier can recompute it from the certificate body + /// and confirm the `@id` was not swapped — the id is a claim like any + /// other, and the only claims worth trusting are the ones you can recheck. pub fn from_delegation_data( delegator_id: &IdentityId, delegate_id: &IdentityId, created_at: DateTime, ) -> Self { let mut hasher = blake3::Hasher::new(); + hasher.update(b"atomic:delegation:v1"); hasher.update(delegator_id.as_bytes()); hasher.update(delegate_id.as_bytes()); hasher.update(&created_at.timestamp().to_le_bytes()); - DelegationId(*hasher.finalize().as_bytes()) + Self(*hasher.finalize().as_bytes()) } - /// Create a delegation ID from raw bytes. + /// Wrap raw bytes. pub fn from_bytes(bytes: [u8; 32]) -> Self { - DelegationId(bytes) + Self(bytes) } - /// Get the raw bytes. + /// The raw bytes. pub fn as_bytes(&self) -> &[u8; 32] { &self.0 } - /// Encode as base32. + /// Base32 (no padding) rendering — the form used in filenames and URNs. pub fn to_base32(&self) -> String { data_encoding::BASE32_NOPAD.encode(&self.0) } - /// Get a short form for display. + /// Parse a base32 rendering, with or without the `urn:atomic:delegation:` + /// prefix. + pub fn from_base32(s: &str) -> Option { + let raw = s.strip_prefix(Self::URN_PREFIX).unwrap_or(s); + let bytes = data_encoding::BASE32_NOPAD.decode(raw.as_bytes()).ok()?; + let bytes: [u8; 32] = bytes.try_into().ok()?; + Some(Self(bytes)) + } + + /// The canonical URN form (`urn:atomic:delegation:`). + pub fn to_urn(&self) -> String { + format!("{}{}", Self::URN_PREFIX, self.to_base32()) + } + + /// A short prefix for display. pub fn short(&self) -> String { - self.to_base32()[..8].to_string() + self.to_base32().chars().take(8).collect() } } -impl fmt::Debug for DelegationId { +impl fmt::Display for DelegationId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "DelegationId({})", self.short()) + write!(f, "{}", self.to_base32()) } } -impl fmt::Display for DelegationId { +/// Runtime status of a delegation, combining local facts (expiry) with +/// whatever the server reports (revocation). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DelegationStatus { + /// Usable right now. + Active, + /// Past its `expires` timestamp. + Expired, + /// Explicitly revoked by the delegator. + Revoked, +} + +impl fmt::Display for DelegationStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.to_base32()) + match self { + DelegationStatus::Active => write!(f, "active"), + DelegationStatus::Expired => write!(f, "expired"), + DelegationStatus::Revoked => write!(f, "revoked"), + } } } impl Delegation { - /// Create a new delegation from a delegator to a delegate. - pub fn new( - delegator: &Identity, - delegate: &Identity, - scope: DelegationScope, - ) -> Result { - let created_at = Utc::now(); - let id = DelegationId::from_delegation_data(&delegator.id, &delegate.id, created_at); - - Ok(Self { - id, - delegator_id: delegator.id, + /// Build a delegation from a delegator and delegate identity. + /// + /// The DIDs are derived from each identity's public key. Issue time is now; + /// use [`Self::expires_in`] or [`Self::with_expiry`] to bound it. + pub fn new(delegator: &Identity, delegate: &Identity, scope: DelegationScope) -> Self { + let issued = Utc::now(); + Self { + id: DelegationId::from_delegation_data(&delegator.id, &delegate.id, issued), + delegator: delegator.id.to_did(), delegator_name: delegator.name.clone(), - delegate_id: delegate.id, + delegator_key: delegator.public_key.to_did_key(), + delegate: delegate.id.to_did(), + delegate_key: delegate.public_key.to_did_key(), delegate_name: delegate.name.clone(), + software_agent: None, scope, - created_at, - expires_at: None, - revoked: false, - revoked_at: None, - signature: None, - }) + issued, + expires: None, + } } - /// Create a delegation with an expiration time. - pub fn with_expiry(mut self, expires_at: DateTime) -> Self { - self.expires_at = Some(expires_at); + /// Name the software agent this key belongs to. + pub fn with_software_agent(mut self, agent: impl Into) -> Self { + self.software_agent = Some(agent.into()); self } - /// Create a delegation that expires after a duration. - pub fn expires_in(mut self, duration: Duration) -> Self { - self.expires_at = Some(Utc::now() + duration); + /// Set an explicit expiry. + pub fn with_expiry(mut self, expires: DateTime) -> Self { + self.expires = Some(expires); self } - /// Check if the delegation is currently valid. - pub fn is_valid(&self) -> bool { - !self.revoked && !self.is_expired() + /// Expire after a duration from the issue time. + pub fn expires_in(mut self, duration: Duration) -> Self { + self.expires = Some(self.issued + duration); + self } - /// Check if the delegation has expired. + /// Has this delegation passed its expiry? pub fn is_expired(&self) -> bool { - self.expires_at.map(|exp| exp < Utc::now()).unwrap_or(false) + self.expires.map(|exp| exp < Utc::now()).unwrap_or(false) } - /// Revoke the delegation. - pub fn revoke(&mut self) { - self.revoked = true; - self.revoked_at = Some(Utc::now()); + /// Status from purely local facts. Revocation is server state, so a caller + /// that knows a revocation exists should report [`DelegationStatus::Revoked`] + /// itself rather than asking this. + pub fn status(&self) -> DelegationStatus { + if self.is_expired() { + DelegationStatus::Expired + } else { + DelegationStatus::Active + } } - /// Check if an operation is allowed by this delegation. - pub fn allows( - &self, - permission: DelegationPermission, - repository: Option<&str>, - view: Option<&str>, - ) -> bool { - if !self.is_valid() { - return false; - } + /// Time remaining before expiry, or `None` if it never expires. + pub fn time_remaining(&self) -> Option { + self.expires.map(|exp| exp - Utc::now()) + } - if !self.scope.has_permission(permission) { + /// Does the delegation authorize `permission` on `resource`? + /// + /// Checks expiry and scope. It does **not** check revocation (server state) + /// or the delegator's own grants — a server must check both, and the + /// effective answer is always the intersection. + pub fn allows(&self, permission: DelegationPermission, resource: &ResourceRef<'_>) -> bool { + if self.is_expired() { return false; } - - if let Some(repo) = repository { - if !self.scope.allows_repository(repo) { - return false; - } - } - - if let Some(view_name) = view { - if !self.scope.allows_view(view_name) { - return false; - } - } - - true + self.scope.allows(permission, resource) } - /// Get the data to be signed for this delegation. - pub fn signing_data(&self) -> Vec { - let mut data = Vec::new(); - data.extend_from_slice(self.delegator_id.as_bytes()); - data.extend_from_slice(self.delegate_id.as_bytes()); - data.extend_from_slice(&self.created_at.timestamp().to_le_bytes()); - if let Some(exp) = self.expires_at { - data.extend_from_slice(&exp.timestamp().to_le_bytes()); - } - data + /// Recompute the id from the body and compare against the carried one. + /// + /// The identity ids are recovered from the DIDs, which are fingerprints, so + /// this needs the two [`IdentityId`]s rather than the DID strings. + pub fn id_matches(&self, delegator_id: &IdentityId, delegate_id: &IdentityId) -> bool { + self.id == DelegationId::from_delegation_data(delegator_id, delegate_id, self.issued) } } @@ -575,7 +747,7 @@ impl fmt::Display for Delegation { "{} -> {} ({})", self.delegator_name, self.delegate_name, - if self.is_valid() { "valid" } else { "invalid" } + self.status() ) } } @@ -587,7 +759,7 @@ mod tests { fn create_test_identities() -> (Identity, Identity) { let user = Identity::generate("alice"); - let agent = Identity::builder("alice-assistant") + let agent = Identity::builder("alice+claude") .identity_type(IdentityType::Agent) .delegated_by(user.id) .build() @@ -598,104 +770,89 @@ mod tests { #[test] fn test_delegation_permission_implies() { assert!(DelegationPermission::Full.implies(&DelegationPermission::Read)); - assert!(DelegationPermission::Full.implies(&DelegationPermission::Record)); - assert!(DelegationPermission::Full.implies(&DelegationPermission::Full)); - - assert!(!DelegationPermission::Read.implies(&DelegationPermission::Record)); + assert!(DelegationPermission::Full.implies(&DelegationPermission::Push)); + assert!(DelegationPermission::Admin.implies(&DelegationPermission::Read)); + assert!(!DelegationPermission::Admin.implies(&DelegationPermission::Push)); assert!(DelegationPermission::Read.implies(&DelegationPermission::Read)); + assert!(!DelegationPermission::Read.implies(&DelegationPermission::Push)); + } + + #[test] + fn test_permission_from_str_round_trip() { + for p in DelegationPermission::standard_permissions() { + let parsed: DelegationPermission = p.to_string().parse().unwrap(); + assert_eq!(&parsed, p); + } + // The pre-"view" spelling still parses, so old scripts keep working. + assert_eq!( + "manage_stacks".parse::().unwrap(), + DelegationPermission::ManageViews + ); + assert!("teleport".parse::().is_err()); } #[test] - fn test_delegation_scope_has_permission() { + fn test_scope_builder() { let scope = DelegationScope::builder() .permission(DelegationPermission::Read) .permission(DelegationPermission::Record) + .server("https://atomic.storage") + .project("acme/*") + .view("main") + .max_changes(100) .build(); assert!(scope.has_permission(DelegationPermission::Read)); assert!(scope.has_permission(DelegationPermission::Record)); assert!(!scope.has_permission(DelegationPermission::Push)); + assert_eq!(scope.max_changes, Some(100)); } #[test] - fn test_delegation_scope_full() { - let scope = DelegationScope::full(); - - assert!(scope.has_permission(DelegationPermission::Read)); - assert!(scope.has_permission(DelegationPermission::Record)); - assert!(scope.has_permission(DelegationPermission::Push)); - assert!(scope.has_permission(DelegationPermission::Admin)); - } - - #[test] - fn test_delegation_scope_repository_patterns() { + fn test_scope_empty_dimension_is_unrestricted() { let scope = DelegationScope::builder() - .permission(DelegationPermission::Read) - .repository_pattern("alice/*") - .repository_pattern("shared/*") + .permission(DelegationPermission::Push) .build(); - - assert!(scope.allows_repository("alice/project")); - assert!(scope.allows_repository("alice/another")); - assert!(scope.allows_repository("shared/common")); - assert!(!scope.allows_repository("bob/project")); + assert!(scope.allows_project("anything/at/all")); + assert!(scope.allows_view("some-view")); + assert!(scope.allows_server("https://elsewhere.example")); } #[test] - fn test_delegation_scope_pattern_matching() { - // Test wildcard matching - assert!(DelegationScope::matches_pattern("*", "anything")); - assert!(DelegationScope::matches_pattern("prefix*", "prefix-suffix")); - assert!(DelegationScope::matches_pattern("*suffix", "prefix-suffix")); - assert!(DelegationScope::matches_pattern("pre*fix", "prefix")); - - // Test question mark - assert!(DelegationScope::matches_pattern("te?t", "test")); - assert!(DelegationScope::matches_pattern("te?t", "text")); - assert!(!DelegationScope::matches_pattern("te?t", "toast")); - - // Test exact match - assert!(DelegationScope::matches_pattern("exact", "exact")); - assert!(!DelegationScope::matches_pattern("exact", "different")); - } - - #[test] - fn test_delegation_new() { - let (user, agent) = create_test_identities(); - let scope = DelegationScope::read_only(); - - let delegation = Delegation::new(&user, &agent, scope).unwrap(); - - assert_eq!(delegation.delegator_id, user.id); - assert_eq!(delegation.delegate_id, agent.id); - assert!(delegation.is_valid()); + fn test_scope_project_globs() { + let scope = DelegationScope::builder() + .permission(DelegationPermission::Push) + .project("acme/*") + .build(); + assert!(scope.allows_project("acme/api")); + assert!(!scope.allows_project("other/api")); } + /// An unbound scope is valid against every deployment. That is correct + /// behaviour for the type — but it is why the CLI binds to the active + /// server unless told otherwise, rather than leaving this empty. #[test] - fn test_delegation_expiry() { - let (user, agent) = create_test_identities(); - let scope = DelegationScope::read_only(); - - let delegation = Delegation::new(&user, &agent, scope) - .unwrap() - .expires_in(Duration::hours(1)); - - assert!(delegation.is_valid()); - assert!(!delegation.is_expired()); + fn an_unbound_scope_is_valid_everywhere() { + let scope = DelegationScope::builder() + .permission(DelegationPermission::Push) + .build(); + assert!(scope.servers.is_empty()); + assert!(scope.allows_server("https://atomic.storage")); + assert!(scope.allows_server("https://staging.example")); } #[test] - fn test_delegation_revoke() { - let (user, agent) = create_test_identities(); - let scope = DelegationScope::read_only(); - - let mut delegation = Delegation::new(&user, &agent, scope).unwrap(); - assert!(delegation.is_valid()); - - delegation.revoke(); - assert!(!delegation.is_valid()); - assert!(delegation.revoked); - assert!(delegation.revoked_at.is_some()); + fn test_scope_server_is_exact_not_glob() { + let scope = DelegationScope::builder() + .permission(DelegationPermission::Push) + .server("https://atomic.storage") + .build(); + assert!(scope.allows_server("https://atomic.storage")); + // Trailing slash and case are noise, not a different server. + assert!(scope.allows_server("https://Atomic.Storage/")); + // A wildcard must not smuggle in a different deployment. + assert!(!scope.allows_server("https://staging.atomic.storage")); + assert!(!scope.allows_server("https://evil.example")); } #[test] @@ -703,76 +860,93 @@ mod tests { let (user, agent) = create_test_identities(); let scope = DelegationScope::builder() .permission(DelegationPermission::Read) - .permission(DelegationPermission::Record) - .repository_pattern("alice/*") + .permission(DelegationPermission::Push) + .server("https://atomic.storage") + .project("acme/*") .build(); + let delegation = Delegation::new(&user, &agent, scope).expires_in(Duration::days(30)); + + let ok = ResourceRef::new() + .server("https://atomic.storage") + .project("acme/api"); + assert!(delegation.allows(DelegationPermission::Push, &ok)); + assert!(delegation.allows(DelegationPermission::Read, &ok)); + // Permission not granted. + assert!(!delegation.allows(DelegationPermission::Admin, &ok)); + // Project out of scope. + assert!(!delegation.allows( + DelegationPermission::Push, + &ResourceRef::new().project("other/api") + )); + // Right project, wrong server. + assert!(!delegation.allows( + DelegationPermission::Push, + &ResourceRef::new() + .server("https://staging.atomic.storage") + .project("acme/api") + )); + } - let delegation = Delegation::new(&user, &agent, scope).unwrap(); - - // Allowed operations - assert!(delegation.allows(DelegationPermission::Read, Some("alice/project"), None)); - assert!(delegation.allows(DelegationPermission::Record, Some("alice/project"), None)); - - // Not allowed (wrong permission) - assert!(!delegation.allows(DelegationPermission::Push, Some("alice/project"), None)); + #[test] + fn test_expired_delegation_allows_nothing() { + let (user, agent) = create_test_identities(); + let delegation = Delegation::new(&user, &agent, DelegationScope::full()) + .with_expiry(Utc::now() - Duration::hours(1)); - // Not allowed (wrong repository) - assert!(!delegation.allows(DelegationPermission::Read, Some("bob/project"), None)); + assert!(delegation.is_expired()); + assert_eq!(delegation.status(), DelegationStatus::Expired); + assert!(!delegation.allows(DelegationPermission::Read, &ResourceRef::new())); } #[test] - fn test_delegation_id_deterministic() { + fn test_delegation_id_deterministic_and_recomputable() { let (user, agent) = create_test_identities(); - let created_at = Utc::now(); - - let id1 = DelegationId::from_delegation_data(&user.id, &agent.id, created_at); - let id2 = DelegationId::from_delegation_data(&user.id, &agent.id, created_at); + let delegation = Delegation::new(&user, &agent, DelegationScope::read_only()); - assert_eq!(id1, id2); + assert!(delegation.id_matches(&user.id, &agent.id)); + // A different delegate yields a different id. + let other = Identity::generate("mallory"); + assert!(!delegation.id_matches(&user.id, &other.id)); } #[test] - fn test_delegation_json_roundtrip() { + fn test_delegation_id_base32_round_trip() { let (user, agent) = create_test_identities(); - let scope = DelegationScope::ci_cd(); - let delegation = Delegation::new(&user, &agent, scope).unwrap(); - - let json = serde_json::to_string(&delegation).unwrap(); - let recovered: Delegation = serde_json::from_str(&json).unwrap(); + let delegation = Delegation::new(&user, &agent, DelegationScope::read_only()); - assert_eq!(delegation.id, recovered.id); - assert_eq!(delegation.delegator_id, recovered.delegator_id); - assert_eq!(delegation.delegate_id, recovered.delegate_id); + let urn = delegation.id.to_urn(); + assert!(urn.starts_with(DelegationId::URN_PREFIX)); + assert_eq!(DelegationId::from_base32(&urn), Some(delegation.id)); + assert_eq!( + DelegationId::from_base32(&delegation.id.to_base32()), + Some(delegation.id) + ); + assert_eq!(DelegationId::from_base32("not base32!"), None); } #[test] - fn test_delegation_scope_builder() { - let scope = DelegationScope::builder() - .permission(DelegationPermission::Read) - .permission(DelegationPermission::Record) - .repository_pattern("project/*") - .view_pattern("main") - .view_pattern("feature-*") - .max_changes(100) - .description("Limited access for testing") - .build(); + fn test_delegate_key_is_recoverable_did_key() { + let (user, agent) = create_test_identities(); + let delegation = Delegation::new(&user, &agent, DelegationScope::read_only()); - assert!(scope.has_permission(DelegationPermission::Read)); - assert!(scope.has_permission(DelegationPermission::Record)); - assert_eq!(scope.repository_patterns.len(), 1); - assert_eq!(scope.view_patterns.len(), 2); - assert_eq!(scope.max_changes, Some(100)); - assert!(scope.description.is_some()); + // did:atomic is a fingerprint; did:key carries the key itself. + assert!(delegation.delegate.starts_with("did:atomic:")); + assert!(delegation.delegate_key.starts_with("did:key:z6Mk")); + assert!(delegation.delegator.starts_with("did:atomic:")); + assert!(delegation.delegator_key.starts_with("did:key:z6Mk")); } #[test] - fn test_delegation_ci_cd_scope() { - let scope = DelegationScope::ci_cd(); - - assert!(scope.has_permission(DelegationPermission::Read)); - assert!(scope.has_permission(DelegationPermission::Record)); - assert!(scope.has_permission(DelegationPermission::Push)); - assert!(scope.has_permission(DelegationPermission::Pull)); - assert!(!scope.has_permission(DelegationPermission::Admin)); + fn test_scope_deserializes_legacy_field_names() { + // Scopes written before the rename must still load. + let legacy = r#"{ + "permissions": ["read"], + "repository_patterns": ["acme/*"], + "view_patterns": ["main"] + }"#; + let scope: DelegationScope = serde_json::from_str(legacy).unwrap(); + assert_eq!(scope.projects, vec!["acme/*".to_string()]); + assert_eq!(scope.views, vec!["main".to_string()]); + assert!(scope.servers.is_empty()); } } diff --git a/atomic-identity/src/error.rs b/atomic-identity/src/error.rs index a22ca96d..f67b5da3 100644 --- a/atomic-identity/src/error.rs +++ b/atomic-identity/src/error.rs @@ -21,6 +21,10 @@ pub enum IdentityError { #[error("Identity already exists: {name}")] AlreadyExists { name: String }, + /// No delegation certificate stored under this id + #[error("Delegation not found: {id}")] + DelegationNotFound { id: String }, + /// Signature verification failed #[error("Signature verification failed")] InvalidSignature, diff --git a/atomic-identity/src/identity.rs b/atomic-identity/src/identity.rs index 3f1877de..52adafe9 100644 --- a/atomic-identity/src/identity.rs +++ b/atomic-identity/src/identity.rs @@ -45,6 +45,12 @@ use std::fmt; #[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct IdentityId([u8; 32]); +/// DID method prefix for atomic's in-tree identity method. +/// +/// `did:atomic:` — a fingerprint of the key, which +/// is why [`IdentityId::to_did`] and the key itself are not interchangeable. +pub const DID_ATOMIC_PREFIX: &str = "did:atomic:"; + impl IdentityId { /// Create an identity ID from a public key. pub fn from_public_key(public_key: &PublicKey) -> Self { @@ -89,6 +95,32 @@ impl IdentityId { pub fn short(&self) -> String { self.to_base32()[..8].to_string() } + + /// The `did:atomic` identifier for this identity. + /// + /// The id is already `blake3(public_key)`, which is exactly what the + /// `did:atomic` method specifies, so the DID is a rendering of the id + /// rather than a second derivation that could drift from it. + /// + /// Note this is a *fingerprint*: the public key cannot be recovered from + /// it. Where a verifier needs the key itself, carry + /// [`PublicKey::to_did_key`] alongside. + pub fn to_did(&self) -> String { + format!("{}{}", DID_ATOMIC_PREFIX, self.to_base32()) + } + + /// Parse a `did:atomic:` identifier back into an id. + pub fn from_did(did: &str) -> Result { + let raw = did.strip_prefix(DID_ATOMIC_PREFIX).ok_or_else(|| { + IdentityError::InvalidKey(format!("not a {DID_ATOMIC_PREFIX} identifier: {did}")) + })?; + Self::from_base32(raw) + } + + /// Does this id correspond to `public_key`? + pub fn matches_public_key(&self, public_key: &PublicKey) -> bool { + *self == Self::from_public_key(public_key) + } } impl fmt::Debug for IdentityId { diff --git a/atomic-identity/src/keypair.rs b/atomic-identity/src/keypair.rs index 059ce188..6f22df58 100644 --- a/atomic-identity/src/keypair.rs +++ b/atomic-identity/src/keypair.rs @@ -11,6 +11,12 @@ use std::fmt; use crate::IdentityError; +/// Multibase/multicodec prefix for the standard `did:key` representation of an +/// Ed25519 public key. +pub const DID_KEY_PREFIX: &str = "did:key:"; +/// Multicodec prefix for an Ed25519 public key (varint `0xed01`). +const MULTICODEC_ED25519_PUB: [u8; 2] = [0xed, 0x01]; + /// A public key for verifying signatures. /// /// Public keys can be freely shared and are used to verify that @@ -47,6 +53,42 @@ impl PublicKey { data_encoding::BASE32_NOPAD.encode(self.as_bytes()) } + /// The standard `did:key` identifier for this Ed25519 key. + /// + /// Multicodec `ed25519-pub` (varint `0xed01`) + the 32 key bytes, base58btc + /// multibase — always rendered `did:key:z6Mk...` for Ed25519. Unlike + /// `did:atomic` (a blake3 fingerprint), the public key is *recoverable* + /// from this form, so a verifier holding only the DID can check a + /// signature. Delegation certificates carry both. + pub fn to_did_key(&self) -> String { + let mut bytes = Vec::with_capacity(2 + Self::SIZE); + bytes.extend_from_slice(&MULTICODEC_ED25519_PUB); + bytes.extend_from_slice(self.as_bytes()); + format!("{}z{}", DID_KEY_PREFIX, bs58::encode(bytes).into_string()) + } + + /// Recover a public key from its `did:key` form. + pub fn from_did_key(did: &str) -> Result { + let body = did.strip_prefix(DID_KEY_PREFIX).ok_or_else(|| { + IdentityError::InvalidKey(format!("not a {DID_KEY_PREFIX} identifier: {did}")) + })?; + let multibase = body.strip_prefix('z').ok_or_else(|| { + IdentityError::InvalidKey("did:key must use base58btc multibase ('z')".to_string()) + })?; + let decoded = bs58::decode(multibase) + .into_vec() + .map_err(|_| IdentityError::InvalidKey("did:key is not valid base58btc".to_string()))?; + let key_bytes = decoded + .strip_prefix(&MULTICODEC_ED25519_PUB[..]) + .ok_or_else(|| { + IdentityError::InvalidKey("did:key is not an ed25519-pub key".to_string()) + })?; + let key_bytes: [u8; 32] = key_bytes.try_into().map_err(|_| { + IdentityError::InvalidKey("did:key payload must be a 32-byte key".to_string()) + })?; + Self::from_bytes(&key_bytes) + } + /// Decode a public key from base32 pub fn from_base32(s: &str) -> Result { let bytes = data_encoding::BASE32_NOPAD diff --git a/atomic-identity/src/lib.rs b/atomic-identity/src/lib.rs index 2803e04c..8eb3f507 100644 --- a/atomic-identity/src/lib.rs +++ b/atomic-identity/src/lib.rs @@ -101,31 +101,40 @@ //! //! # Delegation //! +//! An agent gets its own keypair; a certificate signed by the human binds the +//! two and bounds what the agent may do. Effective permission is always the +//! intersection of the human's own access and the delegation scope — an agent +//! can never exceed the identity that issued it. +//! //! ```rust //! use atomic_identity::{Identity, IdentityType}; -//! use atomic_identity::delegation::{Delegation, DelegationScope, DelegationPermission}; +//! use atomic_identity::delegation::{ +//! Delegation, DelegationPermission, DelegationScope, ResourceRef, +//! }; //! //! // Create user and agent identities //! let user = Identity::generate("alice"); -//! let agent = Identity::builder("alice-assistant") +//! let agent = Identity::builder("alice+claude") //! .identity_type(IdentityType::Agent) //! .delegated_by(user.id) //! .build()?; //! -//! // Create a delegation with specific permissions +//! // Bound the agent to two permissions on one project namespace //! let scope = DelegationScope::builder() //! .permission(DelegationPermission::Read) //! .permission(DelegationPermission::Record) -//! .repository_pattern("alice/*") +//! .project("alice/*") //! .build(); //! -//! let delegation = Delegation::new(&user, &agent, scope)?; +//! let delegation = Delegation::new(&user, &agent, scope); //! -//! // Check if an operation is allowed //! assert!(delegation.allows( //! DelegationPermission::Read, -//! Some("alice/my-project"), -//! None +//! &ResourceRef::new().project("alice/my-project"), +//! )); +//! assert!(!delegation.allows( +//! DelegationPermission::Push, +//! &ResourceRef::new().project("alice/my-project"), //! )); //! # Ok::<(), atomic_identity::IdentityError>(()) //! ``` @@ -166,6 +175,7 @@ pub use usage::IdentityUsage; // Re-export delegation types pub use delegation::{ Delegation, DelegationId, DelegationPermission, DelegationScope, DelegationScopeBuilder, + DelegationStatus, ResourceRef, }; // Re-export signing types @@ -266,10 +276,11 @@ mod tests { .permission(DelegationPermission::Record) .build(); - let delegation = Delegation::new(&user, &agent, scope).unwrap(); + let delegation = Delegation::new(&user, &agent, scope); + let anywhere = ResourceRef::new(); - assert!(delegation.allows(DelegationPermission::Read, None, None)); - assert!(delegation.allows(DelegationPermission::Record, None, None)); - assert!(!delegation.allows(DelegationPermission::Push, None, None)); + assert!(delegation.allows(DelegationPermission::Read, &anywhere)); + assert!(delegation.allows(DelegationPermission::Record, &anywhere)); + assert!(!delegation.allows(DelegationPermission::Push, &anywhere)); } } diff --git a/atomic-identity/src/store.rs b/atomic-identity/src/store.rs index 3e3b9f33..4f008497 100644 --- a/atomic-identity/src/store.rs +++ b/atomic-identity/src/store.rs @@ -357,6 +357,14 @@ impl IdentityStore { /// Secret key file name within each identity directory. const SECRET_KEY_FILE: &'static str = "secret.key"; + /// Directory holding signed delegation certificates, relative to the root. + /// + /// Certificates live inside the store rather than beside it so a store is + /// one self-contained directory to back up, copy, or point a test at. The + /// name cannot collide with an identity directory: those are BASE32_NOPAD + /// (uppercase) renderings of a 32-byte id. + const DELEGATIONS_DIR: &'static str = "delegations"; + /// Open or create the default identity store. /// /// The default location is `~/.atomic/identities/` in the user's home directory. @@ -719,6 +727,128 @@ impl IdentityStore { Ok(self.list()?.len()) } + // ----------------------------------------------------------------------- + // Delegation certificates + // + // The store deals in *documents*, not typed delegations: what is persisted + // is the exact signed JSON that `atomic-canonical` minted and that the + // server holds. Re-serializing a parsed struct would risk producing + // different bytes than the ones the proof covers, so the bytes are the + // artifact and parsing is the caller's business. + // ----------------------------------------------------------------------- + + /// Directory holding delegation certificates. + pub fn delegations_dir(&self) -> PathBuf { + self.root.join(Self::DELEGATIONS_DIR) + } + + /// Path of a certificate, by its base32 id. + fn delegation_path(&self, id: &str) -> PathBuf { + self.delegations_dir().join(format!("{id}.json")) + } + + /// Path of a revocation, by the delegation's base32 id. + fn revocation_path(&self, id: &str) -> PathBuf { + self.delegations_dir().join(format!("{id}.revocation.json")) + } + + /// Store a signed delegation certificate under its id. + /// + /// `document` is written verbatim — the bytes the proof covers. + pub fn save_delegation(&self, id: &str, document: &str) -> Result<(), IdentityError> { + let dir = self.delegations_dir(); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + fs::write(self.delegation_path(id), document)?; + Ok(()) + } + + /// Load a delegation certificate by base32 id. + pub fn load_delegation(&self, id: &str) -> Result { + let path = self.delegation_path(id); + if !path.exists() { + return Err(IdentityError::DelegationNotFound { id: id.to_string() }); + } + Ok(fs::read_to_string(path)?) + } + + /// Is a certificate stored under this id? + pub fn delegation_exists(&self, id: &str) -> bool { + self.delegation_path(id).exists() + } + + /// Every stored certificate, as `(base32 id, document)` pairs. + /// + /// Unreadable files are skipped rather than failing the whole listing: one + /// corrupt certificate should not make `atomic identity agent list` + /// unusable. + pub fn list_delegations(&self) -> Result, IdentityError> { + let dir = self.delegations_dir(); + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut out = Vec::new(); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + // Revocations sit beside certificates and share the id prefix. + if !name.ends_with(".json") || name.ends_with(".revocation.json") { + continue; + } + let id = name.trim_end_matches(".json").to_string(); + if let Ok(document) = fs::read_to_string(entry.path()) { + out.push((id, document)); + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(out) + } + + /// Delete a certificate and any revocation stored alongside it. + pub fn delete_delegation(&self, id: &str) -> Result<(), IdentityError> { + let path = self.delegation_path(id); + if !path.exists() { + return Err(IdentityError::DelegationNotFound { id: id.to_string() }); + } + fs::remove_file(path)?; + let revocation = self.revocation_path(id); + if revocation.exists() { + fs::remove_file(revocation)?; + } + Ok(()) + } + + /// Store a signed revocation for a delegation. + pub fn save_revocation(&self, id: &str, document: &str) -> Result<(), IdentityError> { + let dir = self.delegations_dir(); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + fs::write(self.revocation_path(id), document)?; + Ok(()) + } + + /// The stored revocation for a delegation, if one exists. + pub fn load_revocation(&self, id: &str) -> Result, IdentityError> { + let path = self.revocation_path(id); + if !path.exists() { + return Ok(None); + } + Ok(Some(fs::read_to_string(path)?)) + } + + /// Is a revocation recorded locally for this delegation? + /// + /// A local revocation is authoritative for refusing to *use* a delegation, + /// but never for believing one is still good: the server is the authority + /// on revocations issued elsewhere. + pub fn is_revoked_locally(&self, id: &str) -> bool { + self.revocation_path(id).exists() + } + /// Get the directory for an identity. fn identity_dir(&self, identity: &Identity) -> PathBuf { // Use a sanitized name + short ID for the directory name @@ -939,3 +1069,85 @@ mod tests { assert!(store.exists(&identity.id)); } } + +#[cfg(test)] +mod delegation_store_tests { + use super::*; + use tempfile::TempDir; + + fn store() -> (TempDir, IdentityStore) { + let dir = TempDir::new().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + (dir, store) + } + + #[test] + fn save_and_load_round_trips_bytes_verbatim() { + let (_dir, store) = store(); + // Deliberately odd whitespace: the proof covers these exact bytes, so + // the store must not normalize them. + let doc = "{\n \"@type\" : \"AgentDelegation\"\n}"; + store.save_delegation("ABC123", doc).unwrap(); + + assert!(store.delegation_exists("ABC123")); + assert_eq!(store.load_delegation("ABC123").unwrap(), doc); + } + + #[test] + fn load_missing_delegation_is_not_found() { + let (_dir, store) = store(); + match store.load_delegation("NOPE") { + Err(IdentityError::DelegationNotFound { id }) => assert_eq!(id, "NOPE"), + other => panic!("expected DelegationNotFound, got {other:?}"), + } + } + + #[test] + fn list_skips_revocations_and_sorts() { + let (_dir, store) = store(); + store.save_delegation("BBB", "{}").unwrap(); + store.save_delegation("AAA", "{}").unwrap(); + store.save_revocation("AAA", "{}").unwrap(); + + let listed: Vec = store + .list_delegations() + .unwrap() + .into_iter() + .map(|(id, _)| id) + .collect(); + assert_eq!(listed, vec!["AAA".to_string(), "BBB".to_string()]); + } + + #[test] + fn list_on_a_store_with_no_delegations_is_empty_not_an_error() { + let (_dir, store) = store(); + assert!(store.list_delegations().unwrap().is_empty()); + } + + #[test] + fn revocation_is_recorded_and_readable() { + let (_dir, store) = store(); + store.save_delegation("AAA", "{}").unwrap(); + assert!(!store.is_revoked_locally("AAA")); + assert_eq!(store.load_revocation("AAA").unwrap(), None); + + store.save_revocation("AAA", r#"{"revoked":true}"#).unwrap(); + assert!(store.is_revoked_locally("AAA")); + assert_eq!( + store.load_revocation("AAA").unwrap().as_deref(), + Some(r#"{"revoked":true}"#) + ); + } + + #[test] + fn delete_removes_the_revocation_too() { + let (_dir, store) = store(); + store.save_delegation("AAA", "{}").unwrap(); + store.save_revocation("AAA", "{}").unwrap(); + + store.delete_delegation("AAA").unwrap(); + assert!(!store.delegation_exists("AAA")); + assert!(!store.is_revoked_locally("AAA")); + assert!(store.delete_delegation("AAA").is_err()); + } +} diff --git a/atomic-remote/Cargo.toml b/atomic-remote/Cargo.toml index ed9c8a0e..cb5bed6d 100644 --- a/atomic-remote/Cargo.toml +++ b/atomic-remote/Cargo.toml @@ -45,6 +45,8 @@ uuid = { version = "1.0", features = ["serde"] } # URL encoding urlencoding = "2" +atomic-canonical = { workspace = true } + [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tempfile = { workspace = true } diff --git a/atomic-remote/src/lib.rs b/atomic-remote/src/lib.rs index 34ca020f..910d2ce8 100644 --- a/atomic-remote/src/lib.rs +++ b/atomic-remote/src/lib.rs @@ -145,8 +145,10 @@ pub use http::{HttpRemote, HttpRemoteConfig}; // Storage management client pub use storage::StorageClient; pub use storage_types::{ - ApiError, ApiResponse, CreateProjectRequest, CreateWorkspaceRequest, IdentityInfo, ProjectInfo, - ResponseMetadata, UpdateProjectRequest, UpdateWorkspaceRequest, Visibility, WorkspaceInfo, + AgentIdentityInfo, ApiError, ApiResponse, CreateProjectRequest, CreateWorkspaceRequest, + DelegationInfo, DelegationStatusInfo, EnrollAgentRequest, EpochInfo, IdentityInfo, ProjectInfo, + PushDelegationRequest, ResponseMetadata, RevokeDelegationRequest, UpdateProjectRequest, + UpdateWorkspaceRequest, Visibility, WorkspaceInfo, }; // Protocol types diff --git a/atomic-remote/src/storage.rs b/atomic-remote/src/storage.rs index 2b660b2e..d13abe06 100644 --- a/atomic-remote/src/storage.rs +++ b/atomic-remote/src/storage.rs @@ -7,13 +7,20 @@ //! //! The VCS protocol (push/pull/clone) uses `HttpRemote` instead. -use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; use serde::{de::DeserializeOwned, Serialize}; use crate::error::RemoteError; +/// The delegation header name, lowercased — `HeaderName::from_static` requires +/// it. Kept in step with `atomic_canonical::delegation::DELEGATION_HEADER` by +/// the test at the bottom of this file. +const DELEGATION_HEADER_LOWER: &str = "atomic-delegation"; + use crate::storage_types::{ - ApiResponse, CreateProjectRequest, CreateWorkspaceRequest, IdentityInfo, ProjectInfo, - UpdateProjectRequest, UpdateWorkspaceRequest, WorkspaceInfo, + AgentIdentityInfo, ApiResponse, CreateProjectRequest, CreateWorkspaceRequest, DelegationInfo, + DelegationStatusInfo, EnrollAgentRequest, EpochInfo, IdentityInfo, ProjectInfo, + PushDelegationRequest, RevokeDelegationRequest, UpdateProjectRequest, UpdateWorkspaceRequest, + WorkspaceInfo, }; /// How much of an undeserializable response body to quote in the error. @@ -77,7 +84,32 @@ impl StorageClient { /// `https://alice.atomic.storage`. The `bearer_token` is a short-lived, /// client-self-signed EdDSA JWT (see `atomic-cli`'s `commands::token`). pub fn new(base_url: &str, org_slug: &str, bearer_token: &str) -> Result { + Self::with_delegation(base_url, org_slug, bearer_token, None) + } + + /// Create a client that also presents a delegation certificate. + /// + /// `delegation` is the base64url-encoded certificate an agent acts under. + /// It travels on every request because the server verifies it there and + /// then, against the delegator's registered key — grants are presented, not + /// registered, which is what makes issuing one free of a server round trip. + /// + /// `None` is an ordinary, non-delegated client. + pub fn with_delegation( + base_url: &str, + org_slug: &str, + bearer_token: &str, + delegation: Option<&str>, + ) -> Result { let mut headers = HeaderMap::new(); + if let Some(encoded) = delegation { + headers.insert( + HeaderName::from_static(DELEGATION_HEADER_LOWER), + HeaderValue::from_str(encoded).map_err(|e| { + RemoteError::other(format!("invalid delegation certificate: {}", e)) + })?, + ); + } headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", bearer_token)) @@ -395,12 +427,149 @@ impl StorageClient { )) .await } + + // ----------------------------------------------------------------------- + // Agent identities and delegations + // + // These are apex endpoints (no org subdomain): an agent belongs to a human, + // not to an org, and the same agent may be used across every org that human + // is a member of. + // ----------------------------------------------------------------------- + + /// Enroll an agent identity under the authenticated human identity. + /// + /// The caller must be the delegator named in the certificate — the server + /// verifies the proof against the public key it has on record for the + /// caller, so a certificate signed by anyone else is rejected no matter who + /// presents it. + pub async fn enroll_agent( + &self, + req: &EnrollAgentRequest, + ) -> Result { + self.post("/identities/agents", req).await + } + + /// List the agents enrolled under the authenticated identity. + pub async fn list_agents(&self) -> Result, RemoteError> { + self.get("/identities/agents").await + } + + /// Fetch one agent by its identity UUID. + pub async fn get_agent(&self, agent_id: &str) -> Result { + self.get(&format!("/identities/agents/{agent_id}")).await + } + + /// Retire an agent: revoke its delegations and mark the key unusable. + /// + /// Past work stays attributable — the identity row is not deleted, so a + /// change recorded last month still resolves to a name rather than a + /// dangling key. + pub async fn retire_agent(&self, agent_id: &str) -> Result<(), RemoteError> { + self.delete(&format!("/identities/agents/{agent_id}")).await + } + + /// Upload a new or renewed delegation certificate. + pub async fn push_delegation( + &self, + req: &PushDelegationRequest, + ) -> Result { + self.post("/delegations", req).await + } + + /// List the delegations issued by the authenticated identity. + pub async fn list_delegations(&self) -> Result, RemoteError> { + self.get("/delegations").await + } + + /// Revoke a delegation by presenting a signed revocation document. + pub async fn revoke_delegation( + &self, + delegation_id: &str, + req: &RevokeDelegationRequest, + ) -> Result { + self.post( + &format!("/delegations/{}/revoke", urlencoding::encode(delegation_id)), + req, + ) + .await + } + + /// Bump an agent's epoch: every grant issued to it before now is dead. + /// + /// The only mechanism that reaches grants the server has never seen — which + /// is most of them, since grants are presented rather than registered. Use + /// it whenever a narrowing or a withdrawal has to take effect immediately + /// rather than when certificates happen to expire. + pub async fn set_agent_epoch(&self, agent_id: &str) -> Result { + self.post_empty(&format!("/identities/agents/{agent_id}/epoch")) + .await + } + + /// Clear an agent's epoch, bringing unexpired grants back. + pub async fn clear_agent_epoch(&self, agent_id: &str) -> Result<(), RemoteError> { + self.delete(&format!("/identities/agents/{agent_id}/epoch")) + .await + } + + /// Invalidate every grant the caller has ever issued, to every agent. + /// + /// The key-compromise button. If your signing key leaks, an attacker can + /// mint grants nobody knows about and there is no list to revoke — this is + /// the one action that covers them. + pub async fn set_delegator_epoch(&self) -> Result { + self.post_empty("/delegations/epoch").await + } + + /// Check whether a delegation is still good. + /// + /// Unauthenticated on the server side, so anyone auditing a change's + /// `delegation_id` can check it without an account. + pub async fn delegation_status( + &self, + delegation_id: &str, + ) -> Result { + self.get(&format!( + "/delegations/{}/status", + urlencoding::encode(delegation_id) + )) + .await + } } #[cfg(test)] mod tests { use super::*; + /// `HeaderName::from_static` demands lowercase, so this module cannot use + /// the canonical constant directly. Pin them together instead of hoping. + #[test] + fn the_delegation_header_matches_the_canonical_spelling() { + assert_eq!( + DELEGATION_HEADER_LOWER, + atomic_canonical::delegation::DELEGATION_HEADER.to_ascii_lowercase() + ); + } + + #[test] + fn a_client_without_a_delegation_sends_no_such_header() { + // A human's requests must be byte-identical to before agents existed. + let client = StorageClient::new("https://example.com", "acme", "tok").unwrap(); + assert_eq!(client.base_url(), "https://example.com"); + } + + #[test] + fn an_invalid_delegation_is_rejected_at_construction() { + // A newline in a header value would be a request-splitting vector, so + // it must fail here rather than at send time. + assert!(StorageClient::with_delegation( + "https://example.com", + "acme", + "tok", + Some("bad\nvalue") + ) + .is_err()); + } + #[test] fn new_trims_trailing_slash() { let client = StorageClient::new("https://example.com/", "acme", "tok").unwrap(); diff --git a/atomic-remote/src/storage_types.rs b/atomic-remote/src/storage_types.rs index bf2bf9c5..ab3f3483 100644 --- a/atomic-remote/src/storage_types.rs +++ b/atomic-remote/src/storage_types.rs @@ -128,6 +128,79 @@ pub struct IdentityInfo { pub created_at: DateTime, } +/// An agent identity enrolled under a human identity. +/// +/// Agents are never tenants: enrolling one records a key and its parent, and +/// grants nothing. Whatever the agent may do comes from the delegation +/// certificate intersected with the parent's own access. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentIdentityInfo { + pub id: uuid::Uuid, + pub name: String, + /// `did:atomic:...` for the agent's key. + pub did: String, + /// The human identity this agent acts on behalf of. + pub parent_identity_id: uuid::Uuid, + /// Software agent label, e.g. `urn:atomic:agent:claude-code`. + pub software_agent: Option, + pub status: String, + pub created_at: DateTime, + /// Grants issued to this agent before this instant are rejected. + #[serde(default)] + pub delegations_valid_from: Option>, + /// Grants a client chose to **publish**, newest first. + /// + /// Advisory and usually incomplete — publishing is optional, so this is a + /// visibility aid and never the set of valid grants. + #[serde(default)] + pub published_delegations: Vec, +} + +/// A delegation certificate as the server holds it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegationInfo { + /// `urn:atomic:delegation:`. + pub id: String, + pub delegator_identity_id: uuid::Uuid, + pub delegate_identity_id: uuid::Uuid, + /// `active` | `expired` | `revoked` | `superseded`. + pub status: String, + pub issued_at: DateTime, + pub expires_at: Option>, + /// The signed certificate itself, verbatim. Returned so a client can + /// re-verify rather than trust the parsed fields above. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certificate: Option, +} + +/// The result of setting or clearing an epoch. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EpochInfo { + pub identity_id: uuid::Uuid, + /// `None` means the epoch was cleared — grants are governed by their own + /// expiry alone again. + pub delegations_valid_from: Option>, +} + +/// Public status of a delegation, for third-party verification. +/// +/// Deliberately thin: it answers "is this still good?" for someone checking a +/// change's `delegation_id` and nothing more. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegationStatusInfo { + pub id: String, + /// Whether the grant is on the server's deny-list. + /// + /// Deliberately the only field: the endpoint is unauthenticated, so an + /// unknown id and a live one answer identically and nothing about scope, + /// parties or expiry leaks. + pub revoked: bool, +} + // --------------------------------------------------------------------------- // Request types // --------------------------------------------------------------------------- @@ -469,3 +542,37 @@ mod tests { assert!(!out.contains("default_view")); } } + +/// Request body for enrolling an agent identity. +/// +/// The certificate is the load-bearing field: the server verifies its proof +/// against the *registered* public key of the caller, which is what ties the +/// agent key to a human it already knows. `publicKey` is carried separately +/// only so the server can reject a body whose two halves disagree before doing +/// crypto. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnrollAgentRequest { + pub name: String, + pub email: Option, + /// Base32 Ed25519 public key of the agent. + pub public_key: String, + /// The signed `AgentDelegation` document. + pub certificate: serde_json::Value, +} + +/// Request body for issuing or renewing a delegation on an existing agent. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PushDelegationRequest { + /// The signed `AgentDelegation` document. + pub certificate: serde_json::Value, +} + +/// Request body for revoking a delegation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevokeDelegationRequest { + /// The signed `DelegationRevocation` document. + pub revocation: serde_json::Value, +} diff --git a/atomic-repository/src/repository/vault.rs b/atomic-repository/src/repository/vault.rs index e49c3c81..a14ca237 100644 --- a/atomic-repository/src/repository/vault.rs +++ b/atomic-repository/src/repository/vault.rs @@ -678,16 +678,23 @@ impl Repository { &change.path, entry_type, change.content.clone(), - frontmatter_json, + frontmatter_json.clone(), )?; // `vault_store` maintains only counts/merkle for Intent - // entries — it does not sync the IntentSummary status/pin. On - // a lapse, mirror the demoted status/reason into the manifest - // so lists and triage stay consistent (as vault_intent_update - // does on its path). - if let Some(new_fm_json) = lapsed_fm { - self.sync_intent_summary_after_lapse(&change.path, &new_fm_json)?; + // entries — it does not sync the IntentSummary status/pin, + // and the manifest summary is what `intent list` and triage + // read. + // + // This used to run only when a lapse rewrote the + // frontmatter, so an ordinary edit to an intent's `status:` + // landed in the entry and nowhere else: `intent show` + // reported the new status while `intent list` and every + // gate kept the stale one, forever. Mirror the effective + // frontmatter on every intent store — the lapse case is + // just the variant where `lapsed_fm` replaced it. + if entry_type == VaultEntryType::Intent { + self.sync_intent_summary_from_frontmatter(&change.path, &frontmatter_json)?; } updated_paths.push(change.path.clone()); @@ -790,7 +797,7 @@ impl Repository { /// `vault_path`, since the record path has the path, not the intent key. /// Best-effort and demote-consistent: it copies whatever the (already /// demoted) frontmatter now holds. - fn sync_intent_summary_after_lapse( + fn sync_intent_summary_from_frontmatter( &self, path: &str, new_frontmatter_json: &str, @@ -814,6 +821,28 @@ impl Repository { if let Some(status) = fm.get("status").and_then(|v| v.as_str()) { summary.status = status.to_string(); } + // Same story as status: the title lives in the frontmatter, and + // the summary is what `intent list` and the intents API render. + // Unmirrored, an intent that plainly has a title listed with none. + if let Some(title) = fm + .get("title") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|t| !t.is_empty()) + { + summary.title = title.to_string(); + } + if let Some(priority) = fm + .get("priority") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|p| !p.is_empty()) + { + summary.priority = priority.to_string(); + } + if let Some(assignee) = fm.get("assignee").and_then(|v| v.as_str()) { + summary.assignee = Some(assignee.to_string()); + } summary.done_substance_hash = fm .get("doneSubstanceHash") .and_then(|v| v.as_str()) @@ -831,6 +860,71 @@ impl Repository { } } +#[cfg(test)] +mod summary_sync_tests { + use crate::Repository; + use tempfile::tempdir; + + /// An intent's `status:` edited on disk must reach the manifest summary, + /// not just the entry. + /// + /// `intent list`, `intent update` and every triage gate read the summary. + /// While only the entry was updated, `intent show` reported the new + /// status and `intent list` reported the old one indefinitely — so a + /// review marked `done` on disk stayed `backlog` to the gate that had to + /// see it. + #[test] + fn status_edited_on_disk_reaches_the_manifest_summary() { + let dir = tempdir().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + repo.init_vault().unwrap(); + + let path = "intents/demo-s/intent.md"; + let body = ":::why\nBecause.\n:::"; + let file = dir.path().join(".vault").join(path); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + let write = |status: &str| { + std::fs::write( + &file, + format!("---\nid: DEMO-S\ntitle: Demo {status}\nstatus: {status}\n---\n{body}"), + ) + .unwrap(); + }; + + write("backlog"); + repo.vault_record_working_copy().unwrap(); + + let status_of = || -> String { + repo.vault_intent_list(None) + .unwrap() + .into_iter() + .find(|s| s.id == "DEMO-S") + .map(|s| s.status) + .unwrap_or_default() + }; + assert_eq!(status_of(), "backlog", "baseline"); + + // Edit ONLY the frontmatter status on disk, then sync again. + write("done"); + repo.vault_record_working_copy().unwrap(); + + let title_of = || -> String { + repo.vault_intent_list(None) + .unwrap() + .into_iter() + .find(|s| s.id == "DEMO-S") + .map(|s| s.title) + .unwrap_or_default() + }; + assert_eq!(title_of(), "Demo done", "the title must track the file too"); + assert_eq!( + status_of(), + "done", + "a frontmatter status edit must reach the summary the gates read" + ); + } +} + // ── Manifest helpers ──────────────────────────────────────────────────── struct CascadedIntentUpdate { diff --git a/atomic-repository/src/repository/vault_intent.rs b/atomic-repository/src/repository/vault_intent.rs index e56abadd..64b4e89a 100644 --- a/atomic-repository/src/repository/vault_intent.rs +++ b/atomic-repository/src/repository/vault_intent.rs @@ -155,14 +155,30 @@ impl Repository { .get_vault_manifest() .map_err(|e| RepositoryError::Database(e.to_string()))?; - // Set the project code on first use, derived from the project dir. + // Set the project code on first use. + // + // `ATOMIC_PROJECT_CODE` wins when set, because the directory name + // is not always the project's name: an agent sandbox roots the + // repository at the sandbox directory, so the first intent created + // inside one stamped every intent in that project `SAND::…` + // forever (the prefix is set once and kept). Callers that know the + // real project — an orchestrator working from a project slug — set + // the variable and get a stable, meaningful code. if manifest.intent_prefix.is_empty() { - let project_name = self - .root - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("vault"); - manifest.intent_prefix = VaultManifest::derive_intent_prefix(project_name); + let from_env = std::env::var("ATOMIC_PROJECT_CODE") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + let project_name = match from_env { + Some(code) => code, + None => self + .root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("vault") + .to_string(), + }; + manifest.intent_prefix = VaultManifest::derive_intent_prefix(&project_name); if manifest.intent_prefix.is_empty() { manifest.intent_prefix = "VAULT".to_string(); } diff --git a/atomic-repository/src/repository/vault_triples.rs b/atomic-repository/src/repository/vault_triples.rs index a96fa4b9..d2c1a7b4 100644 --- a/atomic-repository/src/repository/vault_triples.rs +++ b/atomic-repository/src/repository/vault_triples.rs @@ -1149,7 +1149,8 @@ fn project_intent_semantics( // (outgoing from the intent subject, cleaned on re-index/delete), so no // extra bookkeeping is needed. for r in &node.depends_on { - let target = rdf_target_to_kg_id(&r.to, manifest); + let to = canonical_ref_target(&r.to); + let target = rdf_target_to_kg_id(&to, manifest); let kind = match r.edge.as_str() { "blockedBy" => edge_kind::BLOCKED_BY, "remediates" => edge_kind::REMEDIATES, @@ -1222,6 +1223,26 @@ fn kg_label(id: &str) -> &str { id.split_once(':').map(|(_, l)| l).unwrap_or(id) } +/// Canonicalize a `:::ref{to=}` target to an intent URN. +/// +/// Every `:::ref` edge kind is intent→intent (BLOCKED_BY, DEPENDS_ON, +/// REMEDIATES, REVIEWS), so a target written without a `urn:` scheme is an +/// intent named bare — a ULID or a human key. +/// +/// Without this a bare target fell through [`rdf_target_to_kg_id`] +/// untouched and the edge landed on `01ABC…` while every reader looks for +/// `intent:01ABC…`. The edge existed and matched nothing, so triage's +/// `UNREVIEWED_CHANGE` could never clear — and the bare form is exactly +/// what `atomic intent new --review ` scaffolds. +fn canonical_ref_target(target: &str) -> String { + let t = target.trim(); + if t.starts_with("urn:") { + t.to_string() + } else { + format!("urn:atomic:intent:{t}") + } +} + fn rdf_target_to_kg_id(target: &str, manifest: Option<&VaultManifest>) -> String { if let Some(id) = target.strip_prefix("urn:atomic:memory:") { return format!("memory:{id}"); @@ -1810,6 +1831,52 @@ Remediates a defect delivered by DEMO-A.\n\ ); } + /// A `:::ref` target named BARE — no `urn:` scheme — must still land on + /// the intent node. + /// + /// This is the shape `atomic intent new --review ` scaffolds and + /// the shape triage's own remediation hint used to tell people to type. + /// It projected an edge to `01ABC…` while every reader looks for + /// `intent:01ABC…`, so the edge existed, matched nothing, and triage's + /// `UNREVIEWED_CHANGE` could never be cleared — the review gate was + /// unsatisfiable by the documented command. + #[test] + fn test_bare_ref_target_resolves_to_the_intent_node() { + let dir = tempdir().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + repo.init_vault().unwrap(); + + let fm_r = + r#"{"id":"DEMO-R","title":"Review the work","status":"in-progress","kind":"review"}"#; + // Bare target: no `urn:atomic:intent:` prefix. + let body_r = "\ +:::why\n\ +Reviews DEMO-T.\n\ +:::\n\n\ +:::ref{to=DEMO-T edge=reviews}\n\ +:::"; + repo.vault_store( + "intents/demo-r/intent.md", + VaultEntryType::Intent, + body_r.as_bytes().to_vec(), + fm_r.to_string(), + ) + .unwrap(); + + let txn = repo.pristine().read_txn().unwrap(); + let edges = txn.get_kg_edges_from("intent:DEMO-R").unwrap(); + let reviews: Vec<&str> = edges + .iter() + .filter(|e| e.kind == edge_kind::REVIEWS) + .map(|e| e.to_id.as_str()) + .collect(); + assert_eq!( + reviews, + vec!["intent:DEMO-T"], + "a bare target must be namespaced, not passed through raw" + ); + } + /// Slice-3 (b): a review intent's `:::ref{edge=reviews}` projects a /// reverse-queryable `REVIEWS` KG edge (mirroring the remediates test). #[test] diff --git a/docs/agent-identity-design.md b/docs/agent-identity-design.md new file mode 100644 index 00000000..27fc2c81 --- /dev/null +++ b/docs/agent-identity-design.md @@ -0,0 +1,653 @@ +# Agent Identity & Delegation + +How a human issues a keyed identity to an agent, bounds what that agent may do, +proves to Atomic Storage that the agent is theirs, and revokes it — with every +change the agent makes attributable to both the agent and the human behind it. + +Status: **implemented**. This document describes the design as built; where +implementation changed the design, the change and its reason are called out +inline. + +--- + +## 0. The one decision everything else follows from + +**Grants are presented, not registered.** + +A certificate is signed by a key the server already trusts — yours, from +registration — so it proves itself the moment you hand it over. The agent +carries it in a header; the server verifies it on the spot. This is the shape of +JOSE `x5c`, SPIFFE SVIDs, UCANs and macaroons, and it puts the round trip where +it belongs: + +| Operation | Frequency | Server call? | +|---|---|---| +| Register an agent identity | **once** | yes | +| Issue / extend / widen a grant | **often** | **no** | +| Revoke a grant, or a whole epoch | rare | yes | + +The consequence worth internalising: a four-hour grant scoped to one project +costs exactly as much to issue as a year-long grant scoped to everything. There +is no longer a lazy path, so short and narrow becomes the default rather than +the disciplined choice. + +Withdrawal is the asymmetry. A credential the holder possesses cannot prove its +own revocation, so that is the one thing that must reach the server — and §6a +covers what happens when it cannot. + +## 1. The shape of the thing + +Today an agent has no identity. `atomic-agent/src/identity.rs` derives an +`Author` from the human's default identity by plus-tagging the name — +`claude+60f5 ` — and signs with **the human's key**. It is a +labelling convention: legible in `log` and `blame`, worth nothing +cryptographically. The `atomic-identity` crate has a complete, unused +`Delegation` model; `AgentEnvelope.delegation_id` exists and is always `None`. + +The target: + +``` + ┌──────────────────────────┐ + │ aaron │ IdentityType::User + │ did:atomic:B2XZ… │ registered with atomic.storage → tenant + │ Ed25519 keypair │ holds org/workspace grants + └────────────┬─────────────┘ + │ signs a delegation certificate + │ (eddsa-jcs-2022 over the agent's DID + scope + expiry) + ▼ + ┌──────────────────────────┐ + │ aaron+claude │ IdentityType::Delegated + │ did:atomic:K7QF… │ enrolled, never a tenant + │ its own Ed25519 keypair │ holds NO grants of its own + └──────────────────────────┘ + │ + │ effective permissions = + │ aaron's grants ∩ delegation scope + ▼ + acme/api, acme/web · read, record, push · expires in 30d +``` + +Three invariants hold the design together: + +1. **An agent can never exceed its human.** Permissions are an + *intersection*, never a union. Revoking the human's access to a project + revokes the agent's in the same instant, with no extra bookkeeping. +2. **Possession is not authority.** Holding the agent key proves you are the + agent. It says nothing about what the agent may do — that comes from a + certificate signed by the human, checked server-side on every request. +3. **One signature format.** The delegation certificate is a canonical node + with an `eddsa-jcs-2022` Data Integrity proof, the same machinery + `atomic-canonical` already uses for intents and memories. No second + signing path to keep in sync. + +--- + +## 2. The delegation certificate + +A canonical JSON-LD node, JCS-canonicalized and signed by the delegator, using +`atomic-canonical`'s existing `proof` module. It is the whole trust story in +one file: portable, offline-verifiable, and identical on disk, on the wire, and +in the server's database. + +```json +{ + "@context": "https://atomic.dev/ns/v1", + "@type": "AgentDelegation", + "@id": "urn:atomic:delegation:9HTVQ3M8…", + + "delegator": "did:atomic:B2XZ…", + "delegatorKey": "did:key:z6MkqR…", + "delegatorName": "aaron", + + "delegate": "did:atomic:K7QF…", + "delegateKey": "did:key:z6MkfR…", + "delegateName": "aaron+claude", + "softwareAgent": "urn:atomic:agent:claude-code", + + "scope": { + "permissions": ["read", "record", "push"], + "servers": ["https://atomic.storage"], + "workspaces": ["acme"], + "projects": ["acme/api", "acme/web"], + "views": ["main", "feature/*"], + "maxChanges": 500 + }, + + "issued": "2026-09-07T18:00:00Z", + "expires": "2026-10-07T18:00:00Z", + + "contentHash": "…", + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-jcs-2022", + "verificationMethod": "did:atomic:B2XZ…#key-1", + "proofPurpose": "assertionMethod", + "created": "2026-09-07T18:00:00Z", + "proofValue": "z3FXQ…" + } +} +``` + +Both parties carry two DIDs. `did:atomic` is `base32(blake3(pubkey))` — a +fingerprint, not reversible — so the `did:key` form is carried alongside it and +the public key *is* recoverable from the certificate. + +**Changed during implementation:** the design originally carried only +`delegateKey`. That made the certificate unverifiable on a machine holding just +the agent's key — a CI runner, or anyone auditing a clone — because there was no +way to obtain the delegator's key to check the signature against, which would +have made the "offline verifiable" property in §6 untrue. `delegatorKey` fixes +it, and `atomic_canonical::delegation::verify_self_contained` is the entry point +that uses it. Note carefully what that proves: **integrity, not trust**. It +shows the document was signed by whoever holds the key it names and has not been +altered. That the key belongs to the person you think is settled by comparing +against a key you already trust — which is exactly what the server does, using +its registered copy. + +Two fields are new relative to today's `DelegationScope`: **`servers`** (a +delegation must be bounded to the server it is valid against — a certificate +minted for a staging deployment must not authenticate against production) and +**`projects`**, which replaces the more ambiguous `repository_patterns`. +`view_patterns` becomes `views`. `max_changes` survives as a client-enforced +budget with a server-side soft counter (see §8). + +### Revocation is also a signed document + +```json +{ + "@context": "https://atomic.dev/ns/v1", + "@type": "DelegationRevocation", + "delegation": "urn:atomic:delegation:9HTVQ3M8…", + "delegator": "did:atomic:B2XZ…", + "revokedAt": "2026-09-20T09:14:00Z", + "reason": "laptop lost", + "proof": { … } +} +``` + +Revocation as a signed artifact rather than a bare API call means it +replicates, audits, and verifies like everything else — and a revocation +recorded offline is still provable when it reaches a server later. + +--- + +## 3. CLI surface + +Two verbs, and the split between them is the whole model: **`agent`** is the +thing you register once, **`grant`** is the thing you issue often. + +### 3.1 Register the agent — once + +```console +$ atomic identity agent create claude --agent-type claude-code + +Created agent identity alice+claude + DID did:atomic:K7QF… + Key did:key:z6MkfR… + Delegated by alice (did:atomic:B2XZ…) + Expires 2026-10-07 (30 days) + +Registered with https://atomic.storage +``` + +Generates a keypair, creates the identity, enrolls it with the server, and +issues a first grant to get you working. Enrollment is the only part that needs +the server, and it happens once per agent per machine. + +### 3.2 Issue grants — often, and cheaply + +```console +$ atomic identity grant new alice+claude \ + --can record,push --projects acme/api --expires 4h +``` + +No server call. You signed a document; the agent can use it immediately. Because +this is free, the right habit is a short grant scoped to the work at hand rather +than a standing one scoped to everything. + +Three ways to get it to the agent, in rough order of how often you will want +them: + +```console +# 1. straight into the environment — nothing to install, nothing to clean up +$ export ATOMIC_DELEGATION=$(atomic identity grant new alice+claude --expires 1h --export) + +# 2. a file, for a machine you can drop one on +$ atomic identity grant new alice+claude --expires 8h -o grant.json +$ atomic identity grant load grant.json # on the agent's machine + +# 3. piped +$ atomic identity grant new alice+claude --export | ssh runner 'atomic identity grant load -' +``` + +`--export` writes the wire form and nothing else to stdout — the summary goes to +stderr — so command substitution captures exactly the grant. + +### 3.3 Withdraw + +```console +$ atomic identity agent revoke alice+claude --reason "laptop lost" +``` + +Bumps the agent's **epoch** (every grant issued before now is dead, including +ones nobody has a copy of) and deny-lists each grant this machine knows about, +each with a signed revocation. The epoch is the part that actually stops the +agent; the deny-list is the audit trail. + +For a compromised *signing* key, where an attacker can mint grants you will +never see: + +```console +$ atomic identity grant revoke --all-mine +``` + +### 3.4 Inspect and verify + +```console +$ atomic identity agent list +$ atomic identity agent show alice+claude +$ atomic identity grant list +$ atomic identity grant verify --offline +$ atomic identity grant publish # optional: for dashboards only +``` + +`verify --offline` matters most: given a clone and the grant, anyone can check +that a change's `delegation_id` was authorized, with no server. Revocation is +the only check that needs the network. + +`publish` is genuinely optional. A grant works the moment you sign it; publishing +only makes the server able to *show* it. + +### 3.5 Remote enrollment — a key you never hold + +```console +# on the runner +$ atomic identity new ci-runner --type agent --request-delegation > request.json +# on your machine — verifies the self-signature, then countersigns +$ atomic identity delegate --request request.json --can record,push --expires 7d -o grant.json +# back on the runner +$ atomic identity grant load grant.json +``` + +The request is self-signed by the runner's key, which is what stops you being +talked into granting to a key nobody holds. + +## 4. Local storage + +``` +~/.atomic/ +├── config.toml default_identity, [servers.*] bindings +└── identities/ the identity store root + ├── config.toml default identity, per-usage defaults + ├── / identity.toml, secret.key + ├── / identity.toml (delegated_by set), secret.key + └── delegations/ + ├── 9HTVQ3M8….json the signed certificate + └── 9HTVQ3M8….revocation.json present once revoked +``` + +**Changed during implementation:** certificates live *inside* the identity store +root rather than beside it, so a store is one self-contained directory to back +up, copy, or point a test at. There is no collision risk with an identity +directory: those are `BASE32_NOPAD` renderings of a 32-byte id, which is +uppercase-only and 52 characters. + +The store deals in **documents**, not parsed delegations: `save_delegation` +takes the exact bytes the proof covers. Re-serializing a parsed struct could +produce different bytes than the signature was made over, so the bytes are the +artifact and parsing is the caller's business. + +`IdentityStore` gains `save_delegation` / `load_delegation` / `list_delegations` +/ `delete_delegation` — it currently has none; grep for "delegation" in +`store.rs` returns nothing. + +Config gains a per-server agent binding, alongside the existing `identity` +binding that #178 taught push to honor: + +```toml +[servers.storage] +url = "https://atomic.storage" +identity = "aaron" +agent_identity = "aaron+claude" # new: what hooks sign as +``` + +--- + +## 5. Talking to Atomic Storage + +Today's mechanism (worth restating, because the design leans on all of it): +the CLI mints a **short-lived self-signed EdDSA JWT** per request, `kid` = the +caller's base32 Ed25519 public key, `sub` = the same value, 5-minute TTL, no +server login endpoint — the server resolves the registered identity by the +`kid` public key and verifies the signature against the key on record +(`atomic-cli/src/commands/token.rs`). Registration is a separate signed payload +that creates a **tenant** (`atomic identity register`). + +### 5.1 The token grows an actor claim + +RFC 8693 already has the vocabulary for "A acting on behalf of B" — the `act` +claim. The subject becomes the human; the actor becomes the agent; the signer +(and therefore `kid`) is the agent, because the agent is the one holding a key +at request time: + +```json +{ + "alg": "EdDSA", "typ": "JWT", + "kid": "" +} +{ + "sub": "", + "act": { "sub": "" }, + "dlg": "urn:atomic:delegation:9HTVQ3M8…", + "iat": …, "exp": …, "jti": "…" +} +``` + +Note the inversion against today's rule. The server's check becomes: + +- `act` absent → `kid == sub` (today's behavior, unchanged for humans) +- `act` present → `kid == act.sub`, and `sub` must be the delegator recorded on + the delegation named by `dlg` + +`dlg` pins *which* delegation authorized the call when an agent holds several, +so the audit row is unambiguous and the server never has to guess. + +### 5.2 Endpoints + +Apex-scoped: an agent belongs to a *person*, not an org, so one agent works +across every org that person belongs to. + +| Method | Path | Auth | Frequency | +|---|---|---|---| +| `POST` | `/identities/agents` | the human | **once per agent** | +| `GET` | `/identities/agents` | the human | on demand | +| `GET` | `/identities/agents/{id}` | the human | on demand | +| `DELETE` | `/identities/agents/{id}` | the human | retire | +| `POST` | `/identities/agents/{id}/epoch` | the human | **withdrawal** | +| `DELETE` | `/identities/agents/{id}/epoch` | the human | undo an epoch | +| `POST` | `/delegations/epoch` | the human | key compromise | +| `POST` | `/delegations/{id}/revoke` | the human | **withdrawal** | +| `GET` | `/delegations` | the human | listings | +| `POST` | `/delegations` | the human | *optional* publish | +| `GET` | `/delegations/{id}/status` | **none** | third-party audit | + +Note what is *absent*: there is no endpoint you must call to issue, extend or +widen a grant. `POST /delegations` exists only to publish one for visibility, +and nothing depends on it having been called. + +Every authenticated endpoint requires a **direct** call. An agent enrolling +agents, issuing itself grants, or clearing its own epoch would make a leaked key +self-perpetuating — exactly what short expiry exists to bound. + +`GET /delegations/{id}/status` is unauthenticated because someone auditing a +change's `delegation_id` may have a clone and no account. It returns a single +boolean — `revoked` — and deliberately nothing else: not scope, not parties, not +expiry, and not whether the id was ever issued. An unknown id and a live one +answer identically, so it cannot be used to enumerate anything. + +**Changed:** `POST /register` refuses a key already enrolled as an agent. +Registration mints a tenant named for the identity, so an agent getting one +would hand a delegated key its own top-level namespace that outlives any +withdrawal. + +### 5.3 The authorization algebra + +For an agent request against a resource: + +``` +allow ⟺ certificate verifies against the delegator's REGISTERED key + ∧ certificate.delegate == jwt.kid + ∧ certificate.@id == jwt.dlg (when the token names one) + ∧ certificate.delegator == jwt.sub + ∧ server_url ∈ certificate.scope.servers + ∧ now < certificate.expires + ∧ certificate.@id ∉ deny-list + ∧ certificate.issued ≥ agent epoch, and ≥ delegator epoch + ∧ parent_has(delegator, resource, action) ← existing check + ∧ scope_allows(certificate, resource, action) ← narrowing only +``` + +`scope_allows` matches the **server's** notion of the resource — the workspace +and project from the request path — never a client-asserted value. The existing +`parent_has` relation check is untouched: the agent path calls it with the +delegator's identity and then narrows. + +A useful consequence: nothing needs to happen when a human leaves an org. Their +grants disappear, the intersection empties, and every agent they issued goes +inert on the next request. + +### 5.4 Cost per request + +One extra Ed25519 verify (~50µs) and one indexed lookup for the deny-list plus +both epochs, combined into a single query. Delegated tokens deliberately bypass +the resolver cache: it cannot see a revocation or a suspended delegator, and +both are precisely what an operator revoking an agent expects to take effect +*now* rather than when a token ages out. + +The new attack surface is real and worth naming: the server now canonicalizes +and verifies caller-supplied JSON on every delegated request. It is bounded by a +hard 16KB cap checked **before** parsing — rejecting a large payload after +canonicalizing it is not a rejection — and the JCS path wants a fuzz target +before this carries production traffic. + +## 6. How we validate that an identity belongs to who + +Five links; the chain is only as good as its weakest, so each is worth stating +plainly along with what it does *not* prove. + +**1 — The human is who they claim.** Established at `atomic identity register`: +a signature over `atomic-storage:register\n{username}\n{pubkey}\n{timestamp}` +binds the username to the key. *This is trust-on-first-use* — the first key to +claim a username owns it. That is fine for a personal tenant and thin for an +org. Strengthening it is out of scope here but the hooks exist: +`DomainAliasInfo` already carries `verification_method` and +`verification_token`, so an org can prove it controls `acme.com` and thereby +that `aaron@acme.com` is theirs. + +**2 — The agent holds its key.** The agent signs its own JWT; `kid` is its +public key. Only the holder of the secret can mint a token. This proves +*possession* and nothing else. + +**3 — The agent belongs to that human.** The certificate: the parent's Ed25519 +signature over a JCS-canonical document naming the agent's DID, its scope, and +its expiry. This is the link that answers the question. It is verifiable by +anyone holding the parent's public key, with no server and no network — which +is what makes attribution in a clone meaningful rather than a claim the server +makes on your behalf. The server verifies it once at enrollment against the key +it already has on record, and trusts its own stored row thereafter. + +**4 — The authorization is still live.** Three independent facts, checked +server-side on every request: the expiry inside the signed document, the +deny-list, and both epochs. Effective permission is then re-derived from the +human's *current* grants rather than frozen at issue time. + +## 6a. Withdrawal, and why it is the only thing that must reach the server + +A grant proves itself. A withdrawal cannot — you cannot prove a negative with a +document the holder is carrying. Every bearer-credential system has this +asymmetry, which is why X.509 has CRLs and OCSP. + +So the burden inverts, which is the right way round: the frequent operation is +free, and the rare one costs a call. Three mechanisms, in increasing blast +radius: + +| | Reaches | Use when | +|---|---|---| +| **Expiry** | that certificate | always — the backstop that needs nothing | +| **Deny-list** | one certificate, by id | you know which grant to kill | +| **Epoch** | every grant issued before an instant | you don't, or there is no list | + +The epoch is not garnish. Because grants are never registered, **the server +cannot enumerate what is outstanding** — so "revoke everything for this agent" +has no list to walk. A timestamp says it instead. It is also the honest answer +when a laptop goes missing and nobody knows what it issued. + +Two scopes: + +- **Per agent** (`identities.delegations_valid_from` on the agent). The routine + tool. Narrowing a scope means issuing a tighter grant *and* bumping this, so + the old broader one dies immediately instead of lingering until its own + expiry. `atomic identity agent revoke` does both. +- **Per delegator** (the same column on the human). The key-compromise button: + if your signing key leaks, an attacker can mint grants nobody knows exist, and + this is the only action that reaches them. `atomic identity grant + revoke --all-mine`. + +An epoch can be cleared, which brings unexpired grants back. That is safe +because each is still bounded by its own expiry, and it means bumping one in +error is recoverable rather than permanent. + +### What this costs you + +Revocation is now the operation with a hard network dependency. `agent revoke` +says so explicitly when it cannot reach the server, and distinguishes the two +outcomes: grants this machine knows about are refused locally straight away, +while grants it has never seen **remain valid** until the epoch bump lands. +That is stated in the output rather than left for someone to discover. + +### Threat table + +| Threat | What stops it | +|---|---| +| Agent secret key read off disk | Scope limits it to named projects and permissions; short expiry; one-command revoke. It cannot be widened without the human's key. Because issuing is free, the grant it holds should be hours old and narrow, not a standing year-long one. | +| Agent pushes to a project outside its scope | Server matches scope against the request path, not a client claim | +| Forged delegation certificate | Requires the parent's private key — the proof covers the JCS bytes including delegate, scope, and expiry | +| Stale certificate replayed after revocation | Deny-list and both epochs checked per request; `expires` caps the window regardless | +| **Human's signing key compromised** | The attacker can mint grants the server has never seen and there is no list to revoke. The delegator epoch is the answer, and the only one — it works on time rather than identifiers | +| **Oversized or malformed certificate in the header** | 16KB cap enforced before parsing; decode and verify are separate functions so a well-formed certificate is never mistaken for a trusted one | +| Agent escalates its own permissions | No grants are ever written for agent subjects; effective = parent ∩ scope | +| Agent mints itself a tenant | `/register` rejects agent and delegated identity types | +| Agent work silently attributed to the human | Distinct DIDs in the change header and the server audit row; `blame` shows `claude+60f5` | +| Human leaves the org, agent keeps pushing | Intersection empties on the next request; no separate cleanup | +| Token replay inside the 5-minute TTL | `jti` + short TTL. **Open:** whether the server keeps a `jti` cache is unverified from this repo — see §10. | + +--- + +## 7. Changes by crate + +| Crate | File | Change | +|---|---|---| +| `atomic-identity` | `delegation.rs` | Add `servers`, rename `repository_patterns`→`projects`, `view_patterns`→`views`. Remove `signing_data()` — signing moves to `atomic-canonical` so there is one format. Keep the scope/permission types and `allows()`. | +| | `store.rs` | Delegation persistence: save/load/list/delete. Agent-key resolution order (§3.6). | +| | `identity.rs` | `software_agent` label in `IdentityMetadata`; a `parent()` accessor. | +| `atomic-canonical` | `delegation.rs` *(new)* | `AgentDelegation`, `AgentDelegationRequest`, `DelegationRevocation`: mint, verify, JCS + `eddsa-jcs-2022` via the existing `proof` module. Plus the wire encoding (`encode_for_transport`, the 16KB cap, the header name) shared by both ends. | +| | `prov.rs` | A keyed agent's `@id` becomes a real `did:key` instead of `urn:atomic:agent:` — the module comment at line 27 already anticipates exactly this. `actedOnBehalfOf` keeps pointing at the person. | +| `atomic-agent` | `identity.rs` | Prefer a delegated agent identity when one is bound; sign with *its* key. Keep the plus-tag author name and fall back to today's behavior when no agent identity exists. | +| | `envelope.rs` | Populate `delegation_id` — the field and its builder exist and are never called. | +| `atomic-cli` | `commands/token.rs` | Emit `act` and `dlg` for delegated identities | +| | `commands/auth.rs` | Resolve the agent identity (flag → env → config binding); actionable errors for expired/revoked/out-of-scope | +| | `commands/identity/agent.rs` *(new)* | `create`, `list`, `show`, `renew`, `revoke`, `retire` | +| | `commands/identity/delegate.rs` *(new)* | Mint a certificate; `--request` countersigning | +| | `commands/identity/delegation.rs` *(new)* | `install`, `push`, `list`, `show`, `verify`, `revoke` | +| | `commands/identity/register.rs` | Refuse agent/delegated identities with a pointer to the agent flow | +| | `commands/push/` | Pre-flight the delegation locally so scope and expiry failures are actionable before the network call — the pattern #93 established for credentials | +| `atomic-config` | `lib.rs` | `agent_identity` on server profiles | +| **atomic-storage** | — | §5.2 endpoints, §5.1 JWT rule, §5.3 algebra, deny-list + epochs, per-request certificate verification, audit rows | + +--- + +## 7a. What implementation changed + +Six things moved from the design as first written. Each is called out where it +applies; collected here so a reader comparing the two does not have to hunt. + +1. **Grants are presented, not registered** (§0). The first cut made the server + the registry: a certificate had to be POSTed before an agent could use it. + That put the round trip on the frequent operation and made short-lived + narrow grants *more* work than a standing broad one — precisely backwards. +2. **The deny-list and epochs replaced per-grant rows as the authorization + source** (§6a). Once grants are not registered, the server cannot enumerate + them, so withdrawal needed a primitive that works on time rather than + identifiers. +3. **`delegatorKey` added to the certificate** (§2). Without it a certificate + cannot be verified by a machine holding only the agent's key — a CI runner, + or anyone auditing a clone — which contradicted the offline-verification + property the attribution story rests on. It also turned out to be what makes + the *server's* lookup work without a registered row. +4. **Certificates live inside the identity store root** (§4), not beside it, so + a store is one directory. +5. **`DelegationScope` gained `workspaces`**. The server's hierarchy is + org → workspace → project and grants attach at workspace level; a scope that + could not name one would force enumerating every project under it. +6. **Permission mapping fails closed** (§5.3). Every server `Permission` with + no obvious delegated meaning — deletes, tenant administration, identity + management — maps to `Admin`, which nothing but an explicit `--can admin` + grants. The trap avoided is `--can push` quietly also meaning "may delete + this project". + +Two things the design specified and the implementation kept: + +- **Agents are not grant subjects.** `GrantSubjectType` is untouched. +- **`sub` inverts on delegated tokens.** The human is the effective subject and + the agent — the signer, and therefore the `kid` — is the actor. The one + definition of "who signed" lives in `TokenClaims::signing_subject`, so no call + site can verify a delegated token against the human's key by accident. + +## 8. Deferred + +- **A fuzz target for the JCS path.** The server now canonicalizes + caller-supplied JSON on every delegated request. The 16KB pre-parse cap bounds + it, but this is the one genuinely new attack surface and it should be fuzzed + before production traffic. +- **Integration tests for the route handlers.** They need live Postgres and + there is no harness for authenticated end-to-end requests. This matters more + under the presented-grant model than it did under registration: the + authorization path went from a row lookup to a seven-step verification. +- **Caching verified certificates by content hash.** `transport_fingerprint` + exists for it and the encoding is deterministic to make it possible, but + nothing caches yet. One Ed25519 verify per request is cheap; measure before + adding a cache with its own invalidation questions. +- **`maxChanges`** is in the certificate and enforced client-side. Server-side + it needs a counter per grant, which is a write on a hot path. Ship it as a + soft limit reported in `agent show`, harden later if it earns its keep. +- **Agents as grant subjects.** Explicitly excluded (§5.3). If a use case + appears for an agent that should reach something its human cannot, it needs + its own design — it is not a small extension of this one. +- **Nested delegation** (agent delegating to a sub-agent). The certificate + shape allows it; the intersection rule makes it safe in principle. No use case + yet, and the revocation semantics get considerably harder — an epoch on an + intermediate would need to cascade. + +--- + +## 9. Migration + +Nothing breaks. An identity with no grant behaves exactly as today: a JWT with +no `act` claim, no `Atomic-Delegation` header, a plus-tagged author on the +human's key, `delegation_id` null. A human's token is byte-identical to the +pre-agent format, with a test pinning that, so every deployed CLI is unaffected. + +`atomic identity agent create` is opt-in per agent per machine. A server that +has not shipped §5.2 rejects `POST /identities/agents` with 404, which the CLI +reports as "this server does not support agent identities yet" — the same +degradation pattern used for the pre-v1.4.0 tenancy-mode field in `register.rs`. + +The two repositories must land in order: the server compiles against the +client's `atomic-canonical`, so the client change has to reach `release` before +the server's CI can pass. Any future change to the shared certificate contract +will have the same red window on the server side. + +--- + +## 10. Decisions taken, and one still open + +1. **Default expiry: 30 days — but that is a ceiling, not a target.** + `agent::DEFAULT_EXPIRY_DAYS` applies when you say nothing. Since issuing + costs no server call, the intended habit is far shorter: hours, scoped to the + work at hand. The default exists so `agent create` produces something usable, + not as a recommendation. +2. **Verification cadence: every request.** The signature is re-checked against + the delegator's registered key on each delegated call. An earlier revision + verified once at registration and trusted the stored row thereafter, which + stopped making sense the moment grants stopped being registered — there is no + row to trust. The cost is one Ed25519 verify (~50µs), and it buys the + property the whole model rests on: a grant is only as good as the signature + presented with it. +3. **`jti` replay cache: still open.** Whether atomic-storage caches `jti` for + the token TTL was not determined, and this work did not add one. If it does + not, that is a pre-existing gap for humans as much as agents — a 5-minute + window on a self-signed token — and worth its own issue rather than being + folded in here. +4. **Naming: `aaron+claude`.** Mirrors the plus-tag convention + `atomic-agent/src/identity.rs` already used. `aaron/claude` reads better but + collides with slug parsing in paths and URLs.