diff --git a/crates/auths-cli/src/commands/artifact/sign.rs b/crates/auths-cli/src/commands/artifact/sign.rs index 6d164b02..e8d3492d 100644 --- a/crates/auths-cli/src/commands/artifact/sign.rs +++ b/crates/auths-cli/src/commands/artifact/sign.rs @@ -5,7 +5,9 @@ use std::sync::Arc; use auths_core::config::EnvironmentConfig; use auths_core::signing::PassphraseProvider; use auths_core::storage::keychain::KeyAlias; -use auths_sdk::signing::{ArtifactSigningParams, SigningKeyMaterial, sign_artifact}; +use auths_sdk::domains::signing::service::{ + ArtifactSigningParams, SigningKeyMaterial, sign_artifact, +}; use super::file::FileArtifact; use crate::factories::storage::build_auths_context; diff --git a/crates/auths-cli/src/commands/device/authorization.rs b/crates/auths-cli/src/commands/device/authorization.rs index 389352f1..bce9fcc0 100644 --- a/crates/auths-cli/src/commands/device/authorization.rs +++ b/crates/auths-cli/src/commands/device/authorization.rs @@ -273,7 +273,7 @@ pub fn handle_device( Some(Arc::clone(&passphrase_provider)), )?; - let result = auths_sdk::device::link_device( + let result = auths_sdk::domains::device::service::link_device( link_config, &ctx, &auths_core::ports::clock::SystemClock, @@ -300,7 +300,7 @@ pub fn handle_device( )?; let identity_key_alias = KeyAlias::new_unchecked(key); - auths_sdk::device::revoke_device( + auths_sdk::domains::device::service::revoke_device( &device_did, &identity_key_alias, &ctx, @@ -449,11 +449,12 @@ fn handle_extend( }; let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider))?; - let result = - auths_sdk::device::extend_device(config, &ctx, &auths_core::ports::clock::SystemClock) - .with_context(|| { - format!("Failed to extend device authorization for '{}'", device_did) - })?; + let result = auths_sdk::domains::device::service::extend_device( + config, + &ctx, + &auths_core::ports::clock::SystemClock, + ) + .with_context(|| format!("Failed to extend device authorization for '{}'", device_did))?; println!( "Successfully extended expiration for {} to {}", diff --git a/crates/auths-cli/src/commands/id/register.rs b/crates/auths-cli/src/commands/id/register.rs index 44a9232e..28fa88a8 100644 --- a/crates/auths-cli/src/commands/id/register.rs +++ b/crates/auths-cli/src/commands/id/register.rs @@ -9,9 +9,9 @@ use auths_id::ports::registry::RegistryBackend; use auths_id::storage::attestation::AttestationSource; use auths_id::storage::identity::IdentityStorage; use auths_infra_http::HttpRegistryClient; -use auths_sdk::error::RegistrationError; -pub use auths_sdk::registration::DEFAULT_REGISTRY_URL; -use auths_sdk::result::RegistrationOutcome; +use auths_sdk::domains::identity::error::RegistrationError; +pub use auths_sdk::domains::identity::registration::DEFAULT_REGISTRY_URL; +use auths_sdk::domains::identity::types::RegistrationOutcome; use auths_storage::git::{ GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage, }; @@ -48,14 +48,16 @@ pub fn handle_register(repo_path: &Path, registry: &str) -> Result<()> { let registry_client = HttpRegistryClient::new(); - match rt.block_on(auths_sdk::registration::register_identity( - identity_storage, - backend, - attestation_source, - registry, - None, - ®istry_client, - )) { + match rt.block_on( + auths_sdk::domains::identity::registration::register_identity( + identity_storage, + backend, + attestation_source, + registry, + None, + ®istry_client, + ), + ) { Ok(outcome) => display_registration_result(&outcome), Err(RegistrationError::AlreadyRegistered) => { bail!("Identity already registered at this registry."); diff --git a/crates/auths-cli/src/commands/init/gather.rs b/crates/auths-cli/src/commands/init/gather.rs index cb65e678..bcb5659d 100644 --- a/crates/auths-cli/src/commands/init/gather.rs +++ b/crates/auths-cli/src/commands/init/gather.rs @@ -155,14 +155,16 @@ pub(crate) fn submit_registration( let registry_client = HttpRegistryClient::new(); - match rt.block_on(auths_sdk::registration::register_identity( - identity_storage, - backend, - attestation_source, - registry_url, - proof_url, - ®istry_client, - )) { + match rt.block_on( + auths_sdk::domains::identity::registration::register_identity( + identity_storage, + backend, + attestation_source, + registry_url, + proof_url, + ®istry_client, + ), + ) { Ok(outcome) => { out.print_success(&format!("Identity registered at {}", outcome.registry)); Some(outcome.registry) @@ -216,7 +218,7 @@ pub(crate) fn ensure_registry_dir(registry_path: &Path) -> Result<()> { ) })?; } - auths_sdk::setup::install_registry_hook(registry_path); + auths_sdk::domains::identity::service::install_registry_hook(registry_path); Ok(()) } diff --git a/crates/auths-cli/src/commands/init/mod.rs b/crates/auths-cli/src/commands/init/mod.rs index 06b46960..f771f891 100644 --- a/crates/auths-cli/src/commands/init/mod.rs +++ b/crates/auths-cli/src/commands/init/mod.rs @@ -17,11 +17,12 @@ use std::sync::Arc; use auths_core::PrefilledPassphraseProvider; use auths_core::signing::StorageSigner; use auths_core::storage::keychain::KeyStorage; +use auths_sdk::domains::identity::registration::DEFAULT_REGISTRY_URL; +use auths_sdk::domains::identity::service::initialize; +use auths_sdk::domains::identity::types::IdentityConfig; +use auths_sdk::domains::identity::types::InitializeResult; +use auths_sdk::domains::signing::types::GitSigningScope; use auths_sdk::ports::git_config::GitConfigProvider; -use auths_sdk::registration::DEFAULT_REGISTRY_URL; -use auths_sdk::result::InitializeResult; -use auths_sdk::setup::initialize; -use auths_sdk::types::{GitSigningScope, IdentityConfig}; use crate::adapters::git_config::SystemGitConfigProvider; use crate::config::CliConfig; diff --git a/crates/auths-cli/src/commands/namespace.rs b/crates/auths-cli/src/commands/namespace.rs index d377b891..3ace22d4 100644 --- a/crates/auths-cli/src/commands/namespace.rs +++ b/crates/auths-cli/src/commands/namespace.rs @@ -12,8 +12,8 @@ use auths_crypto::AuthsErrorInfo; use auths_id::storage::identity::IdentityStorage; use auths_id::storage::layout; use auths_infra_http::resolve_verified_platform_context; +use auths_sdk::domains::identity::registration::DEFAULT_REGISTRY_URL; use auths_sdk::namespace_registry::NamespaceVerifierRegistry; -use auths_sdk::registration::DEFAULT_REGISTRY_URL; use auths_sdk::workflows::namespace::{ DelegateNamespaceCommand, TransferNamespaceCommand, initiate_namespace_claim, parse_claim_response, parse_lookup_response, sign_namespace_delegate, sign_namespace_transfer, diff --git a/crates/auths-cli/src/errors/renderer.rs b/crates/auths-cli/src/errors/renderer.rs index 56f670e7..c34ef883 100644 --- a/crates/auths-cli/src/errors/renderer.rs +++ b/crates/auths-cli/src/errors/renderer.rs @@ -1,10 +1,10 @@ use anyhow::Error; use auths_core::error::{AgentError, AuthsErrorInfo}; +use auths_sdk::domains::signing::service::{ArtifactSigningError, SigningError}; use auths_sdk::error::{ ApprovalError, DeviceError, DeviceExtensionError, McpAuthError, OrgError, RegistrationError, RotationError, SetupError, }; -use auths_sdk::signing::{ArtifactSigningError, SigningError}; use auths_sdk::workflows::allowed_signers::AllowedSignersError; use auths_verifier::AttestationError; use colored::Colorize; diff --git a/crates/auths-sdk/src/device.rs b/crates/auths-sdk/src/device.rs index 0844a91b..25bb7137 100644 --- a/crates/auths-sdk/src/device.rs +++ b/crates/auths-sdk/src/device.rs @@ -1,347 +1,3 @@ -use std::convert::TryInto; -use std::sync::Arc; +//! Re-exports from the device domain for backwards compatibility. -use auths_core::ports::clock::ClockProvider; -use auths_core::signing::{PassphraseProvider, SecureSigner, StorageSigner}; -use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage}; -use auths_id::attestation::create::create_signed_attestation; -use auths_id::attestation::export::AttestationSink; -use auths_id::attestation::group::AttestationGroup; -use auths_id::attestation::revoke::create_signed_revocation; -use auths_id::storage::attestation::AttestationSource; -use auths_id::storage::git_refs::AttestationMetadata; -use auths_id::storage::identity::IdentityStorage; -use auths_verifier::core::{Capability, Ed25519PublicKey, ResourceId}; -use auths_verifier::types::DeviceDID; -use chrono::{DateTime, Utc}; - -use crate::context::AuthsContext; -use crate::error::{DeviceError, DeviceExtensionError}; -use crate::result::{DeviceExtensionResult, DeviceLinkResult}; -use crate::types::{DeviceExtensionConfig, DeviceLinkConfig}; - -struct AttestationParams { - identity_did: IdentityDID, - device_did: DeviceDID, - device_public_key: Vec, - payload: Option, - meta: AttestationMetadata, - capabilities: Vec, - identity_alias: KeyAlias, - device_alias: Option, -} - -fn build_attestation_params( - config: &DeviceLinkConfig, - identity_did: IdentityDID, - device_did: DeviceDID, - device_public_key: Vec, - now: DateTime, -) -> AttestationParams { - AttestationParams { - identity_did, - device_did, - device_public_key, - payload: config.payload.clone(), - meta: AttestationMetadata { - timestamp: Some(now), - expires_at: config - .expires_in - .map(|s| now + chrono::Duration::seconds(s as i64)), - note: config.note.clone(), - }, - capabilities: config.capabilities.clone(), - identity_alias: config.identity_key_alias.clone(), - device_alias: config.device_key_alias.clone(), - } -} - -/// Links a new device to an existing identity by creating a signed attestation. -/// -/// Args: -/// * `config`: Device link parameters (identity alias, capabilities, etc.). -/// * `ctx`: Runtime context providing storage adapters, key material, and passphrase provider. -/// * `clock`: Clock provider for timestamp generation. -/// -/// Usage: -/// ```ignore -/// let result = link_device(config, &ctx, &SystemClock)?; -/// ``` -pub fn link_device( - config: DeviceLinkConfig, - ctx: &AuthsContext, - clock: &dyn ClockProvider, -) -> Result { - let now = clock.now(); - let identity = load_identity(ctx.identity_storage.as_ref())?; - let signer = StorageSigner::new(Arc::clone(&ctx.key_storage)); - let (device_did, pk_bytes) = extract_device_key( - &config, - ctx.key_storage.as_ref(), - ctx.passphrase_provider.as_ref(), - )?; - let params = build_attestation_params( - &config, - identity.controller_did, - device_did.clone(), - pk_bytes, - now, - ); - let attestation_rid = sign_and_persist_attestation( - now, - ¶ms, - &identity.storage_id, - &signer, - ctx.passphrase_provider.as_ref(), - ctx.attestation_sink.as_ref(), - )?; - - Ok(DeviceLinkResult { - device_did, - attestation_id: ResourceId::new(attestation_rid), - }) -} - -/// Revokes a device's attestation by creating a signed revocation record. -/// -/// Args: -/// * `device_did`: The DID of the device to revoke. -/// * `identity_key_alias`: Keychain alias for the identity key that will sign the revocation. -/// * `ctx`: Runtime context providing storage adapters, key material, and passphrase provider. -/// * `note`: Optional reason for revocation. -/// * `clock`: Clock provider for timestamp generation. -/// -/// Usage: -/// ```ignore -/// revoke_device("did:key:z6Mk...", "my-identity", &ctx, Some("Lost laptop"), &clock)?; -/// ``` -pub fn revoke_device( - device_did: &str, - identity_key_alias: &KeyAlias, - ctx: &AuthsContext, - note: Option, - clock: &dyn ClockProvider, -) -> Result<(), DeviceError> { - let now = clock.now(); - let identity = load_identity(ctx.identity_storage.as_ref())?; - let device_pk = find_device_public_key(ctx.attestation_source.as_ref(), device_did)?; - let signer = StorageSigner::new(Arc::clone(&ctx.key_storage)); - - let target_did = DeviceDID::from_ed25519(device_pk.as_bytes()); - - let revocation = create_signed_revocation( - &identity.storage_id, - &identity.controller_did, - &target_did, - device_pk.as_bytes(), - note, - None, - now, - &signer, - ctx.passphrase_provider.as_ref(), - identity_key_alias, - ) - .map_err(DeviceError::AttestationError)?; - - ctx.attestation_sink - .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(revocation)) - .map_err(|e| DeviceError::StorageError(e.into()))?; - - Ok(()) -} - -/// Extends the expiration of an existing device authorization by creating a new attestation. -/// -/// Loads the latest attestation for the given device DID, verifies it is not revoked, -/// then creates a new signed attestation with the extended expiry and persists it. -/// Capabilities are preserved as empty (`vec![]`) — the extension renews the grant -/// duration only, it does not change what the device is permitted to do. -/// -/// Args: -/// * `config`: Extension parameters (device DID, seconds until expiration, key aliases, registry path). -/// * `ctx`: Runtime context providing storage adapters, key material, and passphrase provider. -/// * `clock`: Clock provider for timestamp generation. -/// -/// Usage: -/// ```ignore -/// let result = extend_device(config, &ctx, &SystemClock)?; -/// ``` -pub fn extend_device( - config: DeviceExtensionConfig, - ctx: &AuthsContext, - clock: &dyn ClockProvider, -) -> Result { - let signer = StorageSigner::new(Arc::clone(&ctx.key_storage)); - - let identity = load_identity(ctx.identity_storage.as_ref()) - .map_err(|_| DeviceExtensionError::IdentityNotFound)?; - - let group = AttestationGroup::from_list( - ctx.attestation_source - .load_all_attestations() - .map_err(|e| DeviceExtensionError::StorageError(e.into()))?, - ); - - #[allow(clippy::disallowed_methods)] - // INVARIANT: config.device_did is a did:key string supplied by the CLI from an existing attestation - let device_did_obj = DeviceDID::new_unchecked(config.device_did.clone()); - let latest = - group - .latest(&device_did_obj) - .ok_or_else(|| DeviceExtensionError::NoAttestationFound { - device_did: config.device_did.clone(), - })?; - - if latest.is_revoked() { - return Err(DeviceExtensionError::AlreadyRevoked { - device_did: config.device_did.clone(), - }); - } - - let previous_expires_at = latest.expires_at; - let now = clock.now(); - let new_expires_at = now + chrono::Duration::seconds(config.expires_in as i64); - - let meta = AttestationMetadata { - note: latest.note.clone(), - timestamp: Some(now), - expires_at: Some(new_expires_at), - }; - - let extended = create_signed_attestation( - now, - &identity.storage_id, - &identity.controller_did, - &device_did_obj, - latest.device_public_key.as_bytes(), - latest.payload.clone(), - &meta, - &signer, - ctx.passphrase_provider.as_ref(), - Some(&config.identity_key_alias), - config.device_key_alias.as_ref(), - vec![], - None, - None, - ) - .map_err(DeviceExtensionError::AttestationFailed)?; - - ctx.attestation_sink - .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(extended.clone())) - .map_err(|e| DeviceExtensionError::StorageError(e.into()))?; - - ctx.attestation_sink.sync_index(&extended); - - Ok(DeviceExtensionResult { - #[allow(clippy::disallowed_methods)] // INVARIANT: config.device_did was already validated above when constructing device_did_obj - device_did: DeviceDID::new_unchecked(config.device_did), - new_expires_at, - previous_expires_at, - }) -} - -struct LoadedIdentity { - controller_did: IdentityDID, - storage_id: String, -} - -fn load_identity(identity_storage: &dyn IdentityStorage) -> Result { - let managed = identity_storage - .load_identity() - .map_err(|e| DeviceError::IdentityNotFound { - did: format!("identity load failed: {e}"), - })?; - Ok(LoadedIdentity { - controller_did: managed.controller_did, - storage_id: managed.storage_id, - }) -} - -fn extract_device_key( - config: &DeviceLinkConfig, - keychain: &(dyn KeyStorage + Send + Sync), - passphrase_provider: &dyn PassphraseProvider, -) -> Result<(DeviceDID, Vec), DeviceError> { - let alias = config - .device_key_alias - .as_ref() - .unwrap_or(&config.identity_key_alias); - - let pk_bytes = auths_core::storage::keychain::extract_public_key_bytes( - keychain, - alias, - passphrase_provider, - ) - .map_err(DeviceError::CryptoError)?; - - let device_did = DeviceDID::from_ed25519(pk_bytes.as_slice().try_into().map_err(|_| { - DeviceError::CryptoError(auths_core::AgentError::InvalidInput( - "public key is not 32 bytes".into(), - )) - })?); - - if let Some(ref expected) = config.device_did - && expected != &device_did.to_string() - { - return Err(DeviceError::DeviceDidMismatch { - expected: expected.clone(), - actual: device_did.to_string(), - }); - } - - Ok((device_did, pk_bytes)) -} - -fn sign_and_persist_attestation( - now: DateTime, - params: &AttestationParams, - rid: &str, - signer: &dyn SecureSigner, - passphrase_provider: &dyn PassphraseProvider, - attestation_sink: &dyn AttestationSink, -) -> Result { - let attestation = create_signed_attestation( - now, - rid, - ¶ms.identity_did, - ¶ms.device_did, - ¶ms.device_public_key, - params.payload.clone(), - ¶ms.meta, - signer, - passphrase_provider, - Some(¶ms.identity_alias), - params.device_alias.as_ref(), - params.capabilities.clone(), - None, - None, - ) - .map_err(DeviceError::AttestationError)?; - - let attestation_rid = attestation.rid.to_string(); - - attestation_sink - .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(attestation)) - .map_err(|e| DeviceError::StorageError(e.into()))?; - - Ok(attestation_rid) -} - -fn find_device_public_key( - attestation_source: &dyn AttestationSource, - device_did: &str, -) -> Result { - let attestations = attestation_source - .load_all_attestations() - .map_err(|e| DeviceError::StorageError(e.into()))?; - - for att in &attestations { - if att.subject.as_str() == device_did { - return Ok(att.device_public_key); - } - } - - Err(DeviceError::DeviceNotFound { - did: device_did.to_string(), - }) -} +pub use crate::domains::device::service::{extend_device, link_device, revoke_device}; diff --git a/crates/auths-sdk/src/domains/auth/error.rs b/crates/auths-sdk/src/domains/auth/error.rs new file mode 100644 index 00000000..b0b32775 --- /dev/null +++ b/crates/auths-sdk/src/domains/auth/error.rs @@ -0,0 +1,135 @@ +use auths_core::error::AuthsErrorInfo; +use thiserror::Error; + +/// Errors from trust policy resolution during verification. +/// +/// Usage: +/// ```ignore +/// match resolve_issuer_key(did, policy) { +/// Err(TrustError::UnknownIdentity { did, policy }) => { +/// eprintln!("Unknown identity under {} policy; run `auths trust add {}`", policy, did) +/// } +/// Err(e) => return Err(e.into()), +/// Ok(key) => { /* use key for verification */ } +/// } +/// ``` +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum TrustError { + /// Identity is unknown and trust policy does not permit TOFU/resolution. + #[error("Unknown identity '{did}' and trust policy is '{policy}'")] + UnknownIdentity { + /// The unknown identity DID. + did: String, + /// The policy preventing resolution (e.g., "explicit"). + policy: String, + }, + + /// Identity exists but no public key could be resolved. + #[error("Failed to resolve public key for identity {did}")] + KeyResolutionFailed { + /// The DID whose key could not be resolved. + did: String, + }, + + /// The provided roots.json or trust store is invalid. + #[error("Invalid trust store: {0}")] + InvalidTrustStore(String), + + /// TOFU prompt was required but execution is non-interactive. + #[error("TOFU trust decision required but running in non-interactive mode")] + TofuRequiresInteraction, +} + +/// Errors from MCP token exchange operations. +/// +/// Usage: +/// ```ignore +/// match result { +/// Err(McpAuthError::BridgeUnreachable(msg)) => { /* retry later */ } +/// Err(McpAuthError::InsufficientCapabilities { .. }) => { /* request fewer caps */ } +/// Err(e) => return Err(e.into()), +/// Ok(token) => { /* use token */ } +/// } +/// ``` +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum McpAuthError { + /// The OIDC bridge is unreachable. + #[error("bridge unreachable: {0}")] + BridgeUnreachable(String), + + /// The bridge returned a non-success status. + #[error("token exchange failed (HTTP {status}): {body}")] + TokenExchangeFailed { + /// HTTP status code from the bridge. + status: u16, + /// Response body. + body: String, + }, + + /// The bridge response could not be parsed. + #[error("invalid response: {0}")] + InvalidResponse(String), + + /// The bridge rejected the requested capabilities. + #[error("insufficient capabilities: requested {requested:?}")] + InsufficientCapabilities { + /// The capabilities that were requested. + requested: Vec, + /// Detail from the bridge error response. + detail: String, + }, +} + +impl AuthsErrorInfo for TrustError { + fn error_code(&self) -> &'static str { + match self { + Self::UnknownIdentity { .. } => "AUTHS-E4001", + Self::KeyResolutionFailed { .. } => "AUTHS-E4002", + Self::InvalidTrustStore(_) => "AUTHS-E4003", + Self::TofuRequiresInteraction => "AUTHS-E4004", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::UnknownIdentity { .. } => { + Some("Run `auths trust add ` or add the identity to .auths/roots.json") + } + Self::KeyResolutionFailed { .. } => { + Some("Verify the identity exists and has a valid public key registered") + } + Self::InvalidTrustStore(_) => Some( + "Check the format of your trust store (roots.json or ~/.auths/known_identities.json)", + ), + Self::TofuRequiresInteraction => { + Some("Run interactively (on a TTY) or use `auths verify --trust explicit`") + } + } + } +} + +impl AuthsErrorInfo for McpAuthError { + fn error_code(&self) -> &'static str { + match self { + Self::BridgeUnreachable(_) => "AUTHS-E5501", + Self::TokenExchangeFailed { .. } => "AUTHS-E5502", + Self::InvalidResponse(_) => "AUTHS-E5503", + Self::InsufficientCapabilities { .. } => "AUTHS-E5504", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::BridgeUnreachable(_) => Some("Check network connectivity to the OIDC bridge"), + Self::TokenExchangeFailed { .. } => Some("Verify your credentials and try again"), + Self::InvalidResponse(_) => Some( + "The OIDC bridge returned an unexpected response; verify the bridge URL and try again", + ), + Self::InsufficientCapabilities { .. } => { + Some("Request fewer capabilities or contact your administrator") + } + } + } +} diff --git a/crates/auths-sdk/src/domains/auth/mod.rs b/crates/auths-sdk/src/domains/auth/mod.rs new file mode 100644 index 00000000..e59570cf --- /dev/null +++ b/crates/auths-sdk/src/domains/auth/mod.rs @@ -0,0 +1,12 @@ +//! Auth domain services +//! +//! Trust policy resolution and MCP token exchange. + +/// Auth errors +pub mod error; +/// Auth services +pub mod service; +/// Auth types and configuration +pub mod types; + +pub use error::*; diff --git a/crates/auths-sdk/src/domains/auth/service.rs b/crates/auths-sdk/src/domains/auth/service.rs new file mode 100644 index 00000000..519dce01 --- /dev/null +++ b/crates/auths-sdk/src/domains/auth/service.rs @@ -0,0 +1 @@ +//! service for auth domain diff --git a/crates/auths-sdk/src/domains/auth/types.rs b/crates/auths-sdk/src/domains/auth/types.rs new file mode 100644 index 00000000..6dc218aa --- /dev/null +++ b/crates/auths-sdk/src/domains/auth/types.rs @@ -0,0 +1 @@ +//! types for auth domain diff --git a/crates/auths-sdk/src/domains/compliance/error.rs b/crates/auths-sdk/src/domains/compliance/error.rs new file mode 100644 index 00000000..826683ad --- /dev/null +++ b/crates/auths-sdk/src/domains/compliance/error.rs @@ -0,0 +1,68 @@ +use auths_core::error::AuthsErrorInfo; +use thiserror::Error; + +/// Errors from approval workflow operations. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ApprovalError { + /// The decision is not RequiresApproval. + #[error("decision is not RequiresApproval")] + NotApprovalRequired, + + /// Approval request not found. + #[error("approval request not found: {hash}")] + RequestNotFound { + /// The hex-encoded request hash. + hash: String, + }, + + /// Approval request expired. + #[error("approval request expired at {expires_at}")] + RequestExpired { + /// When the request expired. + expires_at: chrono::DateTime, + }, + + /// Approval JTI already used (replay attempt). + #[error("approval already used (JTI: {jti})")] + ApprovalAlreadyUsed { + /// The consumed JTI. + jti: String, + }, + + /// Approval partially applied — attestation stored but nonce/cleanup failed. + #[error("approval partially applied — attestation stored but nonce/cleanup failed: {0}")] + PartialApproval(String), + + /// A storage operation failed. + #[error("storage error: {0}")] + ApprovalStorage(#[source] crate::error::SdkStorageError), +} + +impl AuthsErrorInfo for ApprovalError { + fn error_code(&self) -> &'static str { + match self { + Self::NotApprovalRequired => "AUTHS-E5701", + Self::RequestNotFound { .. } => "AUTHS-E5702", + Self::RequestExpired { .. } => "AUTHS-E5703", + Self::ApprovalAlreadyUsed { .. } => "AUTHS-E5704", + Self::PartialApproval(_) => "AUTHS-E5705", + Self::ApprovalStorage(_) => "AUTHS-E5706", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::NotApprovalRequired => Some( + "This operation does not require approval; run it directly without the --approve flag", + ), + Self::RequestNotFound { .. } => { + Some("Run `auths approval list` to see pending requests") + } + Self::RequestExpired { .. } => Some("Submit a new approval request"), + Self::ApprovalAlreadyUsed { .. } => Some("Submit a new approval request"), + Self::PartialApproval(_) => Some("Check approval status and retry if needed"), + Self::ApprovalStorage(_) => Some("Check file permissions and disk space"), + } + } +} diff --git a/crates/auths-sdk/src/domains/compliance/mod.rs b/crates/auths-sdk/src/domains/compliance/mod.rs new file mode 100644 index 00000000..d1eb5351 --- /dev/null +++ b/crates/auths-sdk/src/domains/compliance/mod.rs @@ -0,0 +1,12 @@ +//! Compliance domain services +//! +//! Approval workflows and attestation governance. + +/// Compliance errors +pub mod error; +/// Compliance services +pub mod service; +/// Compliance types and configuration +pub mod types; + +pub use error::*; diff --git a/crates/auths-sdk/src/domains/compliance/service.rs b/crates/auths-sdk/src/domains/compliance/service.rs new file mode 100644 index 00000000..a487a8de --- /dev/null +++ b/crates/auths-sdk/src/domains/compliance/service.rs @@ -0,0 +1,108 @@ +//! Approval workflow functions. +//! +//! Three-phase design: +//! 1. `build_approval_attestation` — pure, deterministic attestation construction. +//! 2. `apply_approval` — side-effecting: consume nonce, remove pending request. +//! 3. `grant_approval` — high-level orchestrator (calls load → build → apply). + +use chrono::{DateTime, Duration, Utc}; + +use auths_policy::approval::ApprovalAttestation; +use auths_policy::types::{CanonicalCapability, CanonicalDid}; + +use crate::domains::compliance::error::ApprovalError; + +/// Config for granting an approval. +pub struct GrantApprovalConfig { + /// Hex-encoded hash of the pending request. + pub request_hash: String, + /// DID of the approver. + pub approver_did: String, + /// Optional note for the approval. + pub note: Option, +} + +/// Config for listing pending approvals. +pub struct ListApprovalsConfig { + /// Path to the repository. + pub repo_path: std::path::PathBuf, +} + +/// Result of granting an approval. +pub struct GrantApprovalResult { + /// The request hash that was approved. + pub request_hash: String, + /// DID of the approver. + pub approver_did: String, + /// The unique JTI for this approval. + pub jti: String, + /// When the approval expires. + pub expires_at: DateTime, + /// Human-readable summary of what was approved. + pub context_summary: String, +} + +/// Build an approval attestation from a pending request (pure function). +/// +/// Args: +/// * `request_hash_hex`: Hex-encoded request hash. +/// * `approver_did`: DID of the human approver. +/// * `capabilities`: Capabilities being approved. +/// * `now`: Current time. +/// * `expires_at`: When the approval expires. +/// +/// Usage: +/// ```ignore +/// let attestation = build_approval_attestation("abc123", &did, &caps, now, expires)?; +/// ``` +pub fn build_approval_attestation( + request_hash_hex: &str, + approver_did: CanonicalDid, + capabilities: Vec, + now: DateTime, + expires_at: DateTime, +) -> Result { + if now >= expires_at { + return Err(ApprovalError::RequestExpired { expires_at }); + } + + let request_hash = hex_to_hash(request_hash_hex)?; + let jti = uuid_v4(now); + + // Cap the attestation expiry to 5 minutes from now + let attestation_expires = std::cmp::min(expires_at, now + Duration::minutes(5)); + + Ok(ApprovalAttestation { + jti, + approver_did, + request_hash, + expires_at: attestation_expires, + approved_capabilities: capabilities, + }) +} + +fn hex_to_hash(hex: &str) -> Result<[u8; 32], ApprovalError> { + let bytes = hex::decode(hex).map_err(|_| ApprovalError::RequestNotFound { + hash: hex.to_string(), + })?; + if bytes.len() != 32 { + return Err(ApprovalError::RequestNotFound { + hash: hex.to_string(), + }); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Ok(arr) +} + +fn uuid_v4(now: DateTime) -> String { + let ts = now.timestamp_nanos_opt().unwrap_or_default() as u64; + format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + (ts >> 32) as u32, + (ts >> 16) & 0xffff, + ts & 0x0fff, + 0x8000 | ((ts >> 20) & 0x3fff), + ts & 0xffffffffffff, + ) +} diff --git a/crates/auths-sdk/src/domains/compliance/types.rs b/crates/auths-sdk/src/domains/compliance/types.rs new file mode 100644 index 00000000..9f6992a2 --- /dev/null +++ b/crates/auths-sdk/src/domains/compliance/types.rs @@ -0,0 +1 @@ +//! types for compliance domain diff --git a/crates/auths-sdk/src/domains/device/error.rs b/crates/auths-sdk/src/domains/device/error.rs new file mode 100644 index 00000000..36ce2119 --- /dev/null +++ b/crates/auths-sdk/src/domains/device/error.rs @@ -0,0 +1,152 @@ +use auths_core::error::AuthsErrorInfo; +use auths_verifier::types::DeviceDID; +use thiserror::Error; + +/// Errors from device linking and revocation operations. +/// +/// Usage: +/// ```ignore +/// match link_result { +/// Err(DeviceError::IdentityNotFound { did }) => { /* identity missing */ } +/// Err(e) => return Err(e.into()), +/// Ok(result) => { /* success */ } +/// } +/// ``` +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum DeviceError { + /// The identity could not be found in storage. + #[error("identity not found: {did}")] + IdentityNotFound { + /// The DID that was not found. + did: String, + }, + + /// The device could not be found in attestation records. + #[error("device not found: {did}")] + DeviceNotFound { + /// The DID of the missing device. + did: String, + }, + + /// Attestation creation or validation failed. + #[error("attestation error: {0}")] + AttestationError(#[source] auths_verifier::error::AttestationError), + + /// The device DID derived from the key does not match the expected DID. + #[error("device DID mismatch: expected {expected}, got {actual}")] + DeviceDidMismatch { + /// The expected device DID. + expected: String, + /// The actual device DID derived from the key. + actual: String, + }, + + /// A cryptographic operation failed. + #[error("crypto error: {0}")] + CryptoError(#[source] auths_core::AgentError), + + /// A storage operation failed. + #[error("storage error: {0}")] + StorageError(#[source] crate::error::SdkStorageError), +} + +/// Errors from device authorization extension operations. +/// +/// Usage: +/// ```ignore +/// match extend_result { +/// Err(DeviceExtensionError::AlreadyRevoked { device_did }) => { /* already gone */ } +/// Err(e) => return Err(e.into()), +/// Ok(result) => { /* success */ } +/// } +/// ``` +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum DeviceExtensionError { + /// The identity could not be found in storage. + #[error("identity not found")] + IdentityNotFound, + + /// No attestation exists for the specified device. + #[error("no attestation found for device {device_did}")] + NoAttestationFound { + /// The DID of the device with no attestation. + device_did: DeviceDID, + }, + + /// The device has already been revoked. + #[error("device {device_did} is already revoked")] + AlreadyRevoked { + /// The DID of the revoked device. + device_did: DeviceDID, + }, + + /// Creating a new attestation failed. + #[error("attestation creation failed: {0}")] + AttestationFailed(#[source] auths_verifier::error::AttestationError), + + /// A storage operation failed. + #[error("storage error: {0}")] + StorageError(#[source] crate::error::SdkStorageError), +} + +impl From for DeviceError { + fn from(err: auths_core::AgentError) -> Self { + DeviceError::CryptoError(err) + } +} + +impl AuthsErrorInfo for DeviceError { + fn error_code(&self) -> &'static str { + match self { + Self::IdentityNotFound { .. } => "AUTHS-E5101", + Self::DeviceNotFound { .. } => "AUTHS-E5102", + Self::AttestationError(_) => "AUTHS-E5103", + Self::DeviceDidMismatch { .. } => "AUTHS-E5105", + Self::CryptoError(e) => e.error_code(), + Self::StorageError(_) => "AUTHS-E5104", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::IdentityNotFound { .. } => Some("Run `auths init` to create an identity first"), + Self::DeviceNotFound { .. } => Some("Run `auths device list` to see linked devices"), + Self::AttestationError(_) => Some( + "The attestation operation failed; run `auths device list` to check device status", + ), + Self::DeviceDidMismatch { .. } => Some("Check that --device-did matches the key alias"), + Self::CryptoError(e) => e.suggestion(), + Self::StorageError(_) => Some("Check file permissions and disk space"), + } + } +} + +impl AuthsErrorInfo for DeviceExtensionError { + fn error_code(&self) -> &'static str { + match self { + Self::IdentityNotFound => "AUTHS-E5201", + Self::NoAttestationFound { .. } => "AUTHS-E5202", + Self::AlreadyRevoked { .. } => "AUTHS-E5203", + Self::AttestationFailed(_) => "AUTHS-E5204", + Self::StorageError(_) => "AUTHS-E5205", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::IdentityNotFound => Some("Run `auths init` to create an identity first"), + Self::NoAttestationFound { .. } => { + Some("Run `auths device link` to create an attestation for this device") + } + Self::AlreadyRevoked { .. } => Some( + "This device has been revoked and cannot be extended; link a new device with `auths device link`", + ), + Self::AttestationFailed(_) => { + Some("Failed to create the extension attestation; check key access and try again") + } + Self::StorageError(_) => Some("Check file permissions and disk space"), + } + } +} diff --git a/crates/auths-sdk/src/domains/device/mod.rs b/crates/auths-sdk/src/domains/device/mod.rs new file mode 100644 index 00000000..9e94a18c --- /dev/null +++ b/crates/auths-sdk/src/domains/device/mod.rs @@ -0,0 +1,11 @@ +//! Domain services for device. + +/// Device errors +pub mod error; +/// Device services +pub mod service; +/// Device types and configuration +pub mod types; + +pub use error::*; +pub use types::*; diff --git a/crates/auths-sdk/src/domains/device/service.rs b/crates/auths-sdk/src/domains/device/service.rs new file mode 100644 index 00000000..bc19759d --- /dev/null +++ b/crates/auths-sdk/src/domains/device/service.rs @@ -0,0 +1,348 @@ +use std::convert::TryInto; +use std::sync::Arc; + +use auths_core::ports::clock::ClockProvider; +use auths_core::signing::{PassphraseProvider, SecureSigner, StorageSigner}; +use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage}; +use auths_id::attestation::create::create_signed_attestation; +use auths_id::attestation::export::AttestationSink; +use auths_id::attestation::group::AttestationGroup; +use auths_id::attestation::revoke::create_signed_revocation; +use auths_id::storage::attestation::AttestationSource; +use auths_id::storage::git_refs::AttestationMetadata; +use auths_id::storage::identity::IdentityStorage; +use auths_verifier::core::{Capability, Ed25519PublicKey, ResourceId}; +use auths_verifier::types::DeviceDID; +use chrono::{DateTime, Utc}; + +use crate::context::AuthsContext; +use crate::domains::device::error::{DeviceError, DeviceExtensionError}; +use crate::domains::device::types::{ + DeviceExtensionConfig, DeviceExtensionResult, DeviceLinkConfig, DeviceLinkResult, +}; + +struct AttestationParams { + identity_did: IdentityDID, + device_did: DeviceDID, + device_public_key: Vec, + payload: Option, + meta: AttestationMetadata, + capabilities: Vec, + identity_alias: KeyAlias, + device_alias: Option, +} + +fn build_attestation_params( + config: &DeviceLinkConfig, + identity_did: IdentityDID, + device_did: DeviceDID, + device_public_key: Vec, + now: DateTime, +) -> AttestationParams { + AttestationParams { + identity_did, + device_did, + device_public_key, + payload: config.payload.clone(), + meta: AttestationMetadata { + timestamp: Some(now), + expires_at: config + .expires_in + .map(|s| now + chrono::Duration::seconds(s as i64)), + note: config.note.clone(), + }, + capabilities: config.capabilities.clone(), + identity_alias: config.identity_key_alias.clone(), + device_alias: config.device_key_alias.clone(), + } +} + +/// Links a new device to an existing identity by creating a signed attestation. +/// +/// Args: +/// * `config`: Device link parameters (identity alias, capabilities, etc.). +/// * `ctx`: Runtime context providing storage adapters, key material, and passphrase provider. +/// * `clock`: Clock provider for timestamp generation. +/// +/// Usage: +/// ```ignore +/// let result = link_device(config, &ctx, &SystemClock)?; +/// ``` +pub fn link_device( + config: DeviceLinkConfig, + ctx: &AuthsContext, + clock: &dyn ClockProvider, +) -> Result { + let now = clock.now(); + let identity = load_identity(ctx.identity_storage.as_ref())?; + let signer = StorageSigner::new(Arc::clone(&ctx.key_storage)); + let (device_did, pk_bytes) = extract_device_key( + &config, + ctx.key_storage.as_ref(), + ctx.passphrase_provider.as_ref(), + )?; + let params = build_attestation_params( + &config, + identity.controller_did, + device_did.clone(), + pk_bytes, + now, + ); + let attestation_rid = sign_and_persist_attestation( + now, + ¶ms, + &identity.storage_id, + &signer, + ctx.passphrase_provider.as_ref(), + ctx.attestation_sink.as_ref(), + )?; + + Ok(DeviceLinkResult { + device_did, + attestation_id: ResourceId::new(attestation_rid), + }) +} + +/// Revokes a device's attestation by creating a signed revocation record. +/// +/// Args: +/// * `device_did`: The DID of the device to revoke. +/// * `identity_key_alias`: Keychain alias for the identity key that will sign the revocation. +/// * `ctx`: Runtime context providing storage adapters, key material, and passphrase provider. +/// * `note`: Optional reason for revocation. +/// * `clock`: Clock provider for timestamp generation. +/// +/// Usage: +/// ```ignore +/// revoke_device("did:key:z6Mk...", "my-identity", &ctx, Some("Lost laptop"), &clock)?; +/// ``` +pub fn revoke_device( + device_did: &str, + identity_key_alias: &KeyAlias, + ctx: &AuthsContext, + note: Option, + clock: &dyn ClockProvider, +) -> Result<(), DeviceError> { + let now = clock.now(); + let identity = load_identity(ctx.identity_storage.as_ref())?; + let device_pk = find_device_public_key(ctx.attestation_source.as_ref(), device_did)?; + let signer = StorageSigner::new(Arc::clone(&ctx.key_storage)); + + let target_did = DeviceDID::from_ed25519(device_pk.as_bytes()); + + let revocation = create_signed_revocation( + &identity.storage_id, + &identity.controller_did, + &target_did, + device_pk.as_bytes(), + note, + None, + now, + &signer, + ctx.passphrase_provider.as_ref(), + identity_key_alias, + ) + .map_err(DeviceError::AttestationError)?; + + ctx.attestation_sink + .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(revocation)) + .map_err(|e| DeviceError::StorageError(e.into()))?; + + Ok(()) +} + +/// Extends the expiration of an existing device authorization by creating a new attestation. +/// +/// Loads the latest attestation for the given device DID, verifies it is not revoked, +/// then creates a new signed attestation with the extended expiry and persists it. +/// Capabilities are preserved as empty (`vec![]`) — the extension renews the grant +/// duration only, it does not change what the device is permitted to do. +/// +/// Args: +/// * `config`: Extension parameters (device DID, seconds until expiration, key aliases, registry path). +/// * `ctx`: Runtime context providing storage adapters, key material, and passphrase provider. +/// * `clock`: Clock provider for timestamp generation. +/// +/// Usage: +/// ```ignore +/// let result = extend_device(config, &ctx, &SystemClock)?; +/// ``` +pub fn extend_device( + config: DeviceExtensionConfig, + ctx: &AuthsContext, + clock: &dyn ClockProvider, +) -> Result { + let signer = StorageSigner::new(Arc::clone(&ctx.key_storage)); + + let identity = load_identity(ctx.identity_storage.as_ref()) + .map_err(|_| DeviceExtensionError::IdentityNotFound)?; + + let group = AttestationGroup::from_list( + ctx.attestation_source + .load_all_attestations() + .map_err(|e| DeviceExtensionError::StorageError(e.into()))?, + ); + + #[allow(clippy::disallowed_methods)] + // INVARIANT: config.device_did is a did:key string supplied by the CLI from an existing attestation + let device_did_obj = DeviceDID::new_unchecked(config.device_did.clone()); + let latest = + group + .latest(&device_did_obj) + .ok_or_else(|| DeviceExtensionError::NoAttestationFound { + device_did: config.device_did.clone(), + })?; + + if latest.is_revoked() { + return Err(DeviceExtensionError::AlreadyRevoked { + device_did: config.device_did.clone(), + }); + } + + let previous_expires_at = latest.expires_at; + let now = clock.now(); + let new_expires_at = now + chrono::Duration::seconds(config.expires_in as i64); + + let meta = AttestationMetadata { + note: latest.note.clone(), + timestamp: Some(now), + expires_at: Some(new_expires_at), + }; + + let extended = create_signed_attestation( + now, + &identity.storage_id, + &identity.controller_did, + &device_did_obj, + latest.device_public_key.as_bytes(), + latest.payload.clone(), + &meta, + &signer, + ctx.passphrase_provider.as_ref(), + Some(&config.identity_key_alias), + config.device_key_alias.as_ref(), + vec![], + None, + None, + ) + .map_err(DeviceExtensionError::AttestationFailed)?; + + ctx.attestation_sink + .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(extended.clone())) + .map_err(|e| DeviceExtensionError::StorageError(e.into()))?; + + ctx.attestation_sink.sync_index(&extended); + + Ok(DeviceExtensionResult { + #[allow(clippy::disallowed_methods)] // INVARIANT: config.device_did was already validated above when constructing device_did_obj + device_did: DeviceDID::new_unchecked(config.device_did), + new_expires_at, + previous_expires_at, + }) +} + +struct LoadedIdentity { + controller_did: IdentityDID, + storage_id: String, +} + +fn load_identity(identity_storage: &dyn IdentityStorage) -> Result { + let managed = identity_storage + .load_identity() + .map_err(|e| DeviceError::IdentityNotFound { + did: format!("identity load failed: {e}"), + })?; + Ok(LoadedIdentity { + controller_did: managed.controller_did, + storage_id: managed.storage_id, + }) +} + +fn extract_device_key( + config: &DeviceLinkConfig, + keychain: &(dyn KeyStorage + Send + Sync), + passphrase_provider: &dyn PassphraseProvider, +) -> Result<(DeviceDID, Vec), DeviceError> { + let alias = config + .device_key_alias + .as_ref() + .unwrap_or(&config.identity_key_alias); + + let pk_bytes = auths_core::storage::keychain::extract_public_key_bytes( + keychain, + alias, + passphrase_provider, + ) + .map_err(DeviceError::CryptoError)?; + + let device_did = DeviceDID::from_ed25519(pk_bytes.as_slice().try_into().map_err(|_| { + DeviceError::CryptoError(auths_core::AgentError::InvalidInput( + "public key is not 32 bytes".into(), + )) + })?); + + if let Some(ref expected) = config.device_did + && expected != &device_did.to_string() + { + return Err(DeviceError::DeviceDidMismatch { + expected: expected.clone(), + actual: device_did.to_string(), + }); + } + + Ok((device_did, pk_bytes)) +} + +fn sign_and_persist_attestation( + now: DateTime, + params: &AttestationParams, + rid: &str, + signer: &dyn SecureSigner, + passphrase_provider: &dyn PassphraseProvider, + attestation_sink: &dyn AttestationSink, +) -> Result { + let attestation = create_signed_attestation( + now, + rid, + ¶ms.identity_did, + ¶ms.device_did, + ¶ms.device_public_key, + params.payload.clone(), + ¶ms.meta, + signer, + passphrase_provider, + Some(¶ms.identity_alias), + params.device_alias.as_ref(), + params.capabilities.clone(), + None, + None, + ) + .map_err(DeviceError::AttestationError)?; + + let attestation_rid = attestation.rid.to_string(); + + attestation_sink + .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(attestation)) + .map_err(|e| DeviceError::StorageError(e.into()))?; + + Ok(attestation_rid) +} + +fn find_device_public_key( + attestation_source: &dyn AttestationSource, + device_did: &str, +) -> Result { + let attestations = attestation_source + .load_all_attestations() + .map_err(|e| DeviceError::StorageError(e.into()))?; + + for att in &attestations { + if att.subject.as_str() == device_did { + return Ok(att.device_public_key); + } + } + + Err(DeviceError::DeviceNotFound { + did: device_did.to_string(), + }) +} diff --git a/crates/auths-sdk/src/domains/device/types.rs b/crates/auths-sdk/src/domains/device/types.rs new file mode 100644 index 00000000..3af832c9 --- /dev/null +++ b/crates/auths-sdk/src/domains/device/types.rs @@ -0,0 +1,144 @@ +use auths_core::storage::keychain::KeyAlias; +use auths_verifier::Capability; +use auths_verifier::core::ResourceId; +use auths_verifier::types::DeviceDID; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Configuration for extending a device authorization's expiration. +/// +/// Args: +/// * `repo_path`: Path to the auths registry. +/// * `device_did`: The DID of the device whose authorization to extend. +/// * `expires_in`: Duration in seconds until expiration (per RFC 6749). +/// * `identity_key_alias`: Keychain alias for the identity key (for re-signing). +/// * `device_key_alias`: Keychain alias for the device key (for re-signing). +/// +/// Usage: +/// ```ignore +/// let config = DeviceExtensionConfig { +/// repo_path: PathBuf::from("/home/user/.auths"), +/// device_did: "did:key:z6Mk...".into(), +/// expires_in: 31_536_000, +/// identity_key_alias: "my-identity".into(), +/// device_key_alias: "my-device".into(), +/// }; +/// ``` +#[derive(Debug)] +pub struct DeviceExtensionConfig { + /// Path to the auths registry. + pub repo_path: PathBuf, + /// DID of the device whose authorization to extend. + pub device_did: DeviceDID, + /// Duration in seconds until expiration (per RFC 6749). + pub expires_in: u64, + /// Keychain alias for the identity signing key. + pub identity_key_alias: KeyAlias, + /// Keychain alias for the device signing key (pass `None` to skip device co-signing). + pub device_key_alias: Option, +} + +/// Configuration for linking a device to an existing identity. +/// +/// Args: +/// * `identity_key_alias`: Alias of the identity key in the keychain. +/// +/// Usage: +/// ```ignore +/// let config = DeviceLinkConfig { +/// identity_key_alias: "my-identity".into(), +/// device_key_alias: Some("macbook-pro".into()), +/// device_did: None, +/// capabilities: vec!["sign-commit".into()], +/// expires_in: Some(31_536_000), +/// note: Some("Work laptop".into()), +/// payload: None, +/// }; +/// ``` +#[derive(Debug)] +pub struct DeviceLinkConfig { + /// Alias of the identity key in the keychain. + pub identity_key_alias: KeyAlias, + /// Optional alias for the device key (defaults to identity alias). + pub device_key_alias: Option, + /// Optional pre-existing device DID (not yet supported). + pub device_did: Option, + /// Capabilities to grant to the linked device. + pub capabilities: Vec, + /// Duration in seconds until expiration (per RFC 6749). + pub expires_in: Option, + /// Optional human-readable note for the attestation. + pub note: Option, + /// Optional JSON payload to embed in the attestation. + pub payload: Option, +} + +// Result types + +/// Outcome of a successful device link operation. +/// +/// Usage: +/// ```ignore +/// let result: DeviceLinkResult = sdk.link_device(config).await?; +/// println!("Linked device {} via attestation {}", result.device_did, result.attestation_id); +/// ``` +#[derive(Debug, Clone)] +pub struct DeviceLinkResult { + /// The DID of the linked device. + pub device_did: DeviceDID, + /// The resource identifier of the created attestation. + pub attestation_id: ResourceId, +} + +/// Outcome of a successful device authorization extension. +/// +/// Usage: +/// ```ignore +/// let result: DeviceExtensionResult = extend_device(config, &ctx, &SystemClock)?; +/// println!("Extended {} until {}", result.device_did, result.new_expires_at.date_naive()); +/// ``` +#[derive(Debug, Clone)] +pub struct DeviceExtensionResult { + /// The DID of the device whose authorization was extended. + pub device_did: DeviceDID, + /// The new expiration timestamp for the device authorization. + pub new_expires_at: chrono::DateTime, + /// The previous expiration timestamp (None if the device had no expiry set). + pub previous_expires_at: Option>, +} + +/// Device readiness status for diagnostics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DeviceReadiness { + /// Device is valid and not expiring soon. + Ok, + /// Device is expiring within 7 days. + ExpiringSoon, + /// Device authorization has expired. + Expired, + /// Device has been revoked. + Revoked, +} + +/// Per-device status for reporting. +/// +/// Usage: +/// ```ignore +/// for device in report.devices { +/// println!("{}: {}", device.device_did, device.readiness); +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceStatus { + /// The device DID. + pub device_did: DeviceDID, + /// Current device readiness status. + pub readiness: DeviceReadiness, + /// Expiration timestamp, if set. + pub expires_at: Option>, + /// Seconds until expiration (RFC 6749 format). + pub expires_in: Option, + /// Revocation timestamp, if revoked. + pub revoked_at: Option>, +} diff --git a/crates/auths-sdk/src/domains/diagnostics/error.rs b/crates/auths-sdk/src/domains/diagnostics/error.rs new file mode 100644 index 00000000..ca3b13d0 --- /dev/null +++ b/crates/auths-sdk/src/domains/diagnostics/error.rs @@ -0,0 +1 @@ +//! error for diagnostics domain diff --git a/crates/auths-sdk/src/domains/diagnostics/mod.rs b/crates/auths-sdk/src/domains/diagnostics/mod.rs new file mode 100644 index 00000000..5f948616 --- /dev/null +++ b/crates/auths-sdk/src/domains/diagnostics/mod.rs @@ -0,0 +1,8 @@ +//! Domain services for diagnostics. + +pub mod error; +pub mod service; +/// Diagnostics types and configuration +pub mod types; + +pub use types::*; diff --git a/crates/auths-sdk/src/domains/diagnostics/service.rs b/crates/auths-sdk/src/domains/diagnostics/service.rs new file mode 100644 index 00000000..a1dc0ec9 --- /dev/null +++ b/crates/auths-sdk/src/domains/diagnostics/service.rs @@ -0,0 +1,119 @@ +//! Diagnostics workflow — orchestrates system health checks via injected providers. + +use crate::ports::diagnostics::{ + CheckCategory, CheckResult, ConfigIssue, CryptoDiagnosticProvider, DiagnosticError, + DiagnosticReport, GitDiagnosticProvider, +}; + +/// Orchestrates diagnostic checks without subprocess calls. +/// +/// Args: +/// * `G`: A [`GitDiagnosticProvider`] implementation. +/// * `C`: A [`CryptoDiagnosticProvider`] implementation. +/// +/// Usage: +/// ```ignore +/// let workflow = DiagnosticsWorkflow::new(posix_adapter.clone(), posix_adapter); +/// let report = workflow.run()?; +/// ``` +pub struct DiagnosticsWorkflow { + git: G, + crypto: C, +} + +impl DiagnosticsWorkflow { + /// Create a new diagnostics workflow with the given providers. + pub fn new(git: G, crypto: C) -> Self { + Self { git, crypto } + } + + /// Names of all available checks. + pub fn available_checks() -> &'static [&'static str] { + &["git_version", "ssh_keygen", "git_signing_config"] + } + + /// Run a single diagnostic check by name. + /// + /// Returns `Err(DiagnosticError::CheckNotFound)` if the name is unknown. + pub fn run_single(&self, name: &str) -> Result { + match name { + "git_version" => self.git.check_git_version(), + "ssh_keygen" => self.crypto.check_ssh_keygen_available(), + "git_signing_config" => { + let mut checks = Vec::new(); + self.check_git_signing_config(&mut checks)?; + checks + .into_iter() + .next() + .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) + } + _ => Err(DiagnosticError::CheckNotFound(name.to_string())), + } + } + + /// Run all diagnostic checks and return the aggregated report. + /// + /// Usage: + /// ```ignore + /// let report = workflow.run()?; + /// assert!(report.checks.iter().all(|c| c.passed)); + /// ``` + pub fn run(&self) -> Result { + let mut checks = Vec::new(); + + checks.push(self.git.check_git_version()?); + checks.push(self.crypto.check_ssh_keygen_available()?); + + self.check_git_signing_config(&mut checks)?; + + Ok(DiagnosticReport { checks }) + } + + fn check_git_signing_config( + &self, + checks: &mut Vec, + ) -> Result<(), DiagnosticError> { + let required = [ + ("gpg.format", "ssh"), + ("commit.gpgsign", "true"), + ("tag.gpgsign", "true"), + ]; + let presence_only = ["user.signingkey", "gpg.ssh.program"]; + + let mut issues: Vec = Vec::new(); + + for (key, expected) in &required { + match self.git.get_git_config(key)? { + Some(val) if val == *expected => {} + Some(actual) => { + issues.push(ConfigIssue::Mismatch { + key: key.to_string(), + expected: expected.to_string(), + actual, + }); + } + None => { + issues.push(ConfigIssue::Absent(key.to_string())); + } + } + } + + for key in &presence_only { + if self.git.get_git_config(key)?.is_none() { + issues.push(ConfigIssue::Absent(key.to_string())); + } + } + + let passed = issues.is_empty(); + + checks.push(CheckResult { + name: "Git signing config".to_string(), + passed, + message: None, + config_issues: issues, + category: CheckCategory::Critical, + }); + + Ok(()) + } +} diff --git a/crates/auths-sdk/src/domains/diagnostics/types.rs b/crates/auths-sdk/src/domains/diagnostics/types.rs new file mode 100644 index 00000000..d2248a7c --- /dev/null +++ b/crates/auths-sdk/src/domains/diagnostics/types.rs @@ -0,0 +1,54 @@ +use auths_core::storage::keychain::{IdentityDID, KeyAlias}; +use serde::{Deserialize, Serialize}; + +/// Identity status for status report. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentityStatus { + /// The controller DID. + pub controller_did: IdentityDID, + /// Key aliases available in keychain. + pub key_aliases: Vec, +} + +/// Agent status for status report. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentStatus { + /// Whether the agent is currently running. + pub running: bool, + /// Process ID if running. + pub pid: Option, + /// Socket path if running. + pub socket_path: Option, +} + +/// Next step recommendation for users. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NextStep { + /// Summary of what to do. + pub summary: String, + /// Command to run. + pub command: String, +} + +/// Full status report combining identity, devices, and agent state. +/// +/// Usage: +/// ```ignore +/// let report = StatusWorkflow::query(&ctx, now)?; +/// println!("Identity: {}", report.identity.controller_did); +/// println!("Devices: {} linked", report.devices.len()); +/// for step in report.next_steps { +/// println!("Try: {}", step.command); +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusReport { + /// Current identity status, if initialized. + pub identity: Option, + /// Per-device authorization status. + pub devices: Vec, + /// Agent/SSH-agent status. + pub agent: AgentStatus, + /// Suggested next steps for the user. + pub next_steps: Vec, +} diff --git a/crates/auths-sdk/src/domains/identity/error.rs b/crates/auths-sdk/src/domains/identity/error.rs new file mode 100644 index 00000000..8e5b1978 --- /dev/null +++ b/crates/auths-sdk/src/domains/identity/error.rs @@ -0,0 +1,258 @@ +use auths_core::error::AuthsErrorInfo; +use thiserror::Error; + +/// Errors from identity setup operations (developer, CI, agent). +/// +/// Usage: +/// ```ignore +/// match sdk_result { +/// Err(SetupError::IdentityAlreadyExists { did }) => { /* reuse or abort */ } +/// Err(e) => return Err(e.into()), +/// Ok(result) => { /* success */ } +/// } +/// ``` +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SetupError { + /// An identity already exists at the configured path. + #[error("identity already exists: {did}")] + IdentityAlreadyExists { + /// The DID of the existing identity. + did: String, + }, + + /// The platform keychain is unavailable or inaccessible. + #[error("keychain unavailable ({backend}): {reason}")] + KeychainUnavailable { + /// The keychain backend name (e.g. "macOS Keychain"). + backend: String, + /// The reason the keychain is unavailable. + reason: String, + }, + + /// A cryptographic operation failed. + #[error("crypto error: {0}")] + CryptoError(#[source] auths_core::AgentError), + + /// A storage operation failed. + #[error("storage error: {0}")] + StorageError(#[source] crate::error::SdkStorageError), + + /// Setting a git configuration key failed. + #[error("git config error: {0}")] + GitConfigError(#[source] crate::ports::git_config::GitConfigError), + + /// Setup configuration parameters are invalid. + #[error("invalid setup config: {0}")] + InvalidSetupConfig(String), + + /// Remote registry registration failed. + #[error("registration failed: {0}")] + RegistrationFailed(#[source] RegistrationError), + + /// Platform identity verification failed. + #[error("platform verification failed: {0}")] + PlatformVerificationFailed(String), +} + +/// Errors from identity rotation operations. +/// +/// Usage: +/// ```ignore +/// match rotate_result { +/// Err(RotationError::KelHistoryFailed(msg)) => { /* no prior events */ } +/// Err(e) => return Err(e.into()), +/// Ok(result) => { /* success */ } +/// } +/// ``` +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum RotationError { + /// The identity was not found at the expected path. + #[error("identity not found at {path}")] + IdentityNotFound { + /// The filesystem path where the identity was expected. + path: std::path::PathBuf, + }, + + /// The requested key alias was not found in the keychain. + #[error("key not found: {0}")] + KeyNotFound(String), + + /// Decrypting the key material failed (e.g. wrong passphrase). + #[error("key decryption failed: {0}")] + KeyDecryptionFailed(String), + + /// Reading or validating the KEL history failed. + #[error("KEL history error: {0}")] + KelHistoryFailed(String), + + /// The rotation operation failed. + #[error("rotation failed: {0}")] + RotationFailed(String), + + /// KEL event was written but the new key could not be persisted to the keychain. + /// Recovery: re-run rotation with the same new key to replay the keychain write. + #[error( + "rotation event committed to KEL but keychain write failed — manual recovery required: {0}" + )] + PartialRotation(String), +} + +/// Errors from remote registry operations. +/// +/// Usage: +/// ```ignore +/// match register_result { +/// Err(RegistrationError::AlreadyRegistered) => { /* skip */ } +/// Err(RegistrationError::QuotaExceeded) => { /* retry later */ } +/// Err(e) => return Err(e.into()), +/// Ok(outcome) => { /* success */ } +/// } +/// ``` +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum RegistrationError { + /// The identity is already registered at the target registry. + #[error("identity already registered at this registry")] + AlreadyRegistered, + + /// The registration rate limit has been exceeded. + #[error("registration quota exceeded — try again later")] + QuotaExceeded, + + /// A network error occurred during registration. + #[error("network error: {0}")] + NetworkError(#[source] auths_core::ports::network::NetworkError), + + /// The local DID format is invalid. + #[error("invalid DID format: {did}")] + InvalidDidFormat { + /// The DID that failed validation. + did: String, + }, + + /// Loading the local identity failed. + #[error("identity load error: {0}")] + IdentityLoadError(#[source] auths_id::error::StorageError), + + /// Reading from the local registry failed. + #[error("registry read error: {0}")] + RegistryReadError(#[source] auths_id::storage::registry::backend::RegistryError), + + /// Serialization of identity data failed. + #[error("serialization error: {0}")] + SerializationError(#[source] serde_json::Error), +} + +impl From for SetupError { + fn from(err: auths_core::AgentError) -> Self { + SetupError::CryptoError(err) + } +} + +impl From for SetupError { + fn from(err: RegistrationError) -> Self { + SetupError::RegistrationFailed(err) + } +} + +impl From for RegistrationError { + fn from(err: auths_core::ports::network::NetworkError) -> Self { + RegistrationError::NetworkError(err) + } +} + +impl AuthsErrorInfo for SetupError { + fn error_code(&self) -> &'static str { + match self { + Self::IdentityAlreadyExists { .. } => "AUTHS-E5001", + Self::KeychainUnavailable { .. } => "AUTHS-E5002", + Self::CryptoError(e) => e.error_code(), + Self::StorageError(_) => "AUTHS-E5003", + Self::GitConfigError(_) => "AUTHS-E5004", + Self::InvalidSetupConfig(_) => "AUTHS-E5007", + Self::RegistrationFailed(_) => "AUTHS-E5005", + Self::PlatformVerificationFailed(_) => "AUTHS-E5006", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::IdentityAlreadyExists { .. } => { + Some("Use `auths id show` to inspect the existing identity") + } + Self::KeychainUnavailable { .. } => { + Some("Run `auths doctor` to diagnose keychain issues") + } + Self::CryptoError(e) => e.suggestion(), + Self::StorageError(_) => Some("Check file permissions and disk space"), + Self::GitConfigError(_) => { + Some("Ensure Git is configured: git config --global user.name/email") + } + Self::InvalidSetupConfig(_) => Some("Check identity setup configuration parameters"), + Self::RegistrationFailed(_) => Some("Check network connectivity and try again"), + Self::PlatformVerificationFailed(_) => Some( + "Platform identity verification failed; check your platform credentials and network connectivity", + ), + } + } +} + +impl AuthsErrorInfo for RotationError { + fn error_code(&self) -> &'static str { + match self { + Self::IdentityNotFound { .. } => "AUTHS-E5301", + Self::KeyNotFound(_) => "AUTHS-E5302", + Self::KeyDecryptionFailed(_) => "AUTHS-E5303", + Self::KelHistoryFailed(_) => "AUTHS-E5304", + Self::RotationFailed(_) => "AUTHS-E5305", + Self::PartialRotation(_) => "AUTHS-E5306", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::IdentityNotFound { .. } => Some("Run `auths init` to create an identity first"), + Self::KeyNotFound(_) => Some("Run `auths key list` to see available keys"), + Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"), + Self::KelHistoryFailed(_) => Some("Run `auths doctor` to check KEL integrity"), + Self::RotationFailed(_) => Some( + "Key rotation failed; verify your current key is accessible with `auths key list`", + ), + Self::PartialRotation(_) => { + Some("Re-run the rotation with the same new key to complete the keychain write") + } + } + } +} + +impl AuthsErrorInfo for RegistrationError { + fn error_code(&self) -> &'static str { + match self { + Self::AlreadyRegistered => "AUTHS-E5401", + Self::QuotaExceeded => "AUTHS-E5402", + Self::NetworkError(e) => e.error_code(), + Self::InvalidDidFormat { .. } => "AUTHS-E5403", + Self::IdentityLoadError(_) => "AUTHS-E5404", + Self::RegistryReadError(_) => "AUTHS-E5405", + Self::SerializationError(_) => "AUTHS-E5406", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::AlreadyRegistered => Some( + "This identity is already registered; use `auths id show` to see registration details", + ), + Self::QuotaExceeded => Some("Wait a few minutes and try again"), + Self::NetworkError(e) => e.suggestion(), + Self::InvalidDidFormat { .. } => { + Some("Run `auths doctor` to check local identity data") + } + Self::IdentityLoadError(_) => Some("Run `auths doctor` to check local identity data"), + Self::RegistryReadError(_) => Some("Run `auths doctor` to check local identity data"), + Self::SerializationError(_) => Some("Run `auths doctor` to check local identity data"), + } + } +} diff --git a/crates/auths-sdk/src/domains/identity/mod.rs b/crates/auths-sdk/src/domains/identity/mod.rs new file mode 100644 index 00000000..0f278662 --- /dev/null +++ b/crates/auths-sdk/src/domains/identity/mod.rs @@ -0,0 +1,19 @@ +//! Identity domain services +//! +//! Provisions, rotates, and manages developer, CI, and agent identities. + +/// Identity errors +pub mod error; +/// Identity provisioning workflows +pub mod provision; +/// Identity registration on remote registries +pub mod registration; +/// Identity key rotation +pub mod rotation; +/// Identity services +pub mod service; +/// Identity types and configuration +pub mod types; + +pub use error::*; +pub use types::*; diff --git a/crates/auths-sdk/src/domains/identity/provision.rs b/crates/auths-sdk/src/domains/identity/provision.rs new file mode 100644 index 00000000..d522fe5d --- /dev/null +++ b/crates/auths-sdk/src/domains/identity/provision.rs @@ -0,0 +1,183 @@ +//! Declarative provisioning workflow for enterprise node setup. +//! +//! Receives a pre-deserialized `NodeConfig` and reconciles the node's identity +//! state. All I/O (TOML loading, env expansion) is handled by the caller. + +use std::collections::HashMap; +use std::sync::Arc; + +use auths_core::signing::PassphraseProvider; +use auths_core::storage::keychain::{KeyAlias, KeyStorage}; +use auths_id::{ + identity::initialize::initialize_registry_identity, + ports::registry::RegistryBackend, + storage::identity::IdentityStorage, + witness_config::{WitnessConfig, WitnessPolicy}, +}; +use serde::Deserialize; + +/// Top-level node configuration for declarative provisioning. +#[derive(Debug, Deserialize)] +pub struct NodeConfig { + /// Identity configuration section. + pub identity: IdentityConfig, + /// Optional witness configuration section. + pub witness: Option, +} + +/// Identity section of the node configuration. +#[derive(Debug, Deserialize)] +pub struct IdentityConfig { + /// Key alias for storing the generated private key. + #[serde(default = "default_key_alias")] + pub key_alias: String, + + /// Path to the Git repository storing identity data. + #[serde(default = "default_repo_path")] + pub repo_path: String, + + /// Storage layout preset (default, radicle, gitoxide). + #[serde(default = "default_preset")] + pub preset: String, + + /// Optional metadata key-value pairs attached to the identity. + #[serde(default)] + pub metadata: HashMap, +} + +/// Witness section of the node configuration (TOML-friendly view). +#[derive(Debug, Deserialize)] +pub struct WitnessOverride { + /// Witness server URLs. + #[serde(default)] + pub urls: Vec, + + /// Minimum witness receipts required (k-of-n threshold). + #[serde(default = "default_threshold")] + pub threshold: usize, + + /// Per-witness timeout in milliseconds. + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, + + /// Witness policy: `enforce`, `warn`, or `skip`. + #[serde(default = "default_policy")] + pub policy: String, +} + +fn default_key_alias() -> String { + "main".to_string() +} + +fn default_repo_path() -> String { + auths_core::paths::auths_home() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "~/.auths".to_string()) +} + +fn default_preset() -> String { + "default".to_string() +} + +fn default_threshold() -> usize { + 1 +} + +fn default_timeout_ms() -> u64 { + 5000 +} + +fn default_policy() -> String { + "enforce".to_string() +} + +/// Result of a successful provisioning run. +#[derive(Debug)] +pub struct ProvisionResult { + /// The controller DID of the newly provisioned identity. + pub controller_did: String, + /// The keychain alias under which the signing key was stored. + pub key_alias: KeyAlias, +} + +/// Errors from the provisioning workflow. +#[derive(Debug, thiserror::Error)] +pub enum ProvisionError { + /// The platform keychain could not be accessed. + #[error("failed to access platform keychain: {0}")] + KeychainUnavailable(String), + + /// The identity initialization step failed. + #[error("failed to initialize identity: {0}")] + IdentityInit(String), + + /// An identity already exists and `force` was not set. + #[error("identity already exists (use force=true to overwrite)")] + IdentityExists, +} + +/// Check for an existing identity and create one if absent (or if force=true). +/// +/// Args: +/// * `config`: The resolved node configuration. +/// * `force`: Overwrite an existing identity when true. +/// * `passphrase_provider`: Provider used to encrypt the generated key. +/// * `keychain`: Platform keychain for key storage. +/// * `registry`: Pre-initialized registry backend. +/// * `identity_storage`: Pre-initialized identity storage adapter. +/// +/// Usage: +/// ```ignore +/// let result = enforce_identity_state( +/// &config, false, passphrase_provider.as_ref(), keychain.as_ref(), registry, identity_storage, +/// )?; +/// println!("DID: {}", result.controller_did); +/// ``` +pub fn enforce_identity_state( + config: &NodeConfig, + force: bool, + passphrase_provider: &dyn PassphraseProvider, + keychain: &(dyn KeyStorage + Send + Sync), + registry: Arc, + identity_storage: Arc, +) -> Result, ProvisionError> { + if identity_storage.load_identity().is_ok() && !force { + return Ok(None); + } + + let witness_config = build_witness_config(config.witness.as_ref()); + + let alias = KeyAlias::new_unchecked(&config.identity.key_alias); + let (controller_did, key_alias) = initialize_registry_identity( + registry, + &alias, + passphrase_provider, + keychain, + witness_config.as_ref(), + ) + .map_err(|e| ProvisionError::IdentityInit(e.to_string()))?; + + Ok(Some(ProvisionResult { + controller_did: controller_did.into_inner(), + key_alias, + })) +} + +fn build_witness_config(witness: Option<&WitnessOverride>) -> Option { + let w = witness?; + if w.urls.is_empty() { + return None; + } + let policy = match w.policy.as_str() { + "warn" => WitnessPolicy::Warn, + "skip" => WitnessPolicy::Skip, + _ => WitnessPolicy::Enforce, + }; + Some(WitnessConfig { + witness_urls: w.urls.iter().filter_map(|u| u.parse().ok()).collect(), + threshold: w.threshold, + timeout_ms: w.timeout_ms, + policy, + ..Default::default() + }) +} diff --git a/crates/auths-sdk/src/domains/identity/registration.rs b/crates/auths-sdk/src/domains/identity/registration.rs new file mode 100644 index 00000000..f3d1b873 --- /dev/null +++ b/crates/auths-sdk/src/domains/identity/registration.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use auths_core::ports::network::{NetworkError, RegistryClient}; +use auths_id::ports::registry::RegistryBackend; +use auths_id::storage::attestation::AttestationSource; +use auths_id::storage::identity::IdentityStorage; +use auths_verifier::IdentityDID; +use auths_verifier::keri::Prefix; + +use crate::domains::identity::error::RegistrationError; +use crate::domains::identity::types::RegistrationOutcome; + +/// Default registry URL used when no explicit registry endpoint is configured. +pub const DEFAULT_REGISTRY_URL: &str = "https://auths-registry.fly.dev"; + +#[derive(Serialize)] +struct RegistryOnboardingPayload { + inception_event: serde_json::Value, + attestations: Vec, + proof_url: Option, +} + +#[derive(Deserialize)] +struct RegistrationResponse { + did: IdentityDID, + platform_claims_indexed: usize, +} + +/// Registers a local identity with a remote registry for public discovery. +/// +/// Args: +/// * `identity_storage`: Storage adapter for loading the local identity. +/// * `registry`: Registry backend for reading KEL events. +/// * `attestation_source`: Source for loading local attestations. +/// * `registry_url`: Base URL of the target registry. +/// * `proof_url`: Optional URL to a platform proof (e.g., GitHub gist). +/// * `registry_client`: Network client for communicating with the registry. +/// +/// Usage: +/// ```ignore +/// let outcome = register_identity( +/// identity_storage, registry, attestation_source, +/// "https://auths-registry.fly.dev", None, &http_client, +/// ).await?; +/// ``` +pub async fn register_identity( + identity_storage: Arc, + registry: Arc, + attestation_source: Arc, + registry_url: &str, + proof_url: Option, + registry_client: &impl RegistryClient, +) -> Result { + let identity = identity_storage + .load_identity() + .map_err(RegistrationError::IdentityLoadError)?; + + let prefix = Prefix::from_did(&identity.controller_did).map_err(|_| { + RegistrationError::InvalidDidFormat { + did: identity.controller_did.to_string(), + } + })?; + let inception = registry + .get_event(&prefix, 0) + .map_err(RegistrationError::RegistryReadError)?; + let inception_event = + serde_json::to_value(&inception).map_err(RegistrationError::SerializationError)?; + + let attestations = attestation_source + .load_all_attestations() + .unwrap_or_default(); + let attestation_values: Vec = attestations + .iter() + .filter_map(|a| serde_json::to_value(a).ok()) + .collect(); + + let payload = RegistryOnboardingPayload { + inception_event, + attestations: attestation_values, + proof_url, + }; + + let json_body = serde_json::to_vec(&payload).map_err(RegistrationError::SerializationError)?; + + let registry_url = registry_url.trim_end_matches('/'); + let response = registry_client + .post_json(registry_url, "v1/identities", &json_body) + .await + .map_err(RegistrationError::NetworkError)?; + + match response.status { + 201 => { + let body: RegistrationResponse = + serde_json::from_slice(&response.body).map_err(|e| { + RegistrationError::NetworkError(NetworkError::InvalidResponse { + detail: e.to_string(), + }) + })?; + + Ok(RegistrationOutcome { + did: body.did, + registry: registry_url.to_string(), + platform_claims_indexed: body.platform_claims_indexed, + }) + } + 409 => Err(RegistrationError::AlreadyRegistered), + 429 => Err(RegistrationError::QuotaExceeded), + _ => { + let body = String::from_utf8_lossy(&response.body); + Err(RegistrationError::NetworkError( + NetworkError::InvalidResponse { + detail: format!("Registry error ({}): {}", response.status, body), + }, + )) + } + } +} diff --git a/crates/auths-sdk/src/domains/identity/rotation.rs b/crates/auths-sdk/src/domains/identity/rotation.rs new file mode 100644 index 00000000..7e406ca8 --- /dev/null +++ b/crates/auths-sdk/src/domains/identity/rotation.rs @@ -0,0 +1,840 @@ +//! Identity rotation workflow. +//! +//! Three-phase design: +//! 1. `compute_rotation_event` — pure, deterministic RotEvent construction. +//! 2. `apply_rotation` — side-effecting KEL append + keychain write. +//! 3. `rotate_identity` — high-level orchestrator (calls both phases in order). + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use zeroize::Zeroizing; + +use auths_core::crypto::said::{compute_next_commitment, compute_said, verify_commitment}; +use auths_core::crypto::signer::{decrypt_keypair, encrypt_keypair, load_seed_and_pubkey}; +use auths_core::ports::clock::ClockProvider; +use auths_core::storage::keychain::{ + IdentityDID, KeyAlias, KeyRole, KeyStorage, extract_public_key_bytes, +}; +use auths_id::identity::helpers::{ + ManagedIdentity, encode_seed_as_pkcs8, extract_seed_bytes, load_keypair_from_der_or_seed, +}; +use auths_id::keri::{ + Event, KERI_VERSION, KeriSequence, KeyState, Prefix, RotEvent, Said, serialize_for_signing, +}; +use auths_id::ports::registry::RegistryBackend; +use auths_id::witness_config::WitnessConfig; + +use crate::context::AuthsContext; +use crate::domains::identity::error::RotationError; +use crate::domains::identity::types::IdentityRotationConfig; +use crate::domains::identity::types::IdentityRotationResult; + +/// Computes a KERI rotation event and its canonical serialization. +/// +/// Pure function — deterministic given fixed inputs. Signs the event bytes with +/// `next_keypair` (the pre-committed future key becoming the new current key). +/// `new_next_keypair` is the freshly generated key committed for the next rotation. +/// +/// Args: +/// * `state`: Current key state from the registry. +/// * `next_keypair`: Pre-committed next key (becomes new current signer after rotation). +/// * `new_next_keypair`: Freshly generated keypair committed for the next rotation. +/// * `witness_config`: Optional witness configuration. +/// +/// Returns `(event, canonical_bytes)` where `canonical_bytes` is the exact +/// byte sequence to write to the KEL — do not re-serialize. +/// +/// Usage: +/// ```ignore +/// let (rot, bytes) = compute_rotation_event(&state, &next_kp, &new_next_kp, None)?; +/// ``` +pub fn compute_rotation_event( + state: &KeyState, + next_keypair: &Ed25519KeyPair, + new_next_keypair: &Ed25519KeyPair, + witness_config: Option<&WitnessConfig>, +) -> Result<(RotEvent, Vec), RotationError> { + let prefix = &state.prefix; + + let new_current_pub_encoded = format!( + "D{}", + URL_SAFE_NO_PAD.encode(next_keypair.public_key().as_ref()) + ); + let new_next_commitment = compute_next_commitment(new_next_keypair.public_key().as_ref()); + + let (bt, b) = match witness_config { + Some(cfg) if cfg.is_enabled() => ( + cfg.threshold.to_string(), + cfg.witness_urls.iter().map(|u| u.to_string()).collect(), + ), + _ => ("0".to_string(), vec![]), + }; + + let new_sequence = state.sequence + 1; + let mut rot = RotEvent { + v: KERI_VERSION.to_string(), + d: Said::default(), + i: prefix.clone(), + s: KeriSequence::new(new_sequence), + p: state.last_event_said.clone(), + kt: "1".to_string(), + k: vec![new_current_pub_encoded], + nt: "1".to_string(), + n: vec![new_next_commitment], + bt, + b, + a: vec![], + x: String::new(), + }; + + let rot_json = serde_json::to_vec(&Event::Rot(rot.clone())) + .map_err(|e| RotationError::RotationFailed(format!("serialization failed: {e}")))?; + rot.d = compute_said(&rot_json); + + let canonical = serialize_for_signing(&Event::Rot(rot.clone())) + .map_err(|e| RotationError::RotationFailed(format!("serialize for signing failed: {e}")))?; + let sig = next_keypair.sign(&canonical); + rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); + + let event_bytes = serialize_for_signing(&Event::Rot(rot.clone())) + .map_err(|e| RotationError::RotationFailed(format!("final serialization failed: {e}")))?; + + Ok((rot, event_bytes)) +} + +/// Key material required for the keychain side of `apply_rotation`. +pub struct RotationKeyMaterial { + /// DID of the identity being rotated. + pub did: IdentityDID, + /// Alias to store the new current key (the former pre-committed next key). + pub next_alias: KeyAlias, + /// Alias for the future pre-committed key (committed in this rotation). + pub new_next_alias: KeyAlias, + /// Pre-committed next key alias to delete after successful rotation. + pub old_next_alias: KeyAlias, + /// Encrypted new current key bytes to store in the keychain. + pub new_current_encrypted: Vec, + /// Encrypted new next key bytes to store for future rotation. + pub new_next_encrypted: Vec, +} + +/// Applies a computed rotation event to the registry and keychain. +/// +/// Writes the KEL event first, then updates the keychain. If the KEL append +/// succeeds but the subsequent keychain write fails, returns +/// `RotationError::PartialRotation` so the caller can surface a recovery path. +/// +/// # NOTE: non-atomic — KEL and keychain writes are not transactional. +/// Recovery: re-run rotation with the same new key to replay the keychain write. +/// +/// Args: +/// * `rot`: The pre-computed rotation event to append to the KEL. +/// * `prefix`: KERI identifier prefix (the `did:keri:` suffix). +/// * `key_material`: Encrypted key material and aliases for keychain operations. +/// * `registry`: Registry backend for KEL append. +/// * `key_storage`: Keychain for storing rotated key material. +/// +/// Usage: +/// ```ignore +/// apply_rotation(&rot, prefix, key_material, registry.as_ref(), key_storage.as_ref())?; +/// ``` +pub fn apply_rotation( + rot: &RotEvent, + prefix: &Prefix, + key_material: RotationKeyMaterial, + registry: &(dyn RegistryBackend + Send + Sync), + key_storage: &(dyn KeyStorage + Send + Sync), +) -> Result<(), RotationError> { + registry + .append_event(prefix, &Event::Rot(rot.clone())) + .map_err(|e| RotationError::RotationFailed(format!("KEL append failed: {e}")))?; + + // NOTE: non-atomic — KEL and keychain writes are not transactional. + // If the keychain write fails here, the KEL is already ahead. + let keychain_result = (|| { + key_storage + .store_key( + &key_material.next_alias, + &key_material.did, + KeyRole::Primary, + &key_material.new_current_encrypted, + ) + .map_err(|e| e.to_string())?; + + key_storage + .store_key( + &key_material.new_next_alias, + &key_material.did, + KeyRole::NextRotation, + &key_material.new_next_encrypted, + ) + .map_err(|e| e.to_string())?; + + let _ = key_storage.delete_key(&key_material.old_next_alias); + + Ok::<(), String>(()) + })(); + + keychain_result.map_err(RotationError::PartialRotation) +} + +/// Rotates the signing keys for an existing KERI identity. +/// +/// Args: +/// * `config` - Configuration for the rotation including aliases and paths. +/// * `ctx` - The application context containing storage adapters. +/// * `clock` - Provider for timestamps. +/// +/// Usage: +/// ```ignore +/// let result = rotate_identity( +/// IdentityRotationConfig { +/// repo_path: PathBuf::from("/home/user/.auths"), +/// identity_key_alias: Some("main".into()), +/// next_key_alias: None, +/// }, +/// &ctx, +/// &SystemClock, +/// )?; +/// println!("Rotated to: {}...", result.new_key_fingerprint); +/// ``` +pub fn rotate_identity( + config: IdentityRotationConfig, + ctx: &AuthsContext, + clock: &dyn ClockProvider, +) -> Result { + let (identity, prefix, current_alias) = resolve_rotation_context(&config, ctx)?; + let next_alias = config.next_key_alias.unwrap_or_else(|| { + KeyAlias::new_unchecked(format!( + "{}-rotated-{}", + current_alias, + clock.now().format("%Y%m%d%H%M%S") + )) + }); + + let previous_key_fingerprint = extract_previous_fingerprint(ctx, ¤t_alias)?; + + let state = ctx + .registry + .get_key_state(&prefix) + .map_err(|e| RotationError::KelHistoryFailed(e.to_string()))?; + + let (decrypted_next_pkcs8, old_next_alias) = + retrieve_precommitted_key(&identity.controller_did, ¤t_alias, &state, ctx)?; + + let (rot, new_next_pkcs8) = generate_rotation_keys(&identity, &state, &decrypted_next_pkcs8)?; + + finalize_rotation_storage( + FinalizeParams { + did: &identity.controller_did, + prefix: &prefix, + next_alias: &next_alias, + old_next_alias: &old_next_alias, + current_pkcs8: &decrypted_next_pkcs8, + new_next_pkcs8: new_next_pkcs8.as_ref(), + rot: &rot, + state: &state, + }, + ctx, + )?; + + let (_, new_pubkey) = load_seed_and_pubkey(&decrypted_next_pkcs8) + .map_err(|e| RotationError::RotationFailed(e.to_string()))?; + + Ok(IdentityRotationResult { + controller_did: identity.controller_did, + new_key_fingerprint: hex::encode(&new_pubkey[..8]), + previous_key_fingerprint, + sequence: state.sequence + 1, + }) +} + +/// Resolves the identity and determines which key alias is currently active. +fn resolve_rotation_context( + config: &IdentityRotationConfig, + ctx: &AuthsContext, +) -> Result<(ManagedIdentity, Prefix, KeyAlias), RotationError> { + let identity = + ctx.identity_storage + .load_identity() + .map_err(|_| RotationError::IdentityNotFound { + path: config.repo_path.clone(), + })?; + + let prefix_str = identity + .controller_did + .as_str() + .strip_prefix("did:keri:") + .ok_or_else(|| { + RotationError::RotationFailed(format!( + "invalid DID format, expected 'did:keri:': {}", + identity.controller_did + )) + })?; + let prefix = Prefix::new_unchecked(prefix_str.to_string()); + + let current_alias = match &config.identity_key_alias { + Some(alias) => alias.clone(), + None => { + let aliases = ctx + .key_storage + .list_aliases_for_identity(&identity.controller_did) + .map_err(|e| RotationError::RotationFailed(format!("alias lookup failed: {e}")))?; + aliases + .into_iter() + .find(|a| !a.contains("--next-")) + .ok_or_else(|| { + RotationError::KeyNotFound(format!( + "no active signing key for {}", + identity.controller_did + )) + })? + } + }; + + Ok((identity, prefix, current_alias)) +} + +fn extract_previous_fingerprint( + ctx: &AuthsContext, + current_alias: &KeyAlias, +) -> Result { + let old_pubkey_bytes = extract_public_key_bytes( + ctx.key_storage.as_ref(), + current_alias, + ctx.passphrase_provider.as_ref(), + ) + .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?; + + Ok(hex::encode(&old_pubkey_bytes[..8])) +} + +/// Retrieves and decrypts the key that was committed in the previous KERI event. +fn retrieve_precommitted_key( + did: &IdentityDID, + current_alias: &KeyAlias, + state: &KeyState, + ctx: &AuthsContext, +) -> Result<(Zeroizing>, KeyAlias), RotationError> { + let target_alias = + KeyAlias::new_unchecked(format!("{}--next-{}", current_alias, state.sequence)); + + let (did_check, _role, encrypted_next) = + ctx.key_storage.load_key(&target_alias).map_err(|e| { + RotationError::KeyNotFound(format!( + "pre-committed next key '{}' not found: {e}", + target_alias + )) + })?; + + if did != &did_check { + return Err(RotationError::RotationFailed(format!( + "DID mismatch for pre-committed key '{}': expected {}, found {}", + target_alias, did, did_check + ))); + } + + let pass = ctx + .passphrase_provider + .get_passphrase(&format!( + "Enter passphrase for pre-committed key '{}':", + target_alias + )) + .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?; + + let decrypted = decrypt_keypair(&encrypted_next, &pass) + .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?; + + let keypair = load_keypair_from_der_or_seed(&decrypted) + .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?; + + if !verify_commitment(keypair.public_key().as_ref(), &state.next_commitment[0]) { + return Err(RotationError::RotationFailed( + "commitment mismatch: next key does not match previous commitment".into(), + )); + } + + Ok((decrypted, target_alias)) +} + +/// Generates the new rotation event and the next forward-looking key commitment. +fn generate_rotation_keys( + identity: &ManagedIdentity, + state: &KeyState, + current_key_pkcs8: &[u8], +) -> Result<(RotEvent, ring::pkcs8::Document), RotationError> { + let witness_config: Option = identity + .metadata + .as_ref() + .and_then(|m| m.get("witness_config")) + .and_then(|wc| serde_json::from_value(wc.clone()).ok()); + + let rng = SystemRandom::new(); + let new_next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng) + .map_err(|e| RotationError::RotationFailed(format!("key generation failed: {e}")))?; + let new_next_keypair = Ed25519KeyPair::from_pkcs8(new_next_pkcs8.as_ref()) + .map_err(|e| RotationError::RotationFailed(format!("key construction failed: {e}")))?; + + let next_keypair = load_keypair_from_der_or_seed(current_key_pkcs8) + .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?; + + let (rot, _event_bytes) = compute_rotation_event( + state, + &next_keypair, + &new_next_keypair, + witness_config.as_ref(), + )?; + + Ok((rot, new_next_pkcs8)) +} + +struct FinalizeParams<'a> { + did: &'a IdentityDID, + prefix: &'a Prefix, + next_alias: &'a KeyAlias, + old_next_alias: &'a KeyAlias, + current_pkcs8: &'a [u8], + new_next_pkcs8: &'a [u8], + rot: &'a RotEvent, + state: &'a KeyState, +} + +/// Encrypts and persists the new current and next keys to secure storage. +fn finalize_rotation_storage( + params: FinalizeParams<'_>, + ctx: &AuthsContext, +) -> Result<(), RotationError> { + let new_pass = ctx + .passphrase_provider + .get_passphrase(&format!( + "Create passphrase for new key alias '{}':", + params.next_alias + )) + .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?; + + let confirm_pass = ctx + .passphrase_provider + .get_passphrase(&format!("Confirm passphrase for '{}':", params.next_alias)) + .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?; + + if new_pass != confirm_pass { + return Err(RotationError::RotationFailed(format!( + "passphrases do not match for alias '{}'", + params.next_alias + ))); + } + + let encrypted_new_current = encrypt_keypair(params.current_pkcs8, &new_pass) + .map_err(|e| RotationError::RotationFailed(format!("encrypt new current key: {e}")))?; + + let new_next_seed = extract_seed_bytes(params.new_next_pkcs8) + .map_err(|e| RotationError::RotationFailed(format!("extract new next seed: {e}")))?; + let new_next_seed_pkcs8 = encode_seed_as_pkcs8(new_next_seed) + .map_err(|e| RotationError::RotationFailed(format!("encode new next seed: {e}")))?; + let encrypted_new_next = encrypt_keypair(&new_next_seed_pkcs8, &new_pass) + .map_err(|e| RotationError::RotationFailed(format!("encrypt new next key: {e}")))?; + + let new_sequence = params.state.sequence + 1; + let new_next_alias = + KeyAlias::new_unchecked(format!("{}--next-{}", params.next_alias, new_sequence)); + + let key_material = RotationKeyMaterial { + did: params.did.clone(), + next_alias: params.next_alias.clone(), + new_next_alias, + old_next_alias: params.old_next_alias.clone(), + new_current_encrypted: encrypted_new_current.to_vec(), + new_next_encrypted: encrypted_new_next.to_vec(), + }; + + apply_rotation( + params.rot, + params.prefix, + key_material, + ctx.registry.as_ref(), + ctx.key_storage.as_ref(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use auths_core::PrefilledPassphraseProvider; + use auths_core::ports::clock::SystemClock; + use auths_core::signing::{PassphraseProvider, StorageSigner}; + use auths_core::storage::memory::{MEMORY_KEYCHAIN, MemoryKeychainHandle}; + use auths_id::attestation::export::AttestationSink; + use auths_id::ports::registry::RegistryBackend; + use auths_id::storage::attestation::AttestationSource; + use auths_id::storage::identity::IdentityStorage; + use auths_id::testing::fakes::FakeIdentityStorage; + use auths_id::testing::fakes::FakeRegistryBackend; + use auths_id::testing::fakes::{FakeAttestationSink, FakeAttestationSource}; + + use crate::domains::identity::service::initialize; + use crate::domains::identity::types::InitializeResult; + use crate::domains::identity::types::{CreateDeveloperIdentityConfig, IdentityConfig}; + use crate::domains::signing::types::GitSigningScope; + + fn fake_ctx(passphrase: &str) -> AuthsContext { + MEMORY_KEYCHAIN.lock().unwrap().clear_all().ok(); + AuthsContext::builder() + .registry( + Arc::new(FakeRegistryBackend::new()) as Arc + ) + .key_storage(Arc::new(MemoryKeychainHandle)) + .clock(Arc::new(SystemClock)) + .identity_storage( + Arc::new(FakeIdentityStorage::new()) as Arc + ) + .attestation_sink( + Arc::new(FakeAttestationSink::new()) as Arc + ) + .attestation_source( + Arc::new(FakeAttestationSource::new()) + as Arc, + ) + .passphrase_provider( + Arc::new(PrefilledPassphraseProvider::new(passphrase)) + as Arc, + ) + .build() + } + + fn provision_identity(ctx: &AuthsContext) -> KeyAlias { + let signer = StorageSigner::new(MemoryKeychainHandle); + let provider = PrefilledPassphraseProvider::new("Test-passphrase1!"); + let config = CreateDeveloperIdentityConfig::builder(KeyAlias::new_unchecked("test-key")) + .with_git_signing_scope(GitSigningScope::Skip) + .build(); + let result = match initialize( + IdentityConfig::Developer(config), + ctx, + Arc::new(MemoryKeychainHandle), + &signer, + &provider, + None, + ) + .unwrap() + { + InitializeResult::Developer(r) => r, + _ => unreachable!(), + }; + result.key_alias + } + + // -- resolve_rotation_context -- + + #[test] + fn resolve_rotation_context_returns_identity_and_prefix() { + let ctx = fake_ctx("Test-passphrase1!"); + let key_alias = provision_identity(&ctx); + + let config = IdentityRotationConfig { + repo_path: std::path::PathBuf::from("/unused"), + identity_key_alias: Some(key_alias.clone()), + next_key_alias: None, + }; + + let (identity, prefix, alias) = resolve_rotation_context(&config, &ctx).unwrap(); + assert!(identity.controller_did.as_str().starts_with("did:keri:")); + assert_eq!( + prefix.as_str(), + identity + .controller_did + .as_str() + .strip_prefix("did:keri:") + .unwrap() + ); + assert_eq!(alias, key_alias); + } + + #[test] + fn resolve_rotation_context_auto_discovers_alias() { + let ctx = fake_ctx("Test-passphrase1!"); + let _key_alias = provision_identity(&ctx); + + let config = IdentityRotationConfig { + repo_path: std::path::PathBuf::from("/unused"), + identity_key_alias: None, + next_key_alias: None, + }; + + let (_identity, _prefix, alias) = resolve_rotation_context(&config, &ctx).unwrap(); + assert!(!alias.contains("--next-")); + } + + #[test] + fn resolve_rotation_context_missing_identity_returns_error() { + let ctx = fake_ctx("unused"); + + let config = IdentityRotationConfig { + repo_path: std::path::PathBuf::from("/unused"), + identity_key_alias: Some(KeyAlias::new_unchecked("any")), + next_key_alias: None, + }; + + let result = resolve_rotation_context(&config, &ctx); + assert!(matches!( + result, + Err(RotationError::IdentityNotFound { .. }) + )); + } + + // -- retrieve_precommitted_key -- + + #[test] + fn retrieve_precommitted_key_succeeds_after_setup() { + let ctx = fake_ctx("Test-passphrase1!"); + let key_alias = provision_identity(&ctx); + + let config = IdentityRotationConfig { + repo_path: std::path::PathBuf::from("/unused"), + identity_key_alias: Some(key_alias.clone()), + next_key_alias: None, + }; + + let (identity, prefix, _) = resolve_rotation_context(&config, &ctx).unwrap(); + let state = ctx.registry.get_key_state(&prefix).unwrap(); + + let (decrypted, old_alias) = + retrieve_precommitted_key(&identity.controller_did, &key_alias, &state, &ctx).unwrap(); + + assert!(!decrypted.is_empty()); + assert!(old_alias.contains("--next-")); + } + + #[test] + fn retrieve_precommitted_key_wrong_did_returns_error() { + let ctx = fake_ctx("Test-passphrase1!"); + let key_alias = provision_identity(&ctx); + + let config = IdentityRotationConfig { + repo_path: std::path::PathBuf::from("/unused"), + identity_key_alias: Some(key_alias.clone()), + next_key_alias: None, + }; + + let (_, prefix, _) = resolve_rotation_context(&config, &ctx).unwrap(); + let state = ctx.registry.get_key_state(&prefix).unwrap(); + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only literal with valid did:keri: prefix + let wrong_did = IdentityDID::new_unchecked("did:keri:EWrongDid".to_string()); + + let result = retrieve_precommitted_key(&wrong_did, &key_alias, &state, &ctx); + assert!(matches!(result, Err(RotationError::RotationFailed(_)))); + } + + #[test] + fn retrieve_precommitted_key_missing_key_returns_error() { + let ctx = fake_ctx("Test-passphrase1!"); + + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only literal with valid did:keri: prefix + let did = IdentityDID::new_unchecked("did:keri:Etest".to_string()); + let state = KeyState { + prefix: Prefix::new_unchecked("Etest".to_string()), + current_keys: vec![], + next_commitment: vec![], + sequence: 999, + last_event_said: Said::default(), + is_abandoned: false, + threshold: 1, + next_threshold: 1, + }; + + let result = retrieve_precommitted_key( + &did, + &KeyAlias::new_unchecked("nonexistent-alias"), + &state, + &ctx, + ); + assert!(matches!(result, Err(RotationError::KeyNotFound(_)))); + } + + // -- generate_rotation_keys -- + + #[test] + fn generate_rotation_keys_produces_valid_event() { + let ctx = fake_ctx("Test-passphrase1!"); + let key_alias = provision_identity(&ctx); + + let config = IdentityRotationConfig { + repo_path: std::path::PathBuf::from("/unused"), + identity_key_alias: Some(key_alias.clone()), + next_key_alias: None, + }; + + let (identity, prefix, _) = resolve_rotation_context(&config, &ctx).unwrap(); + let state = ctx.registry.get_key_state(&prefix).unwrap(); + let (decrypted, _) = + retrieve_precommitted_key(&identity.controller_did, &key_alias, &state, &ctx).unwrap(); + + let (rot, new_next_pkcs8) = generate_rotation_keys(&identity, &state, &decrypted).unwrap(); + + assert_eq!(rot.s, KeriSequence::new(state.sequence + 1)); + assert_eq!(rot.i, prefix); + assert!(!rot.d.is_empty()); + assert!(!rot.x.is_empty()); + assert!(!new_next_pkcs8.as_ref().is_empty()); + } + + // -- finalize_rotation_storage -- + + #[test] + fn finalize_rotation_storage_persists_keys() { + let ctx = fake_ctx("Test-passphrase1!"); + let key_alias = provision_identity(&ctx); + + let config = IdentityRotationConfig { + repo_path: std::path::PathBuf::from("/unused"), + identity_key_alias: Some(key_alias.clone()), + next_key_alias: None, + }; + + let (identity, prefix, _) = resolve_rotation_context(&config, &ctx).unwrap(); + let state = ctx.registry.get_key_state(&prefix).unwrap(); + let (decrypted, old_next_alias) = + retrieve_precommitted_key(&identity.controller_did, &key_alias, &state, &ctx).unwrap(); + let (rot, new_next_pkcs8) = generate_rotation_keys(&identity, &state, &decrypted).unwrap(); + + let rotated_alias = KeyAlias::new_unchecked("rotated-key"); + let result = finalize_rotation_storage( + FinalizeParams { + did: &identity.controller_did, + prefix: &prefix, + next_alias: &rotated_alias, + old_next_alias: &old_next_alias, + current_pkcs8: &decrypted, + new_next_pkcs8: new_next_pkcs8.as_ref(), + rot: &rot, + state: &state, + }, + &ctx, + ); + + assert!( + result.is_ok(), + "finalize_rotation_storage failed: {:?}", + result + ); + + let (loaded_did, _, _) = ctx + .key_storage + .load_key(&KeyAlias::new_unchecked("rotated-key")) + .unwrap(); + assert_eq!(loaded_did, identity.controller_did); + + let new_sequence = state.sequence + 1; + let next_key_alias = format!("rotated-key--next-{}", new_sequence); + let (loaded_next_did, _, _) = ctx + .key_storage + .load_key(&KeyAlias::new_unchecked(&next_key_alias)) + .unwrap(); + assert_eq!(loaded_next_did, identity.controller_did); + } + + #[test] + fn finalize_rotation_storage_rejects_mismatched_passphrases() { + use std::sync::atomic::{AtomicU32, Ordering}; + + struct AlternatingProvider { + call_count: AtomicU32, + } + + impl PassphraseProvider for AlternatingProvider { + fn get_passphrase( + &self, + _prompt: &str, + ) -> Result, auths_core::AgentError> { + let n = self.call_count.fetch_add(1, Ordering::SeqCst); + if n.is_multiple_of(2) { + Ok(zeroize::Zeroizing::new("pass-a".to_string())) + } else { + Ok(zeroize::Zeroizing::new("pass-b".to_string())) + } + } + } + + let prefix = Prefix::new_unchecked("ETestMismatch".to_string()); + #[allow(clippy::disallowed_methods)] + // INVARIANT: test-only literal with valid did:keri: prefix + let did = IdentityDID::new_unchecked("did:keri:ETestMismatch".to_string()); + + let state = KeyState { + prefix: prefix.clone(), + current_keys: vec!["D_key".to_string()], + next_commitment: vec!["hash".to_string()], + sequence: 0, + last_event_said: Said::new_unchecked("EPrior".to_string()), + is_abandoned: false, + threshold: 1, + next_threshold: 1, + }; + + let rng = SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + + let dummy_rot = RotEvent { + v: KERI_VERSION.to_string(), + d: Said::new_unchecked("E_dummy".to_string()), + i: prefix.clone(), + s: KeriSequence::new(1), + p: Said::default(), + kt: "1".to_string(), + k: vec![], + nt: "1".to_string(), + n: vec![], + bt: "0".to_string(), + b: vec![], + a: vec![], + x: String::new(), + }; + + let ctx = + AuthsContext::builder() + .registry( + Arc::new(FakeRegistryBackend::new()) as Arc + ) + .key_storage(Arc::new(MemoryKeychainHandle)) + .clock(Arc::new(SystemClock)) + .identity_storage( + Arc::new(FakeIdentityStorage::new()) as Arc + ) + .attestation_sink( + Arc::new(FakeAttestationSink::new()) as Arc + ) + .attestation_source(Arc::new(FakeAttestationSource::new()) + as Arc) + .passphrase_provider(Arc::new(AlternatingProvider { + call_count: AtomicU32::new(0), + }) + as Arc) + .build(); + + let test_alias = KeyAlias::new_unchecked("test-alias"); + let old_alias = KeyAlias::new_unchecked("old-alias"); + let result = finalize_rotation_storage( + FinalizeParams { + did: &did, + prefix: &prefix, + next_alias: &test_alias, + old_next_alias: &old_alias, + current_pkcs8: pkcs8.as_ref(), + new_next_pkcs8: pkcs8.as_ref(), + rot: &dummy_rot, + state: &state, + }, + &ctx, + ); + + assert!( + matches!(result, Err(RotationError::RotationFailed(ref msg)) if msg.contains("passphrases do not match")), + "Expected passphrase mismatch error, got: {:?}", + result + ); + } +} diff --git a/crates/auths-sdk/src/domains/identity/service.rs b/crates/auths-sdk/src/domains/identity/service.rs new file mode 100644 index 00000000..96ac8f7a --- /dev/null +++ b/crates/auths-sdk/src/domains/identity/service.rs @@ -0,0 +1,481 @@ +use std::convert::TryInto; +use std::path::Path; +use std::sync::Arc; + +use auths_core::signing::{PassphraseProvider, SecureSigner}; +use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage}; +use auths_id::attestation::create::create_signed_attestation; +use auths_id::identity::initialize::initialize_registry_identity; +use auths_id::storage::git_refs::AttestationMetadata; +use auths_id::storage::registry::install_linearity_hook; +use auths_verifier::types::DeviceDID; +use chrono::{DateTime, Utc}; + +use crate::context::AuthsContext; +use crate::domains::identity::error::SetupError; +use crate::domains::identity::types::{ + AgentIdentityResult, CiEnvironment, CiIdentityConfig, CiIdentityResult, + CreateAgentIdentityConfig, CreateDeveloperIdentityConfig, DeveloperIdentityResult, + IdentityConfig, IdentityConflictPolicy, InitializeResult, RegistrationOutcome, +}; +use crate::domains::signing::types::PlatformClaimResult; +use crate::domains::signing::types::{GitSigningScope, PlatformVerification}; +use crate::ports::git_config::GitConfigProvider; + +/// Provisions a new identity for the requested persona. +/// +/// Dispatches to the appropriate setup path based on the `config` variant. +/// No deprecated shims — callers migrate directly to this function. +/// +/// Args: +/// * `config`: Identity persona and all setup parameters. +/// * `ctx`: Injected infrastructure adapters (registry, identity storage, attestation sink, clock). +/// * `keychain`: Platform keychain for key storage and retrieval. +/// * `signer`: Secure signer for creating attestation signatures. +/// * `passphrase_provider`: Provides passphrases for key encryption/decryption. +/// * `git_config`: Git configuration provider; required when git signing is configured. +/// +/// Usage: +/// ```ignore +/// let keychain: Arc = Arc::new(platform_keychain); +/// let result = initialize(IdentityConfig::developer(alias), &ctx, keychain, &signer, &provider, git_cfg)?; +/// match result { +/// InitializeResult::Developer(r) => println!("Identity: {}", r.identity_did), +/// InitializeResult::Ci(r) => println!("CI env block: {} lines", r.env_block.len()), +/// InitializeResult::Agent(r) => println!("Agent: {}", r.agent_did), +/// } +/// ``` +pub fn initialize( + config: IdentityConfig, + ctx: &AuthsContext, + keychain: Arc, + signer: &dyn SecureSigner, + passphrase_provider: &dyn PassphraseProvider, + git_config: Option<&dyn GitConfigProvider>, +) -> Result { + match config { + IdentityConfig::Developer(dev_config) => initialize_developer( + dev_config, + ctx, + keychain.as_ref(), + signer, + passphrase_provider, + git_config, + ) + .map(InitializeResult::Developer), + IdentityConfig::Ci(ci_config) => initialize_ci( + ci_config, + ctx, + keychain.as_ref(), + signer, + passphrase_provider, + ) + .map(InitializeResult::Ci), + IdentityConfig::Agent(agent_config) => { + initialize_agent(agent_config, ctx, Box::new(keychain), passphrase_provider) + .map(InitializeResult::Agent) + } + } +} + +fn initialize_developer( + config: CreateDeveloperIdentityConfig, + ctx: &AuthsContext, + keychain: &(dyn KeyStorage + Send + Sync), + signer: &dyn SecureSigner, + passphrase_provider: &dyn PassphraseProvider, + git_config: Option<&dyn GitConfigProvider>, +) -> Result { + let now = ctx.clock.now(); + let (controller_did, key_alias, reused) = + resolve_or_create_identity(&config, ctx, keychain, passphrase_provider, now)?; + let device_did = if reused { + derive_device_did(&key_alias, keychain, passphrase_provider)? + } else { + bind_device(&key_alias, ctx, keychain, signer, passphrase_provider, now)? + }; + let platform_claim = bind_platform_claim(&config.platform); + let git_configured = configure_git_signing( + &config.git_signing_scope, + &key_alias, + git_config, + config.sign_binary_path.as_deref(), + )?; + let registered = submit_registration(&config); + + Ok(DeveloperIdentityResult { + #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did originates from initialize_registry_identity() which returns a validated IdentityDID; into_inner() only unwraps it + identity_did: IdentityDID::new_unchecked(controller_did), + device_did, + key_alias, + platform_claim, + git_signing_configured: git_configured, + registered, + }) +} + +fn initialize_ci( + config: CiIdentityConfig, + ctx: &AuthsContext, + keychain: &(dyn KeyStorage + Send + Sync), + signer: &dyn SecureSigner, + passphrase_provider: &dyn PassphraseProvider, +) -> Result { + let now = ctx.clock.now(); + let (controller_did, key_alias) = initialize_ci_keys(ctx, keychain, passphrase_provider, now)?; + let device_did = bind_device(&key_alias, ctx, keychain, signer, passphrase_provider, now)?; + let env_block = + generate_ci_env_block(&key_alias, &config.registry_path, &config.ci_environment); + + Ok(CiIdentityResult { + #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did originates from initialize_registry_identity() which returns a validated IdentityDID; into_inner() only unwraps it + identity_did: IdentityDID::new_unchecked(controller_did), + device_did, + env_block, + }) +} + +fn initialize_agent( + config: CreateAgentIdentityConfig, + ctx: &AuthsContext, + keychain: Box, + passphrase_provider: &dyn PassphraseProvider, +) -> Result { + use auths_id::agent_identity::{AgentProvisioningConfig, AgentStorageMode}; + + let cap_strings: Vec = config.capabilities.iter().map(|c| c.to_string()).collect(); + let provisioning_config = AgentProvisioningConfig { + agent_name: config.alias.to_string(), + capabilities: cap_strings, + expires_in: config.expires_in, + delegated_by: config.parent_identity_did.clone().map(|did| { + #[allow(clippy::disallowed_methods)] + // INVARIANT: parent_identity_did is supplied by the CLI after resolving from identity storage, which stores only validated did:keri: DIDs + IdentityDID::new_unchecked(did) + }), + storage_mode: AgentStorageMode::Persistent { + repo_path: Some(config.registry_path.clone()), + }, + }; + + let proposed = build_agent_identity_proposal(&provisioning_config, &config)?; + + if !config.dry_run { + let bundle = auths_id::agent_identity::provision_agent_identity( + ctx.clock.now(), + std::sync::Arc::clone(&ctx.registry), + provisioning_config, + passphrase_provider, + keychain, + ) + .map_err(|e| SetupError::StorageError(e.into()))?; + + return Ok(AgentIdentityResult { + agent_did: Some(bundle.agent_did), + parent_did: config + .parent_identity_did + .and_then(|s| IdentityDID::parse(&s).ok()), + capabilities: config.capabilities, + }); + } + + Ok(proposed) +} + +/// Install the linearity hook in a registry directory. +/// +/// This is called by the CLI after initializing the git repository to prevent +/// non-linear KEL history. +/// +/// Args: +/// * `registry_path`: Path to the initialized git repository. +/// +/// Usage: +/// ```ignore +/// auths_sdk::setup::install_registry_hook(®istry_path); +/// ``` +pub fn install_registry_hook(registry_path: &Path) { + let _ = install_linearity_hook(registry_path); +} + +// ── Private helpers ────────────────────────────────────────────────────── + +/// Returns (controller_did, key_alias, reused). +fn resolve_or_create_identity( + config: &CreateDeveloperIdentityConfig, + ctx: &AuthsContext, + keychain: &(dyn KeyStorage + Send + Sync), + passphrase_provider: &dyn PassphraseProvider, + now: DateTime, +) -> Result<(String, KeyAlias, bool), SetupError> { + if let Ok(existing) = ctx.identity_storage.load_identity() { + match config.conflict_policy { + IdentityConflictPolicy::Error => { + return Err(SetupError::IdentityAlreadyExists { + did: existing.controller_did.into_inner(), + }); + } + IdentityConflictPolicy::ReuseExisting => { + return Ok(( + existing.controller_did.into_inner(), + config.key_alias.clone(), + true, + )); + } + IdentityConflictPolicy::ForceNew => {} + } + } + + let (did, alias) = derive_keys(config, ctx, keychain, passphrase_provider, now)?; + Ok((did, alias, false)) +} + +fn derive_keys( + config: &CreateDeveloperIdentityConfig, + ctx: &AuthsContext, + keychain: &(dyn KeyStorage + Send + Sync), + passphrase_provider: &dyn PassphraseProvider, + _now: DateTime, +) -> Result<(String, KeyAlias), SetupError> { + let (controller_did, _key_event) = initialize_registry_identity( + std::sync::Arc::clone(&ctx.registry), + &config.key_alias, + passphrase_provider, + keychain, + config.witness_config.as_ref(), + ) + .map_err(|e| SetupError::StorageError(e.into()))?; + + let did_str = controller_did.into_inner(); + ctx.identity_storage + .create_identity(&did_str, None) + .map_err(|e| SetupError::StorageError(e.into()))?; + + Ok((did_str, config.key_alias.clone())) +} + +fn derive_device_did( + key_alias: &KeyAlias, + keychain: &(dyn KeyStorage + Send + Sync), + passphrase_provider: &dyn PassphraseProvider, +) -> Result { + let pk_bytes = auths_core::storage::keychain::extract_public_key_bytes( + keychain, + key_alias, + passphrase_provider, + )?; + + let device_did = DeviceDID::from_ed25519(pk_bytes.as_slice().try_into().map_err(|_| { + SetupError::CryptoError(auths_core::AgentError::InvalidInput( + "public key is not 32 bytes".into(), + )) + })?); + + Ok(device_did) +} + +fn bind_device( + key_alias: &KeyAlias, + ctx: &AuthsContext, + keychain: &(dyn KeyStorage + Send + Sync), + signer: &dyn SecureSigner, + passphrase_provider: &dyn PassphraseProvider, + now: DateTime, +) -> Result { + let managed = ctx + .identity_storage + .load_identity() + .map_err(|e| SetupError::StorageError(e.into()))?; + + let pk_bytes = auths_core::storage::keychain::extract_public_key_bytes( + keychain, + key_alias, + passphrase_provider, + )?; + + let device_did = DeviceDID::from_ed25519(pk_bytes.as_slice().try_into().map_err(|_| { + SetupError::CryptoError(auths_core::AgentError::InvalidInput( + "public key is not 32 bytes".into(), + )) + })?); + + let meta = AttestationMetadata { + timestamp: Some(now), + expires_at: None, + note: Some("Linked by auths-sdk setup".to_string()), + }; + + let attestation = create_signed_attestation( + now, + &managed.storage_id, + &managed.controller_did, + &device_did, + &pk_bytes, + None, + &meta, + signer, + passphrase_provider, + Some(key_alias), + Some(key_alias), + vec![], + None, + None, + ) + .map_err(|e| SetupError::StorageError(e.into()))?; + + ctx.attestation_sink + .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(attestation)) + .map_err(|e| SetupError::StorageError(e.into()))?; + + Ok(device_did) +} + +fn bind_platform_claim(platform: &Option) -> Option { + match platform { + Some(PlatformVerification::GitHub { .. }) => None, + Some(PlatformVerification::GitLab { .. }) => None, + Some(PlatformVerification::Skip) | None => None, + } +} + +fn configure_git_signing( + scope: &GitSigningScope, + key_alias: &KeyAlias, + git_config: Option<&dyn GitConfigProvider>, + sign_binary_path: Option<&Path>, +) -> Result { + if matches!(scope, GitSigningScope::Skip) { + return Ok(false); + } + let git_config = git_config.ok_or_else(|| { + SetupError::InvalidSetupConfig("GitConfigProvider required for non-Skip scope".into()) + })?; + let sign_binary_path = sign_binary_path.ok_or_else(|| { + SetupError::InvalidSetupConfig("sign_binary_path required for non-Skip scope".into()) + })?; + set_git_signing_config(key_alias, git_config, sign_binary_path)?; + Ok(true) +} + +fn set_git_signing_config( + key_alias: &KeyAlias, + git_config: &dyn GitConfigProvider, + sign_binary_path: &Path, +) -> Result<(), SetupError> { + let auths_sign_str = sign_binary_path.to_str().ok_or_else(|| { + SetupError::InvalidSetupConfig("auths-sign path is not valid UTF-8".into()) + })?; + let signing_key = format!("auths:{}", key_alias); + let configs: &[(&str, &str)] = &[ + ("gpg.format", "ssh"), + ("gpg.ssh.program", auths_sign_str), + ("user.signingkey", &signing_key), + ("commit.gpgsign", "true"), + ("tag.gpgsign", "true"), + ]; + for (key, val) in configs { + git_config + .set(key, val) + .map_err(SetupError::GitConfigError)?; + } + Ok(()) +} + +fn submit_registration(config: &CreateDeveloperIdentityConfig) -> Option { + if !config.register_on_registry { + return None; + } + None +} + +fn initialize_ci_keys( + ctx: &AuthsContext, + keychain: &(dyn KeyStorage + Send + Sync), + passphrase_provider: &dyn PassphraseProvider, + _now: DateTime, +) -> Result<(String, KeyAlias), SetupError> { + let key_alias = KeyAlias::new_unchecked("ci-key"); + + let (controller_did, _) = initialize_registry_identity( + std::sync::Arc::clone(&ctx.registry), + &key_alias, + passphrase_provider, + keychain, + None, + ) + .map_err(|e| SetupError::StorageError(e.into()))?; + + Ok((controller_did.into_inner(), key_alias)) +} + +fn generate_ci_env_block( + key_alias: &KeyAlias, + repo_path: &Path, + environment: &CiEnvironment, +) -> Vec { + match environment { + CiEnvironment::GitHubActions => generate_github_env_block(key_alias, repo_path), + CiEnvironment::GitLabCi => generate_gitlab_env_block(key_alias, repo_path), + CiEnvironment::Custom { name } => generate_generic_env_block(key_alias, repo_path, name), + CiEnvironment::Unknown => generate_generic_env_block(key_alias, repo_path, "ci"), + } +} + +fn generate_github_env_block(key_alias: &KeyAlias, repo_path: &Path) -> Vec { + let mut lines = base_env_lines(key_alias, repo_path); + lines.push(String::new()); + lines.push("# GitHub Actions: add these as repository secrets".to_string()); + lines.push("# then reference them in your workflow env: block".to_string()); + lines +} + +fn generate_gitlab_env_block(key_alias: &KeyAlias, repo_path: &Path) -> Vec { + let mut lines = base_env_lines(key_alias, repo_path); + lines.push(String::new()); + lines.push("# GitLab CI: add these as CI/CD variables".to_string()); + lines.push("# in Settings > CI/CD > Variables".to_string()); + lines +} + +fn generate_generic_env_block( + key_alias: &KeyAlias, + repo_path: &Path, + platform: &str, +) -> Vec { + let mut lines = base_env_lines(key_alias, repo_path); + lines.push(String::new()); + lines.push(format!("# {platform}: add these as environment variables")); + lines +} + +fn base_env_lines(key_alias: &KeyAlias, repo_path: &Path) -> Vec { + vec![ + format!("export AUTHS_KEYCHAIN_BACKEND=\"memory\""), + format!("export AUTHS_REPO=\"{}\"", repo_path.display()), + format!("export AUTHS_KEY_ALIAS=\"{key_alias}\""), + String::new(), + format!("export GIT_CONFIG_COUNT=4"), + format!("export GIT_CONFIG_KEY_0=\"gpg.format\""), + format!("export GIT_CONFIG_VALUE_0=\"ssh\""), + format!("export GIT_CONFIG_KEY_1=\"gpg.ssh.program\""), + format!("export GIT_CONFIG_VALUE_1=\"auths-sign\""), + format!("export GIT_CONFIG_KEY_2=\"user.signingKey\""), + format!("export GIT_CONFIG_VALUE_2=\"auths:{key_alias}\""), + format!("export GIT_CONFIG_KEY_3=\"commit.gpgSign\""), + format!("export GIT_CONFIG_VALUE_3=\"true\""), + ] +} + +fn build_agent_identity_proposal( + _provisioning_config: &auths_id::agent_identity::AgentProvisioningConfig, + config: &CreateAgentIdentityConfig, +) -> Result { + Ok(AgentIdentityResult { + agent_did: None, + parent_did: config + .parent_identity_did + .as_deref() + .and_then(|s| IdentityDID::parse(s).ok()), + capabilities: config.capabilities.clone(), + }) +} diff --git a/crates/auths-sdk/src/domains/identity/types.rs b/crates/auths-sdk/src/domains/identity/types.rs new file mode 100644 index 00000000..f63f4216 --- /dev/null +++ b/crates/auths-sdk/src/domains/identity/types.rs @@ -0,0 +1,668 @@ +use auths_core::storage::keychain::{IdentityDID, KeyAlias}; +use auths_verifier::Capability; +use auths_verifier::types::DeviceDID; +use std::path::PathBuf; + +/// Policy for handling an existing identity during developer setup. +/// +/// Replaces interactive CLI prompts with a typed enum that headless consumers +/// can set programmatically. +/// +/// Usage: +/// ```ignore +/// let config = CreateDeveloperIdentityConfig::builder("my-key") +/// .with_conflict_policy(IdentityConflictPolicy::ReuseExisting) +/// .build(); +/// ``` +#[derive(Debug, Clone, Default)] +pub enum IdentityConflictPolicy { + /// Return an error if an identity already exists (default). + #[default] + Error, + /// Reuse the existing identity silently. + ReuseExisting, + /// Overwrite the existing identity with a new one. + ForceNew, +} + +/// CI platform environment. +/// +/// Usage: +/// ```ignore +/// let env = CiEnvironment::GitHubActions; +/// ``` +#[derive(Debug, Clone)] +pub enum CiEnvironment { + /// GitHub Actions CI environment. + GitHubActions, + /// GitLab CI/CD environment. + GitLabCi, + /// A custom CI platform with a user-provided name. + Custom { + /// The name of the custom CI platform. + name: String, + }, + /// The CI platform could not be detected. + Unknown, +} + +/// Configuration for provisioning a new developer identity. +/// +/// Use [`CreateDeveloperIdentityConfigBuilder`] to construct this with optional fields. +/// The registry backend is injected via [`crate::context::AuthsContext`] — this +/// struct carries only serializable configuration values. +/// +/// Args: +/// * `key_alias`: Human-readable name for the key (e.g. "work-laptop"). +/// +/// Usage: +/// ```ignore +/// let config = CreateDeveloperIdentityConfig::builder("work-laptop") +/// .with_platform(crate::types::PlatformVerification::GitHub { access_token: "ghp_abc".into() }) +/// .with_git_signing_scope(crate::types::GitSigningScope::Global) +/// .build(); +/// ``` +#[derive(Debug)] +pub struct CreateDeveloperIdentityConfig { + /// Human-readable name for the key (e.g. "work-laptop"). + pub key_alias: KeyAlias, + /// Optional platform verification configuration. + pub platform: Option, + /// How to configure git commit signing. + pub git_signing_scope: crate::types::GitSigningScope, + /// Whether to register the identity on a remote registry. + pub register_on_registry: bool, + /// Remote registry URL, if registration is enabled. + pub registry_url: Option, + /// What to do if an identity already exists. + pub conflict_policy: IdentityConflictPolicy, + /// Optional KERI witness configuration for the inception event. + pub witness_config: Option, + /// Optional JSON metadata to attach to the identity. + pub metadata: Option, + /// Path to the `auths-sign` binary, required when git signing is configured. + /// The CLI resolves this via `which::which("auths-sign")`. + pub sign_binary_path: Option, +} + +impl CreateDeveloperIdentityConfig { + /// Creates a builder with the required key alias. + /// + /// Args: + /// * `key_alias`: Human-readable name for the key. + /// + /// Usage: + /// ```ignore + /// let builder = CreateDeveloperIdentityConfig::builder("my-key"); + /// ``` + pub fn builder(key_alias: KeyAlias) -> CreateDeveloperIdentityConfigBuilder { + CreateDeveloperIdentityConfigBuilder { + key_alias, + platform: None, + git_signing_scope: crate::types::GitSigningScope::Global, + register_on_registry: false, + registry_url: None, + conflict_policy: IdentityConflictPolicy::Error, + witness_config: None, + metadata: None, + sign_binary_path: None, + } + } +} + +/// Builder for [`CreateDeveloperIdentityConfig`]. +#[derive(Debug)] +pub struct CreateDeveloperIdentityConfigBuilder { + key_alias: KeyAlias, + platform: Option, + git_signing_scope: crate::types::GitSigningScope, + register_on_registry: bool, + registry_url: Option, + conflict_policy: IdentityConflictPolicy, + witness_config: Option, + metadata: Option, + sign_binary_path: Option, +} + +impl CreateDeveloperIdentityConfigBuilder { + /// Configures platform identity verification for the new identity. + /// + /// The SDK never opens a browser or runs OAuth flows. The caller must + /// obtain the access token beforehand and pass it here. + /// + /// Args: + /// * `platform`: The platform and access token to verify against. + /// + /// Usage: + /// ```ignore + /// let config = CreateDeveloperIdentityConfig::builder("my-key") + /// .with_platform(crate::types::PlatformVerification::GitHub { + /// access_token: "ghp_abc123".into(), + /// }) + /// .build(); + /// ``` + pub fn with_platform(mut self, platform: crate::types::PlatformVerification) -> Self { + self.platform = Some(platform); + self + } + + /// Sets the Git signing scope (local, global, or skip). + /// + /// Args: + /// * `scope`: How to configure `git config` for commit signing. + /// + /// Usage: + /// ```ignore + /// let config = CreateDeveloperIdentityConfig::builder("my-key") + /// .with_git_signing_scope(crate::types::GitSigningScope::Local { + /// repo_path: PathBuf::from("/path/to/repo"), + /// }) + /// .build(); + /// ``` + pub fn with_git_signing_scope(mut self, scope: crate::types::GitSigningScope) -> Self { + self.git_signing_scope = scope; + self + } + + /// Enables registration on a remote auths registry after identity creation. + /// + /// Args: + /// * `url`: The registry URL to register with. + /// + /// Usage: + /// ```ignore + /// let config = CreateDeveloperIdentityConfig::builder("my-key") + /// .with_registration("https://registry.auths.dev") + /// .build(); + /// ``` + pub fn with_registration(mut self, url: impl Into) -> Self { + self.register_on_registry = true; + self.registry_url = Some(url.into()); + self + } + + /// Sets the policy for handling an existing identity at the registry path. + /// + /// Args: + /// * `policy`: What to do if an identity already exists. + /// + /// Usage: + /// ```ignore + /// let config = CreateDeveloperIdentityConfig::builder("my-key") + /// .with_conflict_policy(IdentityConflictPolicy::ReuseExisting) + /// .build(); + /// ``` + pub fn with_conflict_policy(mut self, policy: IdentityConflictPolicy) -> Self { + self.conflict_policy = policy; + self + } + + /// Sets the witness configuration for the KERI inception event. + /// + /// Args: + /// * `config`: Witness endpoints and thresholds. + /// + /// Usage: + /// ```ignore + /// let config = CreateDeveloperIdentityConfig::builder("my-key") + /// .with_witness_config(witness_cfg) + /// .build(); + /// ``` + pub fn with_witness_config(mut self, config: auths_id::witness_config::WitnessConfig) -> Self { + self.witness_config = Some(config); + self + } + + /// Attaches custom metadata to the identity (e.g. `created_at`, `setup_profile`). + /// + /// Args: + /// * `metadata`: Arbitrary JSON metadata. + /// + /// Usage: + /// ```ignore + /// let config = CreateDeveloperIdentityConfig::builder("my-key") + /// .with_metadata(serde_json::json!({"team": "platform"})) + /// .build(); + /// ``` + pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self { + self.metadata = Some(metadata); + self + } + + /// Sets the path to the `auths-sign` binary used for git signing configuration. + /// + /// Required when `git_signing_scope` is not `Skip`. The CLI resolves this via + /// `which::which("auths-sign")`. + /// + /// Args: + /// * `path`: Absolute path to the `auths-sign` binary. + /// + /// Usage: + /// ```ignore + /// let config = CreateDeveloperIdentityConfig::builder("my-key") + /// .with_sign_binary_path(PathBuf::from("/usr/local/bin/auths-sign")) + /// .build(); + /// ``` + pub fn with_sign_binary_path(mut self, path: PathBuf) -> Self { + self.sign_binary_path = Some(path); + self + } + + /// Builds the final [`CreateDeveloperIdentityConfig`]. + /// + /// Usage: + /// ```ignore + /// let config = CreateDeveloperIdentityConfig::builder("my-key").build(); + /// ``` + pub fn build(self) -> CreateDeveloperIdentityConfig { + CreateDeveloperIdentityConfig { + key_alias: self.key_alias, + platform: self.platform, + git_signing_scope: self.git_signing_scope, + register_on_registry: self.register_on_registry, + registry_url: self.registry_url, + conflict_policy: self.conflict_policy, + witness_config: self.witness_config, + metadata: self.metadata, + sign_binary_path: self.sign_binary_path, + } + } +} + +/// Configuration for CI/ephemeral identity. +/// +/// The keychain and passphrase are passed separately to [`crate::setup::initialize`] — +/// this struct carries only the CI-specific configuration values. +/// +/// Args: +/// * `ci_environment`: The detected or specified CI platform. +/// * `registry_path`: Path to the ephemeral auths registry. +/// +/// Usage: +/// ```ignore +/// let config = CiIdentityConfig { +/// ci_environment: CiEnvironment::GitHubActions, +/// registry_path: PathBuf::from("/tmp/.auths"), +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct CiIdentityConfig { + /// The detected or specified CI platform. + pub ci_environment: CiEnvironment, + /// Path to the ephemeral auths registry directory. + pub registry_path: PathBuf, +} + +/// Selects which identity persona to provision via [`crate::setup::initialize`]. +/// +/// Usage: +/// ```ignore +/// // Developer preset (platform keychain, git signing): +/// let config = IdentityConfig::developer(KeyAlias::new_unchecked("work-laptop")); +/// +/// // CI preset (memory keychain, ephemeral): +/// let config = IdentityConfig::ci(PathBuf::from("/tmp/.auths-ci")); +/// +/// // Agent preset (minimal capabilities, long-lived): +/// let config = IdentityConfig::agent(KeyAlias::new_unchecked("deploy-bot"), registry_path); +/// +/// // Custom configuration: +/// let config = IdentityConfig::Developer( +/// CreateDeveloperIdentityConfig::builder(alias) +/// .with_platform(crate::types::PlatformVerification::GitHub { access_token: token }) +/// .build() +/// ); +/// ``` +#[derive(Debug)] +pub enum IdentityConfig { + /// Full local developer setup: platform keychain, git signing, passphrase. + Developer(CreateDeveloperIdentityConfig), + /// Ephemeral CI setup: memory keychain, no git signing. + Ci(CiIdentityConfig), + /// Agent setup: file keychain, scoped capabilities, long-lived. + Agent(CreateAgentIdentityConfig), +} + +impl IdentityConfig { + /// Create a developer identity config with sensible defaults. + /// + /// Args: + /// * `alias`: Human-readable key alias (e.g. `"work-laptop"`). + /// + /// Usage: + /// ```ignore + /// let config = IdentityConfig::developer(KeyAlias::new_unchecked("work-laptop")); + /// ``` + pub fn developer(alias: KeyAlias) -> Self { + Self::Developer(CreateDeveloperIdentityConfig::builder(alias).build()) + } + + /// Create a CI/ephemeral identity config. + /// + /// Args: + /// * `registry_path`: Path to the ephemeral auths registry directory. + /// + /// Usage: + /// ```ignore + /// let config = IdentityConfig::ci(PathBuf::from("/tmp/.auths-ci")); + /// ``` + pub fn ci(registry_path: impl Into) -> Self { + Self::Ci(CiIdentityConfig { + ci_environment: CiEnvironment::Unknown, + registry_path: registry_path.into(), + }) + } + + /// Create an agent identity config with sensible defaults. + /// + /// Args: + /// * `alias`: Human-readable agent name. + /// * `registry_path`: Path to the auths registry directory. + /// + /// Usage: + /// ```ignore + /// let config = IdentityConfig::agent(KeyAlias::new_unchecked("deploy-bot"), registry_path); + /// ``` + pub fn agent(alias: KeyAlias, registry_path: impl Into) -> Self { + Self::Agent(CreateAgentIdentityConfig::builder(alias, registry_path).build()) + } +} + +/// Configuration for agent identity. +/// +/// Use [`CreateAgentIdentityConfigBuilder`] to construct this with optional fields. +/// +/// Args: +/// * `alias`: Human-readable name for the agent. +/// * `parent_identity_did`: The DID of the identity that owns this agent. +/// * `registry_path`: Path to the auths registry. +/// +/// Usage: +/// ```ignore +/// let config = CreateAgentIdentityConfig::builder("deploy-bot", "did:keri:abc123", path) +/// .with_capabilities(vec!["sign-commit".into()]) +/// .build(); +/// ``` +#[derive(Debug)] +pub struct CreateAgentIdentityConfig { + /// Human-readable name for the agent. + pub alias: KeyAlias, + /// Capabilities granted to the agent. + pub capabilities: Vec, + /// DID of the parent identity that delegates authority. + pub parent_identity_did: Option, + /// Path to the auths registry directory. + pub registry_path: PathBuf, + /// Duration in seconds until expiration (per RFC 6749). + pub expires_in: Option, + /// If true, construct state without persisting. + pub dry_run: bool, +} + +impl CreateAgentIdentityConfig { + /// Creates a builder with alias and registry path. + /// + /// Args: + /// * `alias`: Human-readable name for the agent. + /// * `registry_path`: Path to the auths registry directory. + /// + /// Usage: + /// ```ignore + /// let builder = CreateAgentIdentityConfig::builder("deploy-bot", path); + /// ``` + pub fn builder( + alias: KeyAlias, + registry_path: impl Into, + ) -> CreateAgentIdentityConfigBuilder { + CreateAgentIdentityConfigBuilder { + alias, + capabilities: Vec::new(), + parent_identity_did: None, + registry_path: registry_path.into(), + expires_in: None, + dry_run: false, + } + } +} + +/// Builder for [`CreateAgentIdentityConfig`]. +#[derive(Debug)] +pub struct CreateAgentIdentityConfigBuilder { + alias: KeyAlias, + capabilities: Vec, + parent_identity_did: Option, + registry_path: PathBuf, + expires_in: Option, + dry_run: bool, +} + +impl CreateAgentIdentityConfigBuilder { + /// Sets the parent identity DID that delegates authority to this agent. + /// + /// Args: + /// * `did`: The DID of the owning identity. + /// + /// Usage: + /// ```ignore + /// let config = CreateAgentIdentityConfig::builder("bot", path) + /// .with_parent_did("did:keri:abc123") + /// .build(); + /// ``` + pub fn with_parent_did(mut self, did: impl Into) -> Self { + self.parent_identity_did = Some(did.into()); + self + } + + /// Sets the capabilities granted to the agent. + /// + /// Args: + /// * `capabilities`: List of capabilities. + /// + /// Usage: + /// ```ignore + /// let config = CreateAgentIdentityConfig::builder("bot", path) + /// .with_capabilities(vec![Capability::sign_commit()]) + /// .build(); + /// ``` + pub fn with_capabilities(mut self, capabilities: Vec) -> Self { + self.capabilities = capabilities; + self + } + + /// Sets the agent key expiration time in seconds. + /// + /// Args: + /// * `secs`: Seconds until the agent identity expires. + /// + /// Usage: + /// ```ignore + /// let config = CreateAgentIdentityConfig::builder("bot", path) + /// .with_expiry(86400) // 24 hours + /// .build(); + /// ``` + pub fn with_expiry(mut self, secs: u64) -> Self { + self.expires_in = Some(secs); + self + } + + /// Enables dry-run mode (constructs state without persisting). + /// + /// Usage: + /// ```ignore + /// let config = CreateAgentIdentityConfig::builder("bot", path) + /// .dry_run(true) + /// .build(); + /// ``` + pub fn dry_run(mut self, enabled: bool) -> Self { + self.dry_run = enabled; + self + } + + /// Builds the final [`CreateAgentIdentityConfig`]. + /// + /// Usage: + /// ```ignore + /// let config = CreateAgentIdentityConfig::builder("bot", path).build(); + /// ``` + pub fn build(self) -> CreateAgentIdentityConfig { + CreateAgentIdentityConfig { + alias: self.alias, + capabilities: self.capabilities, + parent_identity_did: self.parent_identity_did, + registry_path: self.registry_path, + expires_in: self.expires_in, + dry_run: self.dry_run, + } + } +} + +/// Configuration for rotating an identity's signing keys. +/// +/// Args: +/// * `repo_path`: Path to the auths registry (typically `~/.auths`). +/// * `identity_key_alias`: Keychain alias of the current signing key. +/// If `None`, the first non-next alias for the identity is used. +/// * `next_key_alias`: Keychain alias to store the new key under. +/// Defaults to `-rotated-`. +/// +/// Usage: +/// ```ignore +/// let config = IdentityRotationConfig { +/// repo_path: PathBuf::from("/home/user/.auths"), +/// identity_key_alias: Some("main".into()), +/// next_key_alias: None, +/// }; +/// ``` +#[derive(Debug)] +pub struct IdentityRotationConfig { + /// Path to the auths registry (typically `~/.auths`). + pub repo_path: PathBuf, + /// Keychain alias of the current signing key (auto-detected if `None`). + pub identity_key_alias: Option, + /// Keychain alias for the new rotated key (auto-generated if `None`). + pub next_key_alias: Option, +} + +// Result types for identity operations + +/// Outcome of a successful developer identity setup. +/// +/// Usage: +/// ```ignore +/// let result = initialize(IdentityConfig::developer(alias), &ctx, keychain, &signer, &provider, git_cfg)?; +/// if let InitializeResult::Developer(r) = result { +/// println!("Created identity: {}", r.identity_did); +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct DeveloperIdentityResult { + /// The controller DID of the created identity. + pub identity_did: IdentityDID, + /// The device DID bound to this identity. + pub device_did: DeviceDID, + /// The keychain alias used for the signing key. + pub key_alias: KeyAlias, + /// Result of platform verification, if performed. + pub platform_claim: Option, + /// Whether git commit signing was configured. + pub git_signing_configured: bool, + /// Result of registry registration, if performed. + pub registered: Option, +} + +/// Outcome of a successful CI/ephemeral identity setup. +/// +/// Usage: +/// ```ignore +/// let result = initialize(IdentityConfig::ci(registry_path), &ctx, keychain, &signer, &provider, None)?; +/// if let InitializeResult::Ci(r) = result { +/// for line in &r.env_block { println!("{line}"); } +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct CiIdentityResult { + /// The controller DID of the CI identity. + pub identity_did: IdentityDID, + /// The device DID bound to this CI identity. + pub device_did: DeviceDID, + /// Shell `export` lines for configuring CI environment variables. + pub env_block: Vec, +} + +/// Outcome of a successful agent identity setup. +/// +/// Usage: +/// ```ignore +/// let result = initialize(IdentityConfig::agent(alias, path), &ctx, keychain, &signer, &provider, None)?; +/// if let InitializeResult::Agent(r) = result { +/// println!("Agent {:?} delegated by {:?}", r.agent_did, r.parent_did); +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct AgentIdentityResult { + /// The DID of the newly created agent identity (None for dry-run proposals). + pub agent_did: Option, + /// The DID of the parent identity that delegated authority (None if no parent). + pub parent_did: Option, + /// The capabilities granted to the agent. + pub capabilities: Vec, +} + +/// Outcome of [`crate::setup::initialize`] — one variant per identity persona. +/// +/// Usage: +/// ```ignore +/// match initialize(config, &ctx, keychain, &signer, &provider, git_cfg)? { +/// InitializeResult::Developer(r) => display_developer_result(r), +/// InitializeResult::Ci(r) => display_ci_result(r), +/// InitializeResult::Agent(r) => display_agent_result(r), +/// } +/// ``` +#[derive(Debug, Clone)] +pub enum InitializeResult { + /// Developer identity result. + Developer(DeveloperIdentityResult), + /// CI/ephemeral identity result. + Ci(CiIdentityResult), + /// Agent identity result. + Agent(AgentIdentityResult), +} + +/// Outcome of a successful identity rotation. +/// +/// Usage: +/// ```ignore +/// let result: IdentityRotationResult = rotate_identity(config, provider)?; +/// println!("Rotated DID: {}", result.controller_did); +/// println!("New key: {}...", result.new_key_fingerprint); +/// println!("Old key: {}...", result.previous_key_fingerprint); +/// ``` +#[derive(Debug, Clone)] +pub struct IdentityRotationResult { + /// The controller DID of the rotated identity. + pub controller_did: IdentityDID, + /// Hex-encoded fingerprint of the new signing key. + pub new_key_fingerprint: String, + /// Hex-encoded fingerprint of the previous signing key. + pub previous_key_fingerprint: String, + /// KERI sequence number after this rotation event. + pub sequence: u64, +} + +/// Outcome of a successful registry registration. +/// +/// Usage: +/// ```ignore +/// if let Some(reg) = result.registered { +/// println!("Registered {} at {}", reg.did, reg.registry); +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct RegistrationOutcome { + /// The DID returned by the registry (e.g. `did:keri:EABC...`). + pub did: IdentityDID, + /// The registry URL where the identity was registered. + pub registry: String, + /// Number of platform claims indexed by the registry. + pub platform_claims_indexed: usize, +} diff --git a/crates/auths-sdk/src/domains/mod.rs b/crates/auths-sdk/src/domains/mod.rs index f99d5707..7bf351de 100644 --- a/crates/auths-sdk/src/domains/mod.rs +++ b/crates/auths-sdk/src/domains/mod.rs @@ -1,5 +1,20 @@ //! Domain services for Auths functionality. //! //! Modules organize domain-specific business logic separate from I/O concerns. +//! +//! ## Domain Architecture +//! +//! Each domain is self-contained with: +//! - `types.rs` — Request/response/config types and domain models +//! - `service.rs` — Business logic and orchestration +//! - `error.rs` — Domain-specific error types pub mod agents; +pub mod auth; +pub mod compliance; +pub mod device; +pub mod diagnostics; +pub mod identity; +pub mod namespace; +pub mod org; +pub mod signing; diff --git a/crates/auths-sdk/src/domains/namespace/error.rs b/crates/auths-sdk/src/domains/namespace/error.rs new file mode 100644 index 00000000..960147a5 --- /dev/null +++ b/crates/auths-sdk/src/domains/namespace/error.rs @@ -0,0 +1 @@ +//! error for namespace domain diff --git a/crates/auths-sdk/src/domains/namespace/mod.rs b/crates/auths-sdk/src/domains/namespace/mod.rs new file mode 100644 index 00000000..1e716457 --- /dev/null +++ b/crates/auths-sdk/src/domains/namespace/mod.rs @@ -0,0 +1,10 @@ +//! Namespace domain services +//! +//! Multi-tenant namespace management and isolation. + +/// Namespace errors +pub mod error; +/// Namespace services +pub mod service; +/// Namespace types and configuration +pub mod types; diff --git a/crates/auths-sdk/src/domains/namespace/service.rs b/crates/auths-sdk/src/domains/namespace/service.rs new file mode 100644 index 00000000..6ebc4441 --- /dev/null +++ b/crates/auths-sdk/src/domains/namespace/service.rs @@ -0,0 +1 @@ +//! service for namespace domain diff --git a/crates/auths-sdk/src/domains/namespace/types.rs b/crates/auths-sdk/src/domains/namespace/types.rs new file mode 100644 index 00000000..e5dfe026 --- /dev/null +++ b/crates/auths-sdk/src/domains/namespace/types.rs @@ -0,0 +1 @@ +//! types for namespace domain diff --git a/crates/auths-sdk/src/domains/org/error.rs b/crates/auths-sdk/src/domains/org/error.rs new file mode 100644 index 00000000..dbc95c3c --- /dev/null +++ b/crates/auths-sdk/src/domains/org/error.rs @@ -0,0 +1,121 @@ +use auths_core::error::AuthsErrorInfo; +use thiserror::Error; + +/// Errors from organization member management workflows. +/// +/// Usage: +/// ```ignore +/// match result { +/// Err(OrgError::AdminNotFound { .. }) => { /* 403 Forbidden */ } +/// Err(OrgError::MemberNotFound { .. }) => { /* 404 Not Found */ } +/// Err(e) => return Err(e.into()), +/// Ok(att) => { /* proceed */ } +/// } +/// ``` +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum OrgError { + /// No admin matching the given public key was found in the organization. + #[error("no admin with the given public key found in organization '{org}'")] + AdminNotFound { + /// The organization identifier. + org: String, + }, + + /// The specified member was not found in the organization. + #[error("member '{did}' not found in organization '{org}'")] + MemberNotFound { + /// The organization identifier. + org: String, + /// The DID of the member that was not found. + did: String, + }, + + /// The member has already been revoked. + #[error("member '{did}' is already revoked")] + AlreadyRevoked { + /// The DID of the already-revoked member. + did: String, + }, + + /// The capability string could not be parsed. + #[error("invalid capability '{cap}': {reason}")] + InvalidCapability { + /// The invalid capability string. + cap: String, + /// The reason parsing failed. + reason: String, + }, + + /// The organization DID is malformed. + #[error("invalid organization DID: {0}")] + InvalidDid(String), + + /// The hex-encoded public key is invalid. + #[error("invalid public key: {0}")] + InvalidPublicKey(String), + + /// A signing operation failed while creating or revoking an attestation. + #[error("signing error: {0}")] + Signing(String), + + /// The identity could not be loaded from storage. + #[error("identity error: {0}")] + Identity(String), + + /// A key storage operation failed. + #[error("key storage error: {0}")] + KeyStorage(String), + + /// A storage operation failed. + #[error("storage error: {0}")] + Storage(#[source] auths_id::storage::registry::backend::RegistryError), +} + +impl AuthsErrorInfo for OrgError { + fn error_code(&self) -> &'static str { + match self { + Self::AdminNotFound { .. } => "AUTHS-E5601", + Self::MemberNotFound { .. } => "AUTHS-E5602", + Self::AlreadyRevoked { .. } => "AUTHS-E5603", + Self::InvalidCapability { .. } => "AUTHS-E5604", + Self::InvalidDid(_) => "AUTHS-E5605", + Self::InvalidPublicKey(_) => "AUTHS-E5606", + Self::Signing(_) => "AUTHS-E5607", + Self::Identity(_) => "AUTHS-E5608", + Self::KeyStorage(_) => "AUTHS-E5609", + Self::Storage(_) => "AUTHS-E5610", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::AdminNotFound { .. } => { + Some("Verify you are using the correct admin key for this organization") + } + Self::MemberNotFound { .. } => { + Some("Run `auths org list-members` to see current members") + } + Self::AlreadyRevoked { .. } => { + Some("This member has already been revoked from the organization") + } + Self::InvalidCapability { .. } => { + Some("Use a valid capability (e.g., 'sign_commit', 'manage_members', 'admin')") + } + Self::InvalidDid(_) => Some("Organization DIDs must be valid did:keri identifiers"), + Self::InvalidPublicKey(_) => Some("Public keys must be hex-encoded Ed25519 keys"), + Self::Signing(_) => { + Some("The signing operation failed; check your key access with `auths key list`") + } + Self::Identity(_) => { + Some("Failed to load identity; run `auths id show` to check identity status") + } + Self::KeyStorage(_) => { + Some("Failed to access key storage; run `auths doctor` to diagnose") + } + Self::Storage(_) => { + Some("Failed to access organization storage; check repository permissions") + } + } + } +} diff --git a/crates/auths-sdk/src/domains/org/mod.rs b/crates/auths-sdk/src/domains/org/mod.rs new file mode 100644 index 00000000..afcf6443 --- /dev/null +++ b/crates/auths-sdk/src/domains/org/mod.rs @@ -0,0 +1,7 @@ +//! Domain services for org. + +/// Org errors +pub mod error; +/// Org services +pub mod service; +pub mod types; diff --git a/crates/auths-sdk/src/domains/org/service.rs b/crates/auths-sdk/src/domains/org/service.rs new file mode 100644 index 00000000..b02e503d --- /dev/null +++ b/crates/auths-sdk/src/domains/org/service.rs @@ -0,0 +1,546 @@ +//! Organization membership workflows: add, revoke, update, and list members. +//! +//! All workflows accept an [`OrgContext`] carrying injected infrastructure +//! adapters (registry, clock, signer, passphrase provider). The CLI constructs +//! this context at the presentation boundary; tests inject fakes. + +use std::ops::ControlFlow; + +use auths_core::ports::clock::ClockProvider; +use auths_core::ports::id::UuidProvider; +use auths_core::signing::{PassphraseProvider, SecureSigner}; +use auths_core::storage::keychain::KeyAlias; +use auths_id::attestation::create::create_signed_attestation; +use auths_id::attestation::revoke::create_signed_revocation; +use auths_id::ports::registry::RegistryBackend; +use auths_id::storage::git_refs::AttestationMetadata; +use auths_verifier::Capability; +use auths_verifier::PublicKeyHex; +pub use auths_verifier::core::Role; +use auths_verifier::core::{Attestation, Ed25519PublicKey}; +use auths_verifier::types::{DeviceDID, IdentityDID}; + +use crate::domains::org::error::OrgError; + +/// Runtime dependency container for organization workflows. +/// +/// Bundles all injected infrastructure adapters needed by org operations. +/// The CLI constructs this from real implementations; tests inject fakes. +/// +/// Args: +/// * `registry`: Backend for reading/writing org member attestations. +/// * `clock`: Wall-clock provider (use `SystemClock` in production, `MockClock` in tests). +/// * `uuid_provider`: UUID generator for attestation resource IDs. +/// * `signer`: Signing backend for creating cryptographic signatures. +/// * `passphrase_provider`: Provider for obtaining key decryption passphrases. +/// +/// Usage: +/// ```ignore +/// let ctx = OrgContext { +/// registry: &backend, +/// clock: &SystemClock, +/// uuid_provider: &uuid_provider, +/// signer: &signer, +/// passphrase_provider: passphrase_provider.as_ref(), +/// }; +/// let att = add_organization_member(&ctx, cmd)?; +/// ``` +pub struct OrgContext<'a> { + /// Backend for reading/writing org member attestations. + pub registry: &'a dyn RegistryBackend, + /// Wall-clock provider (use `SystemClock` in production, `MockClock` in tests). + pub clock: &'a dyn ClockProvider, + /// UUID generator for attestation resource IDs. + pub uuid_provider: &'a dyn UuidProvider, + /// Signing backend for creating cryptographic signatures. + pub signer: &'a dyn SecureSigner, + /// Provider for obtaining key decryption passphrases. + pub passphrase_provider: &'a dyn PassphraseProvider, +} + +/// Ordering key for org member display: admin < member < readonly < unknown. +/// +/// Args: +/// * `role`: Optional role as stored in an attestation. +/// +/// Usage: +/// ```ignore +/// members.sort_by(|a, b| member_role_order(&a.role).cmp(&member_role_order(&b.role))); +/// ``` +pub fn member_role_order(role: &Option) -> u8 { + match role { + Some(Role::Admin) => 0, + Some(Role::Member) => 1, + Some(Role::Readonly) => 2, + None => 3, + } +} + +/// Find the first org-member attestation whose device public key matches `public_key_hex` +/// and which holds the `manage_members` capability. +/// +/// Args: +/// * `backend`: Registry backend to query. +/// * `org_prefix`: The KERI method-specific ID of the organization. +/// * `public_key_hex`: Hex-encoded device public key of the candidate admin. +/// +/// Usage: +/// ```ignore +/// let admin = find_admin(backend, "EOrg1234567890", &pubkey_hex)?; +/// ``` +pub(crate) fn find_admin( + backend: &dyn RegistryBackend, + org_prefix: &str, + public_key_hex: &PublicKeyHex, +) -> Result { + let signer_bytes = hex::decode(public_key_hex.as_str()) + .map_err(|e| OrgError::InvalidPublicKey(format!("hex decode failed: {e}")))?; + + let mut found: Option = None; + + backend + .visit_org_member_attestations(org_prefix, &mut |entry| { + if let Ok(att) = &entry.attestation + && att.device_public_key.as_bytes().as_slice() == signer_bytes.as_slice() + && !att.is_revoked() + && att.capabilities.contains(&Capability::manage_members()) + { + found = Some(att.clone()); + return ControlFlow::Break(()); + } + ControlFlow::Continue(()) + }) + .map_err(OrgError::Storage)?; + + found.ok_or_else(|| OrgError::AdminNotFound { + org: org_prefix.to_owned(), + }) +} + +/// Find a member's current attestation by their DID within an org. +/// +/// Args: +/// * `backend`: Registry backend to query. +/// * `org_prefix`: The KERI method-specific ID of the organization. +/// * `member_did`: Full DID of the member to look up. +/// +/// Usage: +/// ```ignore +/// let att = find_member(backend, "EOrg1234567890", "did:key:z6Mk...")?; +/// ``` +pub(crate) fn find_member( + backend: &dyn RegistryBackend, + org_prefix: &str, + member_did: &str, +) -> Result, OrgError> { + let mut found: Option = None; + + backend + .visit_org_member_attestations(org_prefix, &mut |entry| { + if entry.did.to_string() == member_did + && let Ok(att) = &entry.attestation + { + found = Some(att.clone()); + return ControlFlow::Break(()); + } + ControlFlow::Continue(()) + }) + .map_err(OrgError::Storage)?; + + Ok(found) +} + +// ── Parse helpers ───────────────────────────────────────────────────────────── + +fn parse_capabilities(raw: &[String]) -> Result, OrgError> { + raw.iter() + .map(|s| { + Capability::try_from(s.clone()).map_err(|e| OrgError::InvalidCapability { + cap: s.clone(), + reason: e.to_string(), + }) + }) + .collect() +} + +// ── Command structs ─────────────────────────────────────────────────────────── + +/// Command to add a new member to an organization. +/// +/// Args: +/// * `org_prefix`: KERI method-specific ID of the org. +/// * `member_did`: Full DID of the member being added. +/// * `member_public_key`: Ed25519 public key of the member. +/// * `role`: Role to assign. +/// * `capabilities`: Capability strings to grant. +/// * `admin_public_key_hex`: Hex-encoded public key of the signing admin. +/// * `signer_alias`: Keychain alias of the admin's signing key. +/// * `note`: Optional note for the attestation. +/// +/// Usage: +/// ```ignore +/// let cmd = AddMemberCommand { +/// org_prefix: "EOrg1234567890".into(), +/// member_did: "did:key:z6Mk...".into(), +/// member_public_key: Ed25519PublicKey::from_bytes(pk_bytes), +/// role: Role::Member, +/// capabilities: vec!["sign_commit".into()], +/// admin_public_key_hex: hex::encode(&admin_pk), +/// signer_alias: KeyAlias::new_unchecked("org-myorg"), +/// note: Some("Added by admin".into()), +/// }; +/// ``` +pub struct AddMemberCommand { + /// KERI method-specific ID of the org. + pub org_prefix: String, + /// Full DID of the member being added. + pub member_did: String, + /// Ed25519 public key of the member. + pub member_public_key: Ed25519PublicKey, + /// Role to assign. + pub role: Role, + /// Capability strings to grant. + pub capabilities: Vec, + /// Hex-encoded public key of the signing admin. + pub admin_public_key_hex: PublicKeyHex, + /// Keychain alias of the admin's signing key. + pub signer_alias: KeyAlias, + /// Optional note for the attestation. + pub note: Option, +} + +/// Command to revoke an existing org member. +/// +/// Args: +/// * `org_prefix`: KERI method-specific ID of the org. +/// * `member_did`: Full DID of the member to revoke. +/// * `member_public_key`: Ed25519 public key of the member (from existing attestation). +/// * `admin_public_key_hex`: Hex-encoded public key of the signing admin. +/// * `signer_alias`: Keychain alias of the admin's signing key. +/// * `note`: Optional reason for revocation. +/// +/// Usage: +/// ```ignore +/// let cmd = RevokeMemberCommand { +/// org_prefix: "EOrg1234567890".into(), +/// member_did: "did:key:z6Mk...".into(), +/// member_public_key: Ed25519PublicKey::from_bytes(pk_bytes), +/// admin_public_key_hex: hex::encode(&admin_pk), +/// signer_alias: KeyAlias::new_unchecked("org-myorg"), +/// note: Some("Policy violation".into()), +/// }; +/// ``` +pub struct RevokeMemberCommand { + /// KERI method-specific ID of the org. + pub org_prefix: String, + /// Full DID of the member to revoke. + pub member_did: String, + /// Ed25519 public key of the member (from existing attestation). + pub member_public_key: Ed25519PublicKey, + /// Hex-encoded public key of the signing admin. + pub admin_public_key_hex: PublicKeyHex, + /// Keychain alias of the admin's signing key. + pub signer_alias: KeyAlias, + /// Optional reason for revocation. + pub note: Option, +} + +/// Command to update the capability set of an org member. +pub struct UpdateCapabilitiesCommand { + /// KERI method-specific ID of the org. + pub org_prefix: String, + /// Full DID of the member whose capabilities are being updated. + pub member_did: String, + /// New capability strings to replace the existing set. + pub capabilities: Vec, + /// Hex-encoded public key of the admin performing the update. + pub public_key_hex: PublicKeyHex, +} + +/// Command to atomically update a member's role and capabilities. +/// +/// Unlike separate revoke+add, this is a single atomic operation that +/// prevents partial state if one step fails. +pub struct UpdateMemberCommand { + /// KERI method-specific ID of the org. + pub org_prefix: String, + /// Full DID of the member being updated. + pub member_did: String, + /// New role (if changing). + pub role: Option, + /// New capability strings (if changing). + pub capabilities: Option>, + /// Hex-encoded public key of the admin performing the update. + pub admin_public_key_hex: PublicKeyHex, +} + +/// Accepts either a KERI prefix or a full DID. +/// +/// Auto-detected by whether the string starts with `did:`. +#[derive(Debug, Clone)] +pub enum OrgIdentifier { + /// Bare KERI prefix (e.g. `EOrg1234567890`). + Prefix(String), + /// Full DID (e.g. `did:keri:EOrg1234567890`). + Did(String), +} + +impl OrgIdentifier { + /// Parse a string into an `OrgIdentifier`, auto-detecting the format. + pub fn parse(s: &str) -> Self { + if s.starts_with("did:") { + OrgIdentifier::Did(s.to_owned()) + } else { + OrgIdentifier::Prefix(s.to_owned()) + } + } + + /// Extract the KERI prefix regardless of format. + pub fn prefix(&self) -> &str { + match self { + OrgIdentifier::Prefix(p) => p, + OrgIdentifier::Did(d) => d.strip_prefix("did:keri:").unwrap_or(d), + } + } +} + +impl From<&str> for OrgIdentifier { + fn from(s: &str) -> Self { + OrgIdentifier::parse(s) + } +} + +// ── Workflow functions ──────────────────────────────────────────────────────── + +/// Add a new member to an organization with a cryptographically signed attestation. +/// +/// Verifies that the signer holds the `manage_members` capability, creates a +/// signed attestation via `create_signed_attestation` from auths-id, and stores +/// the result in the registry backend. +/// +/// Args: +/// * `ctx`: Organization context with injected infrastructure adapters. +/// * `cmd`: Add-member command with org prefix, member DID, role, and capabilities. +/// +/// Usage: +/// ```ignore +/// let att = add_organization_member(&ctx, cmd)?; +/// println!("Added member: {}", att.subject); +/// ``` +pub fn add_organization_member( + ctx: &OrgContext, + cmd: AddMemberCommand, +) -> Result { + let admin_att = find_admin(ctx.registry, &cmd.org_prefix, &cmd.admin_public_key_hex)?; + let parsed_caps = parse_capabilities(&cmd.capabilities)?; + let now = ctx.clock.now(); + let rid = ctx.uuid_provider.new_id().to_string(); + + #[allow(clippy::disallowed_methods)] + // INVARIANT: cmd.member_did is a did:key string from the CLI, validated by the caller + let member_did = DeviceDID::new_unchecked(&cmd.member_did); + let meta = AttestationMetadata { + note: cmd + .note + .or_else(|| Some(format!("Added as {} by {}", cmd.role, admin_att.subject))), + timestamp: Some(now), + expires_at: None, + }; + + #[allow(clippy::disallowed_methods)] + // INVARIANT: admin_att.issuer is a CanonicalDid from a verified attestation loaded by find_admin() + let admin_issuer_did = IdentityDID::new_unchecked(admin_att.issuer.as_str()); + let attestation = create_signed_attestation( + now, + &rid, + &admin_issuer_did, + &member_did, + cmd.member_public_key.as_bytes(), + Some(serde_json::json!({ + "org_role": cmd.role.to_string(), + "org_did": format!("did:keri:{}", cmd.org_prefix), + })), + &meta, + ctx.signer, + ctx.passphrase_provider, + Some(&cmd.signer_alias), + None, + parsed_caps, + Some(cmd.role), + { + #[allow(clippy::disallowed_methods)] + // INVARIANT: admin_att.subject is a CanonicalDid from a verified attestation loaded by find_admin() + Some(IdentityDID::new_unchecked(admin_att.subject.to_string())) + }, + ) + .map_err(|e| OrgError::Signing(e.to_string()))?; + + ctx.registry + .store_org_member(&cmd.org_prefix, &attestation) + .map_err(OrgError::Storage)?; + + Ok(attestation) +} + +/// Revoke an existing org member with a cryptographically signed revocation. +/// +/// Verifies that the signer holds `manage_members`, checks the member exists +/// and is not already revoked, then creates a signed revocation attestation +/// via `create_signed_revocation` from auths-id. +/// +/// Args: +/// * `ctx`: Organization context with injected infrastructure adapters. +/// * `cmd`: Revoke-member command with org prefix and member DID. +/// +/// Usage: +/// ```ignore +/// let revoked = revoke_organization_member(&ctx, cmd)?; +/// assert!(revoked.is_revoked()); +/// ``` +pub fn revoke_organization_member( + ctx: &OrgContext, + cmd: RevokeMemberCommand, +) -> Result { + let admin_att = find_admin(ctx.registry, &cmd.org_prefix, &cmd.admin_public_key_hex)?; + + let existing = + find_member(ctx.registry, &cmd.org_prefix, &cmd.member_did)?.ok_or_else(|| { + OrgError::MemberNotFound { + org: cmd.org_prefix.clone(), + did: cmd.member_did.clone(), + } + })?; + + if existing.is_revoked() { + return Err(OrgError::AlreadyRevoked { + did: cmd.member_did.clone(), + }); + } + + let now = ctx.clock.now(); + #[allow(clippy::disallowed_methods)] + // INVARIANT: cmd.member_did is a did:key string from the CLI, validated by the caller + let member_did = DeviceDID::new_unchecked(&cmd.member_did); + + #[allow(clippy::disallowed_methods)] + // INVARIANT: admin_att.issuer is a CanonicalDid from a verified attestation loaded by find_admin() + let admin_issuer_did = IdentityDID::new_unchecked(admin_att.issuer.as_str()); + let revocation = create_signed_revocation( + admin_att.rid.as_str(), + &admin_issuer_did, + &member_did, + cmd.member_public_key.as_bytes(), + cmd.note, + None, + now, + ctx.signer, + ctx.passphrase_provider, + &cmd.signer_alias, + ) + .map_err(|e| OrgError::Signing(e.to_string()))?; + + ctx.registry + .store_org_member(&cmd.org_prefix, &revocation) + .map_err(OrgError::Storage)?; + + Ok(revocation) +} + +/// Update the capability set of an org member. +/// +/// Verifies that the signer holds `manage_members`, checks the member exists +/// and is not revoked, replaces their capability set, and re-stores. +/// +/// Args: +/// * `backend`: Registry backend for storage. +/// * `clock`: Clock provider for the update timestamp. +/// * `cmd`: Update-capabilities command with org prefix, member DID, and new capabilities. +/// +/// Usage: +/// ```ignore +/// let updated = update_member_capabilities(backend, clock, cmd)?; +/// ``` +pub fn update_member_capabilities( + backend: &dyn RegistryBackend, + clock: &dyn ClockProvider, + cmd: UpdateCapabilitiesCommand, +) -> Result { + find_admin(backend, &cmd.org_prefix, &cmd.public_key_hex)?; + + let existing = find_member(backend, &cmd.org_prefix, &cmd.member_did)?.ok_or_else(|| { + OrgError::MemberNotFound { + org: cmd.org_prefix.clone(), + did: cmd.member_did.clone(), + } + })?; + + if existing.is_revoked() { + return Err(OrgError::AlreadyRevoked { + did: cmd.member_did.clone(), + }); + } + + let parsed_caps = parse_capabilities(&cmd.capabilities)?; + let mut updated = existing; + updated.capabilities = parsed_caps; + updated.timestamp = Some(clock.now()); + + backend + .store_org_member(&cmd.org_prefix, &updated) + .map_err(OrgError::Storage)?; + + Ok(updated) +} + +/// Atomically update a member's role and/or capabilities in a single operation. +/// +/// Unlike the current pattern of revoke+re-add, this performs an in-place update +/// to prevent partial state on failure. +pub fn update_organization_member( + backend: &dyn RegistryBackend, + clock: &dyn ClockProvider, + cmd: UpdateMemberCommand, +) -> Result { + find_admin(backend, &cmd.org_prefix, &cmd.admin_public_key_hex)?; + + let existing = find_member(backend, &cmd.org_prefix, &cmd.member_did)?.ok_or_else(|| { + OrgError::MemberNotFound { + org: cmd.org_prefix.clone(), + did: cmd.member_did.clone(), + } + })?; + + if existing.is_revoked() { + return Err(OrgError::AlreadyRevoked { + did: cmd.member_did.clone(), + }); + } + + let mut updated = existing; + + if let Some(caps) = cmd.capabilities { + updated.capabilities = parse_capabilities(&caps)?; + } + if let Some(role) = cmd.role { + updated.role = Some(role); + } + updated.timestamp = Some(clock.now()); + + backend + .store_org_member(&cmd.org_prefix, &updated) + .map_err(OrgError::Storage)?; + + Ok(updated) +} + +/// Look up a single org member by DID (O(1) with the right backend). +pub fn get_organization_member( + backend: &dyn RegistryBackend, + org_prefix: &str, + member_did: &str, +) -> Result { + find_member(backend, org_prefix, member_did)?.ok_or_else(|| OrgError::MemberNotFound { + org: org_prefix.to_owned(), + did: member_did.to_owned(), + }) +} diff --git a/crates/auths-sdk/src/domains/org/types.rs b/crates/auths-sdk/src/domains/org/types.rs new file mode 100644 index 00000000..f20617d9 --- /dev/null +++ b/crates/auths-sdk/src/domains/org/types.rs @@ -0,0 +1 @@ +//! types for org domain diff --git a/crates/auths-sdk/src/domains/signing/error.rs b/crates/auths-sdk/src/domains/signing/error.rs new file mode 100644 index 00000000..660f8cd7 --- /dev/null +++ b/crates/auths-sdk/src/domains/signing/error.rs @@ -0,0 +1 @@ +//! error for signing domain diff --git a/crates/auths-sdk/src/domains/signing/mod.rs b/crates/auths-sdk/src/domains/signing/mod.rs new file mode 100644 index 00000000..af2dc320 --- /dev/null +++ b/crates/auths-sdk/src/domains/signing/mod.rs @@ -0,0 +1,7 @@ +//! Domain services for signing. + +pub mod error; +/// Platform-specific signing implementations +pub mod platform; +pub mod service; +pub mod types; diff --git a/crates/auths-sdk/src/domains/signing/platform.rs b/crates/auths-sdk/src/domains/signing/platform.rs new file mode 100644 index 00000000..198c6817 --- /dev/null +++ b/crates/auths-sdk/src/domains/signing/platform.rs @@ -0,0 +1,84 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::{Deserialize, Serialize}; + +use auths_core::ports::clock::ClockProvider; +use auths_core::signing::{PassphraseProvider, SecureSigner}; +use auths_core::storage::keychain::KeyAlias; + +use crate::domains::identity::error::SetupError; + +/// A signed platform claim linking a DID to a platform username. +/// +/// Usage: +/// ```ignore +/// let claim: PlatformClaim = serde_json::from_str(&claim_json)?; +/// assert_eq!(claim.platform, "github"); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformClaim { + /// The claim type identifier (always `"platform_claim"`). + #[serde(rename = "type")] + pub claim_type: String, + /// The platform name (e.g. `"github"`, `"gitlab"`). + pub platform: String, + /// The username on the platform. + pub namespace: String, + /// The controller DID (e.g. `"did:keri:E..."`). + pub did: String, + /// ISO-8601 timestamp of when the claim was created. + pub timestamp: String, + /// Base64url-encoded Ed25519 signature over the canonicalized claim. + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +/// Creates a signed platform claim linking a DID to a platform username. +/// +/// The claim is JSON-canonicalized (RFC 8785) before signing, ensuring +/// deterministic verification without the original OAuth token. +/// +/// Args: +/// * `platform`: Platform name (e.g., "github"). +/// * `namespace`: Username on the platform. +/// * `did`: The controller DID (e.g., "did:keri:E..."). +/// * `key_alias`: Keychain alias for the signing key. +/// * `signer`: Secure signer for creating the claim signature. +/// * `passphrase_provider`: Provider for key decryption passphrase. +/// +/// Usage: +/// ```ignore +/// let claim_json = create_platform_claim("github", "octocat", "did:keri:E...", "main", &signer, &provider)?; +/// ``` +pub fn create_platform_claim( + platform: &str, + namespace: &str, + did: &str, + key_alias: &KeyAlias, + signer: &dyn SecureSigner, + passphrase_provider: &dyn PassphraseProvider, + clock: &dyn ClockProvider, +) -> Result { + let mut claim = PlatformClaim { + claim_type: "platform_claim".to_string(), + platform: platform.to_string(), + namespace: namespace.to_string(), + did: did.to_string(), + timestamp: clock.now().to_rfc3339(), + signature: None, + }; + + let unsigned_json = serde_json::to_value(&claim) + .map_err(|e| SetupError::PlatformVerificationFailed(format!("serialize claim: {e}")))?; + let canonical = json_canon::to_string(&unsigned_json) + .map_err(|e| SetupError::PlatformVerificationFailed(format!("canonicalize claim: {e}")))?; + + let signature_bytes = signer + .sign_with_alias(key_alias, passphrase_provider, canonical.as_bytes()) + .map_err(|e| SetupError::PlatformVerificationFailed(format!("sign claim: {e}")))?; + + claim.signature = Some(URL_SAFE_NO_PAD.encode(&signature_bytes)); + + serde_json::to_string_pretty(&claim) + .map_err(|e| SetupError::PlatformVerificationFailed(format!("serialize signed claim: {e}"))) +} diff --git a/crates/auths-sdk/src/domains/signing/service.rs b/crates/auths-sdk/src/domains/signing/service.rs new file mode 100644 index 00000000..045336a5 --- /dev/null +++ b/crates/auths-sdk/src/domains/signing/service.rs @@ -0,0 +1,514 @@ +//! Signing pipeline orchestration. +//! +//! Composed pipeline: validate freeze → sign data → format SSHSIG. +//! Agent communication and passphrase prompting remain in the CLI. + +use crate::context::AuthsContext; +use crate::ports::artifact::ArtifactSource; +use auths_core::crypto::ssh::{self, SecureSeed}; +use auths_core::crypto::{provider_bridge, signer as core_signer}; +use auths_core::signing::{PassphraseProvider, SecureSigner}; +use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage}; +use auths_id::attestation::core::resign_attestation; +use auths_id::attestation::create::create_signed_attestation; +use auths_id::storage::git_refs::AttestationMetadata; +use auths_verifier::core::{Capability, ResourceId}; +use auths_verifier::types::DeviceDID; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +/// Errors from the signing pipeline. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SigningError { + /// The identity is in a freeze state and signing is not permitted. + #[error("identity is frozen: {0}")] + IdentityFrozen(String), + /// The requested key alias could not be resolved from the keychain. + #[error("key resolution failed: {0}")] + KeyResolution(String), + /// The cryptographic signing operation failed. + #[error("signing operation failed: {0}")] + SigningFailed(String), + /// The supplied passphrase was incorrect. + #[error("invalid passphrase")] + InvalidPassphrase, + /// SSHSIG PEM encoding failed after signing. + #[error("PEM encoding failed: {0}")] + PemEncoding(String), + /// The agent is not available (platform unsupported, not installed, or not reachable). + #[error("agent unavailable: {0}")] + AgentUnavailable(String), + /// The agent accepted the signing request but it failed. + #[error("agent signing failed")] + AgentSigningFailed(#[source] crate::ports::agent::AgentSigningError), + /// All passphrase attempts were exhausted without a successful decryption. + #[error("passphrase exhausted after {attempts} attempt(s)")] + PassphraseExhausted { + /// Number of failed attempts before giving up. + attempts: usize, + }, + /// The platform keychain could not be accessed. + #[error("keychain unavailable: {0}")] + KeychainUnavailable(String), + /// The encrypted key material could not be decrypted. + #[error("key decryption failed: {0}")] + KeyDecryptionFailed(String), +} + +impl auths_core::error::AuthsErrorInfo for SigningError { + fn error_code(&self) -> &'static str { + match self { + Self::IdentityFrozen(_) => "AUTHS-E5901", + Self::KeyResolution(_) => "AUTHS-E5902", + Self::SigningFailed(_) => "AUTHS-E5903", + Self::InvalidPassphrase => "AUTHS-E5904", + Self::PemEncoding(_) => "AUTHS-E5905", + Self::AgentUnavailable(_) => "AUTHS-E5906", + Self::AgentSigningFailed(_) => "AUTHS-E5907", + Self::PassphraseExhausted { .. } => "AUTHS-E5908", + Self::KeychainUnavailable(_) => "AUTHS-E5909", + Self::KeyDecryptionFailed(_) => "AUTHS-E5910", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::IdentityFrozen(_) => Some("To unfreeze: auths emergency unfreeze"), + Self::KeyResolution(_) => Some("Run `auths key list` to check available keys"), + Self::SigningFailed(_) => Some( + "The signing operation failed; verify your key is accessible with `auths key list`", + ), + Self::InvalidPassphrase => Some("Check your passphrase and try again"), + Self::PemEncoding(_) => { + Some("Failed to encode the key in PEM format; the key material may be corrupted") + } + Self::AgentUnavailable(_) => Some("Start the agent with `auths agent start`"), + Self::AgentSigningFailed(_) => Some("Check agent logs with `auths agent status`"), + Self::PassphraseExhausted { .. } => Some( + "The passphrase you entered is incorrect (tried 3 times). Verify it matches what you set during init, or try: auths key export --key-alias --format pub", + ), + Self::KeychainUnavailable(_) => Some("Run `auths doctor` to diagnose keychain issues"), + Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"), + } + } +} + +/// Configuration for a signing operation. +/// +/// Args: +/// * `namespace`: The SSHSIG namespace (typically "git"). +/// +/// Usage: +/// ```ignore +/// let config = SigningConfig { +/// namespace: "git".to_string(), +/// }; +/// ``` +pub struct SigningConfig { + /// SSHSIG namespace string (e.g. `"git"` for commit signing). + pub namespace: String, +} + +/// Validate that the identity is not frozen. +/// +/// Args: +/// * `repo_path`: Path to the auths repository (typically `~/.auths`). +/// * `now`: The reference time used to check if the freeze is active. +/// +/// Usage: +/// ```ignore +/// validate_freeze_state(&repo_path, clock.now())?; +/// ``` +pub fn validate_freeze_state( + repo_path: &Path, + now: chrono::DateTime, +) -> Result<(), SigningError> { + use auths_id::freeze::load_active_freeze; + + if let Some(state) = load_active_freeze(repo_path, now) + .map_err(|e| SigningError::IdentityFrozen(e.to_string()))? + { + return Err(SigningError::IdentityFrozen(format!( + "frozen until {}. Remaining: {}. To unfreeze: auths emergency unfreeze", + state.frozen_until.format("%Y-%m-%d %H:%M UTC"), + state.expires_description(now), + ))); + } + + Ok(()) +} + +/// Construct the SSHSIG signed-data payload for the given data and namespace. +/// +/// Args: +/// * `data`: The raw bytes to sign. +/// * `namespace`: The SSHSIG namespace (e.g. "git"). +/// +/// Usage: +/// ```ignore +/// let payload = construct_signature_payload(b"data", "git")?; +/// ``` +pub fn construct_signature_payload(data: &[u8], namespace: &str) -> Result, SigningError> { + ssh::construct_sshsig_signed_data(data, namespace) + .map_err(|e| SigningError::SigningFailed(e.to_string())) +} + +/// Create a complete SSHSIG PEM signature from a seed and data. +/// +/// Args: +/// * `seed`: The Ed25519 signing seed. +/// * `data`: The raw bytes to sign. +/// * `namespace`: The SSHSIG namespace. +/// +/// Usage: +/// ```ignore +/// let pem = sign_with_seed(&seed, b"data to sign", "git")?; +/// ``` +pub fn sign_with_seed( + seed: &SecureSeed, + data: &[u8], + namespace: &str, +) -> Result { + ssh::create_sshsig(seed, data, namespace).map_err(|e| SigningError::PemEncoding(e.to_string())) +} + +// --------------------------------------------------------------------------- +// Artifact attestation signing +// --------------------------------------------------------------------------- + +/// Selects how a signing key is supplied to `sign_artifact`. +/// +/// `Alias` resolves the key from the platform keychain at call time. +/// `Direct` injects a raw seed, bypassing the keychain — intended for headless +/// CI/CD runners that have no platform keychain available. +pub enum SigningKeyMaterial { + /// Resolve by alias from the platform keychain. + Alias(KeyAlias), + /// Inject a raw Ed25519 seed directly. The passphrase provider is not called. + Direct(SecureSeed), +} + +/// Parameters for the artifact attestation signing workflow. +/// +/// Usage: +/// ```ignore +/// let params = ArtifactSigningParams { +/// artifact: Arc::new(my_artifact), +/// identity_key: Some(SigningKeyMaterial::Alias("my-identity".into())), +/// device_key: SigningKeyMaterial::Direct(my_seed), +/// expires_in: Some(31_536_000), +/// note: None, +/// }; +/// ``` +pub struct ArtifactSigningParams { + /// The artifact to attest. Provides the canonical digest and metadata. + pub artifact: Arc, + /// Identity key source. `None` skips the identity signature. + pub identity_key: Option, + /// Device key source. Required to produce a dual-signed attestation. + pub device_key: SigningKeyMaterial, + /// Duration in seconds until expiration (per RFC 6749). + pub expires_in: Option, + /// Optional human-readable annotation embedded in the attestation. + pub note: Option, +} + +/// Result of a successful artifact attestation signing operation. +/// +/// Usage: +/// ```ignore +/// let result = sign_artifact(params, &ctx)?; +/// std::fs::write(&output_path, &result.attestation_json)?; +/// println!("Signed {} (sha256:{})", result.rid, result.digest); +/// ``` +#[derive(Debug)] +pub struct ArtifactSigningResult { + /// Canonical JSON of the signed attestation. + pub attestation_json: String, + /// Resource identifier assigned to the attestation in the identity store. + pub rid: ResourceId, + /// Hex-encoded SHA-256 digest of the attested artifact. + pub digest: String, +} + +/// Errors from the artifact attestation signing workflow. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ArtifactSigningError { + /// No auths identity was found in the configured identity storage. + #[error("identity not found in configured identity storage")] + IdentityNotFound, + + /// The key alias could not be resolved to usable key material. + #[error("key resolution failed: {0}")] + KeyResolutionFailed(String), + + /// The encrypted key material could not be decrypted (e.g. wrong passphrase). + #[error("key decryption failed: {0}")] + KeyDecryptionFailed(String), + + /// Computing the artifact digest failed. + #[error("digest computation failed: {0}")] + DigestFailed(String), + + /// Building or serializing the attestation failed. + #[error("attestation creation failed: {0}")] + AttestationFailed(String), + + /// Adding the device signature to a partially-signed attestation failed. + #[error("attestation re-signing failed: {0}")] + ResignFailed(String), +} + +impl auths_core::error::AuthsErrorInfo for ArtifactSigningError { + fn error_code(&self) -> &'static str { + match self { + Self::IdentityNotFound => "AUTHS-E5801", + Self::KeyResolutionFailed(_) => "AUTHS-E5802", + Self::KeyDecryptionFailed(_) => "AUTHS-E5803", + Self::DigestFailed(_) => "AUTHS-E5804", + Self::AttestationFailed(_) => "AUTHS-E5805", + Self::ResignFailed(_) => "AUTHS-E5806", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::IdentityNotFound => { + Some("Run `auths init` to create an identity, or `auths key import` to restore one") + } + Self::KeyResolutionFailed(_) => { + Some("Run `auths status` to see available device aliases") + } + Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"), + Self::DigestFailed(_) => Some("Verify the file exists and is readable"), + Self::AttestationFailed(_) => Some("Check identity storage with `auths status`"), + Self::ResignFailed(_) => { + Some("Verify your device key is accessible with `auths status`") + } + } + } +} + +/// A `SecureSigner` backed by pre-resolved in-memory seeds. +/// +/// Seeds are keyed by alias. The passphrase provider is never called because +/// all key material was resolved before construction. +struct SeedMapSigner { + seeds: HashMap, +} + +impl SecureSigner for SeedMapSigner { + fn sign_with_alias( + &self, + alias: &auths_core::storage::keychain::KeyAlias, + _passphrase_provider: &dyn PassphraseProvider, + message: &[u8], + ) -> Result, auths_core::AgentError> { + let seed = self + .seeds + .get(alias.as_str()) + .ok_or(auths_core::AgentError::KeyNotFound)?; + provider_bridge::sign_ed25519_sync(seed, message) + .map_err(|e| auths_core::AgentError::CryptoError(e.to_string())) + } + + fn sign_for_identity( + &self, + _identity_did: &IdentityDID, + _passphrase_provider: &dyn PassphraseProvider, + _message: &[u8], + ) -> Result, auths_core::AgentError> { + Err(auths_core::AgentError::KeyNotFound) + } +} + +struct ResolvedKey { + alias: KeyAlias, + seed: SecureSeed, + public_key_bytes: Vec, +} + +fn resolve_optional_key( + material: Option<&SigningKeyMaterial>, + synthetic_alias: &'static str, + keychain: &(dyn KeyStorage + Send + Sync), + passphrase_provider: &dyn PassphraseProvider, + passphrase_prompt: &str, +) -> Result, ArtifactSigningError> { + match material { + None => Ok(None), + Some(SigningKeyMaterial::Alias(alias)) => { + let (_, _role, encrypted) = keychain + .load_key(alias) + .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?; + let passphrase = passphrase_provider + .get_passphrase(passphrase_prompt) + .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?; + let pkcs8 = core_signer::decrypt_keypair(&encrypted, &passphrase) + .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?; + let (seed, pubkey) = core_signer::load_seed_and_pubkey(&pkcs8) + .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?; + Ok(Some(ResolvedKey { + alias: alias.clone(), + seed, + public_key_bytes: pubkey.to_vec(), + })) + } + Some(SigningKeyMaterial::Direct(seed)) => { + let pubkey = provider_bridge::ed25519_public_key_from_seed_sync(seed) + .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?; + Ok(Some(ResolvedKey { + alias: KeyAlias::new_unchecked(synthetic_alias), + seed: SecureSeed::new(*seed.as_bytes()), + public_key_bytes: pubkey.to_vec(), + })) + } + } +} + +fn resolve_required_key( + material: &SigningKeyMaterial, + synthetic_alias: &'static str, + keychain: &(dyn KeyStorage + Send + Sync), + passphrase_provider: &dyn PassphraseProvider, + passphrase_prompt: &str, +) -> Result { + resolve_optional_key( + Some(material), + synthetic_alias, + keychain, + passphrase_provider, + passphrase_prompt, + ) + .map(|opt| { + opt.ok_or(ArtifactSigningError::KeyDecryptionFailed( + "expected key material but got None".into(), + )) + })? +} + +/// Full artifact attestation signing pipeline. +/// +/// Loads the identity, resolves key material (supporting both keychain aliases +/// and direct in-memory seed injection), computes the artifact digest, and +/// produces a dual-signed attestation JSON. +/// +/// Args: +/// * `params`: All inputs required for signing, including key material and artifact source. +/// * `ctx`: Runtime context providing identity storage, key storage, passphrase provider, and clock. +/// +/// Usage: +/// ```ignore +/// let params = ArtifactSigningParams { +/// artifact: Arc::new(FileArtifact::new(Path::new("release.tar.gz"))), +/// identity_key: Some(SigningKeyMaterial::Alias("my-key".into())), +/// device_key: SigningKeyMaterial::Direct(seed), +/// expires_in: Some(31_536_000), +/// note: None, +/// }; +/// let result = sign_artifact(params, &ctx)?; +/// ``` +pub fn sign_artifact( + params: ArtifactSigningParams, + ctx: &AuthsContext, +) -> Result { + let managed = ctx + .identity_storage + .load_identity() + .map_err(|_| ArtifactSigningError::IdentityNotFound)?; + + let keychain = ctx.key_storage.as_ref(); + let passphrase_provider = ctx.passphrase_provider.as_ref(); + + let identity_resolved = resolve_optional_key( + params.identity_key.as_ref(), + "__artifact_identity__", + keychain, + passphrase_provider, + "Enter passphrase for identity key:", + )?; + + let device_resolved = resolve_required_key( + ¶ms.device_key, + "__artifact_device__", + keychain, + passphrase_provider, + "Enter passphrase for device key:", + )?; + + let mut seeds: HashMap = HashMap::new(); + let identity_alias: Option = identity_resolved.map(|r| { + let alias = r.alias.clone(); + seeds.insert(r.alias.into_inner(), r.seed); + alias + }); + let device_alias = device_resolved.alias.clone(); + seeds.insert(device_resolved.alias.into_inner(), device_resolved.seed); + let device_pk_bytes = device_resolved.public_key_bytes; + + let device_did = + DeviceDID::from_ed25519(device_pk_bytes.as_slice().try_into().map_err(|_| { + ArtifactSigningError::AttestationFailed("device public key must be 32 bytes".into()) + })?); + + let artifact_meta = params + .artifact + .metadata() + .map_err(|e| ArtifactSigningError::DigestFailed(e.to_string()))?; + + let rid = ResourceId::new(format!("sha256:{}", artifact_meta.digest.hex)); + let now = ctx.clock.now(); + let meta = AttestationMetadata { + timestamp: Some(now), + expires_at: params + .expires_in + .map(|s| now + chrono::Duration::seconds(s as i64)), + note: params.note, + }; + + let payload = serde_json::to_value(&artifact_meta) + .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; + + let signer = SeedMapSigner { seeds }; + // Seeds are already resolved — passphrase provider will not be called. + let noop_provider = auths_core::PrefilledPassphraseProvider::new(""); + + let mut attestation = create_signed_attestation( + now, + &rid, + &managed.controller_did, + &device_did, + &device_pk_bytes, + Some(payload), + &meta, + &signer, + &noop_provider, + identity_alias.as_ref(), + Some(&device_alias), + vec![Capability::sign_release()], + None, + None, + ) + .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; + + resign_attestation( + &mut attestation, + &signer, + &noop_provider, + identity_alias.as_ref(), + &device_alias, + ) + .map_err(|e| ArtifactSigningError::ResignFailed(e.to_string()))?; + + let attestation_json = serde_json::to_string_pretty(&attestation) + .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; + + Ok(ArtifactSigningResult { + attestation_json, + rid, + digest: artifact_meta.digest.hex, + }) +} diff --git a/crates/auths-sdk/src/domains/signing/types.rs b/crates/auths-sdk/src/domains/signing/types.rs new file mode 100644 index 00000000..ab6d3166 --- /dev/null +++ b/crates/auths-sdk/src/domains/signing/types.rs @@ -0,0 +1,67 @@ +//! Signing domain types for platform verification and Git configuration. + +use std::path::PathBuf; + +/// How to verify a platform identity. +/// +/// The CLI obtains tokens interactively (OAuth device flow, browser open). +/// The SDK accepts the resulting token — it never opens a browser. +/// +/// Usage: +/// ```ignore +/// let platform = PlatformVerification::GitHub { +/// access_token: "ghp_abc123".into(), +/// }; +/// ``` +#[derive(Debug, Clone)] +pub enum PlatformVerification { + /// Verify via GitHub using a personal access token. + GitHub { + /// The GitHub personal access token. + access_token: String, + }, + /// Verify via GitLab using a personal access token. + GitLab { + /// The GitLab personal access token. + access_token: String, + }, + /// Skip platform verification. + Skip, +} + +/// Whether and how to configure Git commit signing. +/// +/// Usage: +/// ```ignore +/// let scope = GitSigningScope::Global; +/// ``` +#[derive(Debug, Clone, Default)] +pub enum GitSigningScope { + /// Configure signing for a specific repository only. + Local { + /// Path to the repository to configure. + repo_path: PathBuf, + }, + /// Configure signing globally for all repositories (default). + #[default] + Global, + /// Do not configure git signing. + Skip, +} + +/// Outcome of a successful platform claim verification. +/// +/// Usage: +/// ```ignore +/// let claim: PlatformClaimResult = sdk.platform_claim(platform).await?; +/// println!("Verified as {} on {}", claim.username, claim.platform); +/// ``` +#[derive(Debug, Clone)] +pub struct PlatformClaimResult { + /// The platform name (e.g. `"github"`). + pub platform: String, + /// The verified username on the platform. + pub username: String, + /// Optional URL to the public proof artifact (e.g. a GitHub gist). + pub proof_url: Option, +} diff --git a/crates/auths-sdk/src/error.rs b/crates/auths-sdk/src/error.rs index bbb9ad7e..5dab377d 100644 --- a/crates/auths-sdk/src/error.rs +++ b/crates/auths-sdk/src/error.rs @@ -1,5 +1,3 @@ -use auths_core::error::AuthsErrorInfo; -use auths_verifier::types::DeviceDID; use thiserror::Error; /// Typed storage errors originating from the `auths-id` layer. @@ -33,729 +31,21 @@ pub enum SdkStorageError { Attestation(#[from] auths_verifier::error::AttestationError), } -/// Errors from identity setup operations (developer, CI, agent). -/// -/// Usage: -/// ```ignore -/// match sdk_result { -/// Err(SetupError::IdentityAlreadyExists { did }) => { /* reuse or abort */ } -/// Err(e) => return Err(e.into()), -/// Ok(result) => { /* success */ } -/// } -/// ``` -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum SetupError { - /// An identity already exists at the configured path. - #[error("identity already exists: {did}")] - IdentityAlreadyExists { - /// The DID of the existing identity. - did: String, - }, - - /// The platform keychain is unavailable or inaccessible. - #[error("keychain unavailable ({backend}): {reason}")] - KeychainUnavailable { - /// The keychain backend name (e.g. "macOS Keychain"). - backend: String, - /// The reason the keychain is unavailable. - reason: String, - }, - - /// A cryptographic operation failed. - #[error("crypto error: {0}")] - CryptoError(#[source] auths_core::AgentError), - - /// A storage operation failed. - #[error("storage error: {0}")] - StorageError(#[source] SdkStorageError), - - /// Setting a git configuration key failed. - #[error("git config error: {0}")] - GitConfigError(#[source] crate::ports::git_config::GitConfigError), - - /// Setup configuration parameters are invalid. - #[error("invalid setup config: {0}")] - InvalidSetupConfig(String), - - /// Remote registry registration failed. - #[error("registration failed: {0}")] - RegistrationFailed(#[source] RegistrationError), - - /// Platform identity verification failed. - #[error("platform verification failed: {0}")] - PlatformVerificationFailed(String), -} - -/// Errors from device linking and revocation operations. -/// -/// Usage: -/// ```ignore -/// match link_result { -/// Err(DeviceError::IdentityNotFound { did }) => { /* identity missing */ } -/// Err(e) => return Err(e.into()), -/// Ok(result) => { /* success */ } -/// } -/// ``` -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum DeviceError { - /// The identity could not be found in storage. - #[error("identity not found: {did}")] - IdentityNotFound { - /// The DID that was not found. - did: String, - }, - - /// The device could not be found in attestation records. - #[error("device not found: {did}")] - DeviceNotFound { - /// The DID of the missing device. - did: String, - }, - - /// Attestation creation or validation failed. - #[error("attestation error: {0}")] - AttestationError(#[source] auths_verifier::error::AttestationError), - - /// The device DID derived from the key does not match the expected DID. - #[error("device DID mismatch: expected {expected}, got {actual}")] - DeviceDidMismatch { - /// The expected device DID. - expected: String, - /// The actual device DID derived from the key. - actual: String, - }, - - /// A cryptographic operation failed. - #[error("crypto error: {0}")] - CryptoError(#[source] auths_core::AgentError), - - /// A storage operation failed. - #[error("storage error: {0}")] - StorageError(#[source] SdkStorageError), -} - -/// Errors from device authorization extension operations. -/// -/// Usage: -/// ```ignore -/// match extend_result { -/// Err(DeviceExtensionError::AlreadyRevoked { device_did }) => { /* already gone */ } -/// Err(e) => return Err(e.into()), -/// Ok(result) => { /* success */ } -/// } -/// ``` -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum DeviceExtensionError { - /// The identity could not be found in storage. - #[error("identity not found")] - IdentityNotFound, - - /// No attestation exists for the specified device. - #[error("no attestation found for device {device_did}")] - NoAttestationFound { - /// The DID of the device with no attestation. - device_did: DeviceDID, - }, - - /// The device has already been revoked. - #[error("device {device_did} is already revoked")] - AlreadyRevoked { - /// The DID of the revoked device. - device_did: DeviceDID, - }, - - /// Creating a new attestation failed. - #[error("attestation creation failed: {0}")] - AttestationFailed(#[source] auths_verifier::error::AttestationError), - - /// A storage operation failed. - #[error("storage error: {0}")] - StorageError(#[source] SdkStorageError), -} - -/// Errors from identity rotation operations. -/// -/// Usage: -/// ```ignore -/// match rotate_result { -/// Err(RotationError::KelHistoryFailed(msg)) => { /* no prior events */ } -/// Err(e) => return Err(e.into()), -/// Ok(result) => { /* success */ } -/// } -/// ``` -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum RotationError { - /// The identity was not found at the expected path. - #[error("identity not found at {path}")] - IdentityNotFound { - /// The filesystem path where the identity was expected. - path: std::path::PathBuf, - }, - - /// The requested key alias was not found in the keychain. - #[error("key not found: {0}")] - KeyNotFound(String), - - /// Decrypting the key material failed (e.g. wrong passphrase). - #[error("key decryption failed: {0}")] - KeyDecryptionFailed(String), - - /// Reading or validating the KEL history failed. - #[error("KEL history error: {0}")] - KelHistoryFailed(String), - - /// The rotation operation failed. - #[error("rotation failed: {0}")] - RotationFailed(String), - - /// KEL event was written but the new key could not be persisted to the keychain. - /// Recovery: re-run rotation with the same new key to replay the keychain write. - #[error( - "rotation event committed to KEL but keychain write failed — manual recovery required: {0}" - )] - PartialRotation(String), -} - -/// Errors from trust policy resolution during verification. -/// -/// Usage: -/// ```ignore -/// match resolve_issuer_key(did, policy) { -/// Err(TrustError::UnknownIdentity { did, policy }) => { -/// eprintln!("Unknown identity under {} policy; run `auths trust add {}`", policy, did) -/// } -/// Err(e) => return Err(e.into()), -/// Ok(key) => { /* use key for verification */ } -/// } -/// ``` -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum TrustError { - /// Identity is unknown and trust policy does not permit TOFU/resolution. - #[error("Unknown identity '{did}' and trust policy is '{policy}'")] - UnknownIdentity { - /// The unknown identity DID. - did: String, - /// The policy preventing resolution (e.g., "explicit"). - policy: String, - }, - - /// Identity exists but no public key could be resolved. - #[error("Failed to resolve public key for identity {did}")] - KeyResolutionFailed { - /// The DID whose key could not be resolved. - did: String, - }, - - /// The provided roots.json or trust store is invalid. - #[error("Invalid trust store: {0}")] - InvalidTrustStore(String), - - /// TOFU prompt was required but execution is non-interactive. - #[error("TOFU trust decision required but running in non-interactive mode")] - TofuRequiresInteraction, -} - -impl AuthsErrorInfo for TrustError { - fn error_code(&self) -> &'static str { - match self { - Self::UnknownIdentity { .. } => "AUTHS-E4001", - Self::KeyResolutionFailed { .. } => "AUTHS-E4002", - Self::InvalidTrustStore(_) => "AUTHS-E4003", - Self::TofuRequiresInteraction => "AUTHS-E4004", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::UnknownIdentity { .. } => { - Some("Run `auths trust add ` or add the identity to .auths/roots.json") - } - Self::KeyResolutionFailed { .. } => { - Some("Verify the identity exists and has a valid public key registered") - } - Self::InvalidTrustStore(_) => Some( - "Check the format of your trust store (roots.json or ~/.auths/known_identities.json)", - ), - Self::TofuRequiresInteraction => { - Some("Run interactively (on a TTY) or use `auths verify --trust explicit`") - } - } - } -} - -/// Errors from remote registry operations. -/// -/// Usage: -/// ```ignore -/// match register_result { -/// Err(RegistrationError::AlreadyRegistered) => { /* skip */ } -/// Err(RegistrationError::QuotaExceeded) => { /* retry later */ } -/// Err(e) => return Err(e.into()), -/// Ok(outcome) => { /* success */ } -/// } -/// ``` -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum RegistrationError { - /// The identity is already registered at the target registry. - #[error("identity already registered at this registry")] - AlreadyRegistered, +/// Re-export identity domain errors for backwards compatibility. +pub use crate::domains::identity::error::{RegistrationError, RotationError, SetupError}; - /// The registration rate limit has been exceeded. - #[error("registration quota exceeded — try again later")] - QuotaExceeded, +/// Re-export device domain errors for backwards compatibility. +pub use crate::domains::device::error::{DeviceError, DeviceExtensionError}; - /// A network error occurred during registration. - #[error("network error: {0}")] - NetworkError(#[source] auths_core::ports::network::NetworkError), +/// Re-export auth domain errors for backwards compatibility. +pub use crate::domains::auth::error::{McpAuthError, TrustError}; - /// The local DID format is invalid. - #[error("invalid DID format: {did}")] - InvalidDidFormat { - /// The DID that failed validation. - did: String, - }, +/// Re-export org domain errors for backwards compatibility. +pub use crate::domains::org::error::OrgError; - /// Loading the local identity failed. - #[error("identity load error: {0}")] - IdentityLoadError(#[source] auths_id::error::StorageError), - - /// Reading from the local registry failed. - #[error("registry read error: {0}")] - RegistryReadError(#[source] auths_id::storage::registry::backend::RegistryError), - - /// Serialization of identity data failed. - #[error("serialization error: {0}")] - SerializationError(#[source] serde_json::Error), -} - -impl From for SetupError { - fn from(err: auths_core::AgentError) -> Self { - SetupError::CryptoError(err) - } -} - -impl From for SetupError { - fn from(err: RegistrationError) -> Self { - SetupError::RegistrationFailed(err) - } -} - -impl From for DeviceError { - fn from(err: auths_core::AgentError) -> Self { - DeviceError::CryptoError(err) - } -} - -impl From for RegistrationError { - fn from(err: auths_core::ports::network::NetworkError) -> Self { - RegistrationError::NetworkError(err) - } -} - -impl AuthsErrorInfo for SetupError { - fn error_code(&self) -> &'static str { - match self { - Self::IdentityAlreadyExists { .. } => "AUTHS-E5001", - Self::KeychainUnavailable { .. } => "AUTHS-E5002", - Self::CryptoError(e) => e.error_code(), - Self::StorageError(_) => "AUTHS-E5003", - Self::GitConfigError(_) => "AUTHS-E5004", - Self::InvalidSetupConfig(_) => "AUTHS-E5007", - Self::RegistrationFailed(_) => "AUTHS-E5005", - Self::PlatformVerificationFailed(_) => "AUTHS-E5006", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::IdentityAlreadyExists { .. } => { - Some("Use `auths id show` to inspect the existing identity") - } - Self::KeychainUnavailable { .. } => { - Some("Run `auths doctor` to diagnose keychain issues") - } - Self::CryptoError(e) => e.suggestion(), - Self::StorageError(_) => Some("Check file permissions and disk space"), - Self::GitConfigError(_) => { - Some("Ensure Git is configured: git config --global user.name/email") - } - Self::InvalidSetupConfig(_) => Some("Check identity setup configuration parameters"), - Self::RegistrationFailed(_) => Some("Check network connectivity and try again"), - Self::PlatformVerificationFailed(_) => Some( - "Platform identity verification failed; check your platform credentials and network connectivity", - ), - } - } -} - -impl AuthsErrorInfo for DeviceError { - fn error_code(&self) -> &'static str { - match self { - Self::IdentityNotFound { .. } => "AUTHS-E5101", - Self::DeviceNotFound { .. } => "AUTHS-E5102", - Self::AttestationError(_) => "AUTHS-E5103", - Self::DeviceDidMismatch { .. } => "AUTHS-E5105", - Self::CryptoError(e) => e.error_code(), - Self::StorageError(_) => "AUTHS-E5104", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::IdentityNotFound { .. } => Some("Run `auths init` to create an identity first"), - Self::DeviceNotFound { .. } => Some("Run `auths device list` to see linked devices"), - Self::AttestationError(_) => Some( - "The attestation operation failed; run `auths device list` to check device status", - ), - Self::DeviceDidMismatch { .. } => Some("Check that --device-did matches the key alias"), - Self::CryptoError(e) => e.suggestion(), - Self::StorageError(_) => Some("Check file permissions and disk space"), - } - } -} - -impl AuthsErrorInfo for DeviceExtensionError { - fn error_code(&self) -> &'static str { - match self { - Self::IdentityNotFound => "AUTHS-E5201", - Self::NoAttestationFound { .. } => "AUTHS-E5202", - Self::AlreadyRevoked { .. } => "AUTHS-E5203", - Self::AttestationFailed(_) => "AUTHS-E5204", - Self::StorageError(_) => "AUTHS-E5205", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::IdentityNotFound => Some("Run `auths init` to create an identity first"), - Self::NoAttestationFound { .. } => { - Some("Run `auths device link` to create an attestation for this device") - } - Self::AlreadyRevoked { .. } => Some( - "This device has been revoked and cannot be extended; link a new device with `auths device link`", - ), - Self::AttestationFailed(_) => { - Some("Failed to create the extension attestation; check key access and try again") - } - Self::StorageError(_) => Some("Check file permissions and disk space"), - } - } -} - -impl AuthsErrorInfo for RotationError { - fn error_code(&self) -> &'static str { - match self { - Self::IdentityNotFound { .. } => "AUTHS-E5301", - Self::KeyNotFound(_) => "AUTHS-E5302", - Self::KeyDecryptionFailed(_) => "AUTHS-E5303", - Self::KelHistoryFailed(_) => "AUTHS-E5304", - Self::RotationFailed(_) => "AUTHS-E5305", - Self::PartialRotation(_) => "AUTHS-E5306", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::IdentityNotFound { .. } => Some("Run `auths init` to create an identity first"), - Self::KeyNotFound(_) => Some("Run `auths key list` to see available keys"), - Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"), - Self::KelHistoryFailed(_) => Some("Run `auths doctor` to check KEL integrity"), - Self::RotationFailed(_) => Some( - "Key rotation failed; verify your current key is accessible with `auths key list`", - ), - Self::PartialRotation(_) => { - Some("Re-run the rotation with the same new key to complete the keychain write") - } - } - } -} - -impl AuthsErrorInfo for RegistrationError { - fn error_code(&self) -> &'static str { - match self { - Self::AlreadyRegistered => "AUTHS-E5401", - Self::QuotaExceeded => "AUTHS-E5402", - Self::NetworkError(e) => e.error_code(), - Self::InvalidDidFormat { .. } => "AUTHS-E5403", - Self::IdentityLoadError(_) => "AUTHS-E5404", - Self::RegistryReadError(_) => "AUTHS-E5405", - Self::SerializationError(_) => "AUTHS-E5406", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::AlreadyRegistered => Some( - "This identity is already registered; use `auths id show` to see registration details", - ), - Self::QuotaExceeded => Some("Wait a few minutes and try again"), - Self::NetworkError(e) => e.suggestion(), - Self::InvalidDidFormat { .. } => { - Some("Run `auths doctor` to check local identity data") - } - Self::IdentityLoadError(_) => Some("Run `auths doctor` to check local identity data"), - Self::RegistryReadError(_) => Some("Run `auths doctor` to check local identity data"), - Self::SerializationError(_) => Some("Run `auths doctor` to check local identity data"), - } - } -} - -impl AuthsErrorInfo for McpAuthError { - fn error_code(&self) -> &'static str { - match self { - Self::BridgeUnreachable(_) => "AUTHS-E5501", - Self::TokenExchangeFailed { .. } => "AUTHS-E5502", - Self::InvalidResponse(_) => "AUTHS-E5503", - Self::InsufficientCapabilities { .. } => "AUTHS-E5504", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::BridgeUnreachable(_) => Some("Check network connectivity to the OIDC bridge"), - Self::TokenExchangeFailed { .. } => Some("Verify your credentials and try again"), - Self::InvalidResponse(_) => Some( - "The OIDC bridge returned an unexpected response; verify the bridge URL and try again", - ), - Self::InsufficientCapabilities { .. } => { - Some("Request fewer capabilities or contact your administrator") - } - } - } -} - -impl AuthsErrorInfo for OrgError { - fn error_code(&self) -> &'static str { - match self { - Self::AdminNotFound { .. } => "AUTHS-E5601", - Self::MemberNotFound { .. } => "AUTHS-E5602", - Self::AlreadyRevoked { .. } => "AUTHS-E5603", - Self::InvalidCapability { .. } => "AUTHS-E5604", - Self::InvalidDid(_) => "AUTHS-E5605", - Self::InvalidPublicKey(_) => "AUTHS-E5606", - Self::Signing(_) => "AUTHS-E5607", - Self::Identity(_) => "AUTHS-E5608", - Self::KeyStorage(_) => "AUTHS-E5609", - Self::Storage(_) => "AUTHS-E5610", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::AdminNotFound { .. } => { - Some("Verify you are using the correct admin key for this organization") - } - Self::MemberNotFound { .. } => { - Some("Run `auths org list-members` to see current members") - } - Self::AlreadyRevoked { .. } => { - Some("This member has already been revoked from the organization") - } - Self::InvalidCapability { .. } => { - Some("Use a valid capability (e.g., 'sign_commit', 'manage_members', 'admin')") - } - Self::InvalidDid(_) => Some("Organization DIDs must be valid did:keri identifiers"), - Self::InvalidPublicKey(_) => Some("Public keys must be hex-encoded Ed25519 keys"), - Self::Signing(_) => { - Some("The signing operation failed; check your key access with `auths key list`") - } - Self::Identity(_) => { - Some("Failed to load identity; run `auths id show` to check identity status") - } - Self::KeyStorage(_) => { - Some("Failed to access key storage; run `auths doctor` to diagnose") - } - Self::Storage(_) => { - Some("Failed to access organization storage; check repository permissions") - } - } - } -} - -impl AuthsErrorInfo for ApprovalError { - fn error_code(&self) -> &'static str { - match self { - Self::NotApprovalRequired => "AUTHS-E5701", - Self::RequestNotFound { .. } => "AUTHS-E5702", - Self::RequestExpired { .. } => "AUTHS-E5703", - Self::ApprovalAlreadyUsed { .. } => "AUTHS-E5704", - Self::PartialApproval(_) => "AUTHS-E5705", - Self::ApprovalStorage(_) => "AUTHS-E5706", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::NotApprovalRequired => Some( - "This operation does not require approval; run it directly without the --approve flag", - ), - Self::RequestNotFound { .. } => { - Some("Run `auths approval list` to see pending requests") - } - Self::RequestExpired { .. } => Some("Submit a new approval request"), - Self::ApprovalAlreadyUsed { .. } => Some("Submit a new approval request"), - Self::PartialApproval(_) => Some("Check approval status and retry if needed"), - Self::ApprovalStorage(_) => Some("Check file permissions and disk space"), - } - } -} - -/// Errors from MCP token exchange operations. -/// -/// Usage: -/// ```ignore -/// match result { -/// Err(McpAuthError::BridgeUnreachable(msg)) => { /* retry later */ } -/// Err(McpAuthError::InsufficientCapabilities { .. }) => { /* request fewer caps */ } -/// Err(e) => return Err(e.into()), -/// Ok(token) => { /* use token */ } -/// } -/// ``` -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum McpAuthError { - /// The OIDC bridge is unreachable. - #[error("bridge unreachable: {0}")] - BridgeUnreachable(String), - - /// The bridge returned a non-success status. - #[error("token exchange failed (HTTP {status}): {body}")] - TokenExchangeFailed { - /// HTTP status code from the bridge. - status: u16, - /// Response body. - body: String, - }, - - /// The bridge response could not be parsed. - #[error("invalid response: {0}")] - InvalidResponse(String), - - /// The bridge rejected the requested capabilities. - #[error("insufficient capabilities: requested {requested:?}")] - InsufficientCapabilities { - /// The capabilities that were requested. - requested: Vec, - /// Detail from the bridge error response. - detail: String, - }, -} - -/// Errors from organization member management workflows. -/// -/// Usage: -/// ```ignore -/// match result { -/// Err(OrgError::AdminNotFound { .. }) => { /* 403 Forbidden */ } -/// Err(OrgError::MemberNotFound { .. }) => { /* 404 Not Found */ } -/// Err(e) => return Err(e.into()), -/// Ok(att) => { /* proceed */ } -/// } -/// ``` -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum OrgError { - /// No admin matching the given public key was found in the organization. - #[error("no admin with the given public key found in organization '{org}'")] - AdminNotFound { - /// The organization identifier. - org: String, - }, - - /// The specified member was not found in the organization. - #[error("member '{did}' not found in organization '{org}'")] - MemberNotFound { - /// The organization identifier. - org: String, - /// The DID of the member that was not found. - did: String, - }, - - /// The member has already been revoked. - #[error("member '{did}' is already revoked")] - AlreadyRevoked { - /// The DID of the already-revoked member. - did: String, - }, - - /// The capability string could not be parsed. - #[error("invalid capability '{cap}': {reason}")] - InvalidCapability { - /// The invalid capability string. - cap: String, - /// The reason parsing failed. - reason: String, - }, - - /// The organization DID is malformed. - #[error("invalid organization DID: {0}")] - InvalidDid(String), - - /// The hex-encoded public key is invalid. - #[error("invalid public key: {0}")] - InvalidPublicKey(String), - - /// A signing operation failed while creating or revoking an attestation. - #[error("signing error: {0}")] - Signing(String), - - /// The identity could not be loaded from storage. - #[error("identity error: {0}")] - Identity(String), - - /// A key storage operation failed. - #[error("key storage error: {0}")] - KeyStorage(String), - - /// A storage operation failed. - #[error("storage error: {0}")] - Storage(#[source] auths_id::storage::registry::backend::RegistryError), -} +/// Re-export compliance domain errors for backwards compatibility. +pub use crate::domains::compliance::error::ApprovalError; /// Re-export from `auths-core` — defined there to avoid a circular dependency with /// `auths-infra-http` (which implements the platform port traits). pub use auths_core::ports::platform::PlatformError; - -/// Errors from approval workflow operations. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum ApprovalError { - /// The decision is not RequiresApproval. - #[error("decision is not RequiresApproval")] - NotApprovalRequired, - - /// Approval request not found. - #[error("approval request not found: {hash}")] - RequestNotFound { - /// The hex-encoded request hash. - hash: String, - }, - - /// Approval request expired. - #[error("approval request expired at {expires_at}")] - RequestExpired { - /// When the request expired. - expires_at: chrono::DateTime, - }, - - /// Approval JTI already used (replay attempt). - #[error("approval already used (JTI: {jti})")] - ApprovalAlreadyUsed { - /// The consumed JTI. - jti: String, - }, - - /// Approval partially applied — attestation stored but nonce/cleanup failed. - #[error("approval partially applied — attestation stored but nonce/cleanup failed: {0}")] - PartialApproval(String), - - /// A storage operation failed. - #[error("storage error: {0}")] - ApprovalStorage(#[source] SdkStorageError), -} diff --git a/crates/auths-sdk/src/lib.rs b/crates/auths-sdk/src/lib.rs index c32cbcf4..bcbc9d89 100644 --- a/crates/auths-sdk/src/lib.rs +++ b/crates/auths-sdk/src/lib.rs @@ -61,3 +61,14 @@ pub mod testing; pub use context::AuthsContext; pub use context::EventSink; + +// Re-export types and errors from domains for ease of access +pub use domains::auth::error::*; +pub use domains::compliance::error::*; +pub use domains::device::error::*; +pub use domains::device::types::*; +pub use domains::diagnostics::types::*; +pub use domains::identity::error::*; +pub use domains::identity::types::*; +pub use domains::org::error::*; +pub use domains::signing::types::*; diff --git a/crates/auths-sdk/src/platform.rs b/crates/auths-sdk/src/platform.rs index d6860c85..1e035bc9 100644 --- a/crates/auths-sdk/src/platform.rs +++ b/crates/auths-sdk/src/platform.rs @@ -1,84 +1,3 @@ -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use serde::{Deserialize, Serialize}; +//! Re-exports from the signing domain for backwards compatibility. -use auths_core::ports::clock::ClockProvider; -use auths_core::signing::{PassphraseProvider, SecureSigner}; -use auths_core::storage::keychain::KeyAlias; - -use crate::error::SetupError; - -/// A signed platform claim linking a DID to a platform username. -/// -/// Usage: -/// ```ignore -/// let claim: PlatformClaim = serde_json::from_str(&claim_json)?; -/// assert_eq!(claim.platform, "github"); -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PlatformClaim { - /// The claim type identifier (always `"platform_claim"`). - #[serde(rename = "type")] - pub claim_type: String, - /// The platform name (e.g. `"github"`, `"gitlab"`). - pub platform: String, - /// The username on the platform. - pub namespace: String, - /// The controller DID (e.g. `"did:keri:E..."`). - pub did: String, - /// ISO-8601 timestamp of when the claim was created. - pub timestamp: String, - /// Base64url-encoded Ed25519 signature over the canonicalized claim. - #[serde(skip_serializing_if = "Option::is_none")] - pub signature: Option, -} - -/// Creates a signed platform claim linking a DID to a platform username. -/// -/// The claim is JSON-canonicalized (RFC 8785) before signing, ensuring -/// deterministic verification without the original OAuth token. -/// -/// Args: -/// * `platform`: Platform name (e.g., "github"). -/// * `namespace`: Username on the platform. -/// * `did`: The controller DID (e.g., "did:keri:E..."). -/// * `key_alias`: Keychain alias for the signing key. -/// * `signer`: Secure signer for creating the claim signature. -/// * `passphrase_provider`: Provider for key decryption passphrase. -/// -/// Usage: -/// ```ignore -/// let claim_json = create_platform_claim("github", "octocat", "did:keri:E...", "main", &signer, &provider)?; -/// ``` -pub fn create_platform_claim( - platform: &str, - namespace: &str, - did: &str, - key_alias: &KeyAlias, - signer: &dyn SecureSigner, - passphrase_provider: &dyn PassphraseProvider, - clock: &dyn ClockProvider, -) -> Result { - let mut claim = PlatformClaim { - claim_type: "platform_claim".to_string(), - platform: platform.to_string(), - namespace: namespace.to_string(), - did: did.to_string(), - timestamp: clock.now().to_rfc3339(), - signature: None, - }; - - let unsigned_json = serde_json::to_value(&claim) - .map_err(|e| SetupError::PlatformVerificationFailed(format!("serialize claim: {e}")))?; - let canonical = json_canon::to_string(&unsigned_json) - .map_err(|e| SetupError::PlatformVerificationFailed(format!("canonicalize claim: {e}")))?; - - let signature_bytes = signer - .sign_with_alias(key_alias, passphrase_provider, canonical.as_bytes()) - .map_err(|e| SetupError::PlatformVerificationFailed(format!("sign claim: {e}")))?; - - claim.signature = Some(URL_SAFE_NO_PAD.encode(&signature_bytes)); - - serde_json::to_string_pretty(&claim) - .map_err(|e| SetupError::PlatformVerificationFailed(format!("serialize signed claim: {e}"))) -} +pub use crate::domains::signing::platform::{PlatformClaim, create_platform_claim}; diff --git a/crates/auths-sdk/src/registration.rs b/crates/auths-sdk/src/registration.rs index c9b7628b..8293a414 100644 --- a/crates/auths-sdk/src/registration.rs +++ b/crates/auths-sdk/src/registration.rs @@ -1,119 +1,4 @@ -use std::sync::Arc; +//! Re-exports from the identity domain for backwards compatibility. -use serde::{Deserialize, Serialize}; - -use auths_core::ports::network::{NetworkError, RegistryClient}; -use auths_id::ports::registry::RegistryBackend; -use auths_id::storage::attestation::AttestationSource; -use auths_id::storage::identity::IdentityStorage; -use auths_verifier::IdentityDID; -use auths_verifier::keri::Prefix; - -use crate::error::RegistrationError; -use crate::result::RegistrationOutcome; - -/// Default registry URL used when no explicit registry endpoint is configured. -pub const DEFAULT_REGISTRY_URL: &str = "https://auths-registry.fly.dev"; - -#[derive(Serialize)] -struct RegistryOnboardingPayload { - inception_event: serde_json::Value, - attestations: Vec, - proof_url: Option, -} - -#[derive(Deserialize)] -struct RegistrationResponse { - did: IdentityDID, - platform_claims_indexed: usize, -} - -/// Registers a local identity with a remote registry for public discovery. -/// -/// Args: -/// * `identity_storage`: Storage adapter for loading the local identity. -/// * `registry`: Registry backend for reading KEL events. -/// * `attestation_source`: Source for loading local attestations. -/// * `registry_url`: Base URL of the target registry. -/// * `proof_url`: Optional URL to a platform proof (e.g., GitHub gist). -/// * `registry_client`: Network client for communicating with the registry. -/// -/// Usage: -/// ```ignore -/// let outcome = register_identity( -/// identity_storage, registry, attestation_source, -/// "https://auths-registry.fly.dev", None, &http_client, -/// ).await?; -/// ``` -pub async fn register_identity( - identity_storage: Arc, - registry: Arc, - attestation_source: Arc, - registry_url: &str, - proof_url: Option, - registry_client: &impl RegistryClient, -) -> Result { - let identity = identity_storage - .load_identity() - .map_err(RegistrationError::IdentityLoadError)?; - - let prefix = Prefix::from_did(&identity.controller_did).map_err(|_| { - RegistrationError::InvalidDidFormat { - did: identity.controller_did.to_string(), - } - })?; - let inception = registry - .get_event(&prefix, 0) - .map_err(RegistrationError::RegistryReadError)?; - let inception_event = - serde_json::to_value(&inception).map_err(RegistrationError::SerializationError)?; - - let attestations = attestation_source - .load_all_attestations() - .unwrap_or_default(); - let attestation_values: Vec = attestations - .iter() - .filter_map(|a| serde_json::to_value(a).ok()) - .collect(); - - let payload = RegistryOnboardingPayload { - inception_event, - attestations: attestation_values, - proof_url, - }; - - let json_body = serde_json::to_vec(&payload).map_err(RegistrationError::SerializationError)?; - - let registry_url = registry_url.trim_end_matches('/'); - let response = registry_client - .post_json(registry_url, "v1/identities", &json_body) - .await - .map_err(RegistrationError::NetworkError)?; - - match response.status { - 201 => { - let body: RegistrationResponse = - serde_json::from_slice(&response.body).map_err(|e| { - RegistrationError::NetworkError(NetworkError::InvalidResponse { - detail: e.to_string(), - }) - })?; - - Ok(RegistrationOutcome { - did: body.did, - registry: registry_url.to_string(), - platform_claims_indexed: body.platform_claims_indexed, - }) - } - 409 => Err(RegistrationError::AlreadyRegistered), - 429 => Err(RegistrationError::QuotaExceeded), - _ => { - let body = String::from_utf8_lossy(&response.body); - Err(RegistrationError::NetworkError( - NetworkError::InvalidResponse { - detail: format!("Registry error ({}): {}", response.status, body), - }, - )) - } - } -} +pub use crate::domains::identity::registration::{DEFAULT_REGISTRY_URL, register_identity}; +pub use crate::domains::identity::types::RegistrationOutcome; diff --git a/crates/auths-sdk/src/result.rs b/crates/auths-sdk/src/result.rs index 7cbcf177..f4280b7e 100644 --- a/crates/auths-sdk/src/result.rs +++ b/crates/auths-sdk/src/result.rs @@ -1,264 +1,18 @@ -use auths_core::storage::keychain::{IdentityDID, KeyAlias}; -use auths_verifier::Capability; -use auths_verifier::core::ResourceId; -use auths_verifier::types::DeviceDID; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +//! Re-exports of domain result types for backwards compatibility. -/// Outcome of a successful developer identity setup. -/// -/// Usage: -/// ```ignore -/// let result = initialize(IdentityConfig::developer(alias), &ctx, keychain, &signer, &provider, git_cfg)?; -/// if let InitializeResult::Developer(r) = result { -/// println!("Created identity: {}", r.identity_did); -/// } -/// ``` -#[derive(Debug, Clone)] -pub struct DeveloperIdentityResult { - /// The controller DID of the created identity. - pub identity_did: IdentityDID, - /// The device DID bound to this identity. - pub device_did: DeviceDID, - /// The keychain alias used for the signing key. - pub key_alias: KeyAlias, - /// Result of platform verification, if performed. - pub platform_claim: Option, - /// Whether git commit signing was configured. - pub git_signing_configured: bool, - /// Result of registry registration, if performed. - pub registered: Option, -} +// Re-export identity result types +pub use crate::domains::identity::types::{ + AgentIdentityResult, CiIdentityResult, DeveloperIdentityResult, IdentityRotationResult, + InitializeResult, RegistrationOutcome, +}; -/// Outcome of a successful CI/ephemeral identity setup. -/// -/// Usage: -/// ```ignore -/// let result = initialize(IdentityConfig::ci(registry_path), &ctx, keychain, &signer, &provider, None)?; -/// if let InitializeResult::Ci(r) = result { -/// for line in &r.env_block { println!("{line}"); } -/// } -/// ``` -#[derive(Debug, Clone)] -pub struct CiIdentityResult { - /// The controller DID of the CI identity. - pub identity_did: IdentityDID, - /// The device DID bound to this CI identity. - pub device_did: DeviceDID, - /// Shell `export` lines for configuring CI environment variables. - pub env_block: Vec, -} +// Re-export signing result types +pub use crate::domains::signing::types::PlatformClaimResult; -/// Outcome of a successful agent identity setup. -/// -/// Usage: -/// ```ignore -/// let result = initialize(IdentityConfig::agent(alias, path), &ctx, keychain, &signer, &provider, None)?; -/// if let InitializeResult::Agent(r) = result { -/// println!("Agent {:?} delegated by {:?}", r.agent_did, r.parent_did); -/// } -/// ``` -#[derive(Debug, Clone)] -pub struct AgentIdentityResult { - /// The DID of the newly created agent identity (None for dry-run proposals). - pub agent_did: Option, - /// The DID of the parent identity that delegated authority (None if no parent). - pub parent_did: Option, - /// The capabilities granted to the agent. - pub capabilities: Vec, -} +// Re-export device result types +pub use crate::domains::device::types::{ + DeviceExtensionResult, DeviceLinkResult, DeviceReadiness, DeviceStatus, +}; -/// Outcome of [`crate::setup::initialize`] — one variant per identity persona. -/// -/// Usage: -/// ```ignore -/// match initialize(config, &ctx, keychain, &signer, &provider, git_cfg)? { -/// InitializeResult::Developer(r) => display_developer_result(r), -/// InitializeResult::Ci(r) => display_ci_result(r), -/// InitializeResult::Agent(r) => display_agent_result(r), -/// } -/// ``` -#[derive(Debug, Clone)] -pub enum InitializeResult { - /// Developer identity result. - Developer(DeveloperIdentityResult), - /// CI/ephemeral identity result. - Ci(CiIdentityResult), - /// Agent identity result. - Agent(AgentIdentityResult), -} - -/// Outcome of a successful device link operation. -/// -/// Usage: -/// ```ignore -/// let result: DeviceLinkResult = sdk.link_device(config).await?; -/// println!("Linked device {} via attestation {}", result.device_did, result.attestation_id); -/// ``` -#[derive(Debug, Clone)] -pub struct DeviceLinkResult { - /// The DID of the linked device. - pub device_did: DeviceDID, - /// The resource identifier of the created attestation. - pub attestation_id: ResourceId, -} - -/// Outcome of a successful identity rotation. -/// -/// Usage: -/// ```ignore -/// let result: IdentityRotationResult = rotate_identity(config, provider)?; -/// println!("Rotated DID: {}", result.controller_did); -/// println!("New key: {}...", result.new_key_fingerprint); -/// println!("Old key: {}...", result.previous_key_fingerprint); -/// ``` -#[derive(Debug, Clone)] -pub struct IdentityRotationResult { - /// The controller DID of the rotated identity. - pub controller_did: IdentityDID, - /// Hex-encoded fingerprint of the new signing key. - pub new_key_fingerprint: String, - /// Hex-encoded fingerprint of the previous signing key. - pub previous_key_fingerprint: String, - /// KERI sequence number after this rotation event. - pub sequence: u64, -} - -/// Outcome of a successful device authorization extension. -/// -/// Usage: -/// ```ignore -/// let result: DeviceExtensionResult = extend_device(config, &ctx, &SystemClock)?; -/// println!("Extended {} until {}", result.device_did, result.new_expires_at.date_naive()); -/// ``` -#[derive(Debug, Clone)] -pub struct DeviceExtensionResult { - /// The DID of the device whose authorization was extended. - pub device_did: DeviceDID, - /// The new expiration timestamp for the device authorization. - pub new_expires_at: chrono::DateTime, - /// The previous expiration timestamp (None if the device had no expiry set). - pub previous_expires_at: Option>, -} - -/// Outcome of a successful platform claim verification. -/// -/// Usage: -/// ```ignore -/// let claim: PlatformClaimResult = sdk.platform_claim(platform).await?; -/// println!("Verified as {} on {}", claim.username, claim.platform); -/// ``` -#[derive(Debug, Clone)] -pub struct PlatformClaimResult { - /// The platform name (e.g. `"github"`). - pub platform: String, - /// The verified username on the platform. - pub username: String, - /// Optional URL to the public proof artifact (e.g. a GitHub gist). - pub proof_url: Option, -} - -/// Outcome of a successful registry registration. -/// -/// Usage: -/// ```ignore -/// if let Some(reg) = result.registered { -/// println!("Registered {} at {}", reg.did, reg.registry); -/// } -/// ``` -#[derive(Debug, Clone)] -pub struct RegistrationOutcome { - /// The DID returned by the registry (e.g. `did:keri:EABC...`). - pub did: IdentityDID, - /// The registry URL where the identity was registered. - pub registry: String, - /// Number of platform claims indexed by the registry. - pub platform_claims_indexed: usize, -} - -/// Device readiness status for diagnostics. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum DeviceReadiness { - /// Device is valid and not expiring soon. - Ok, - /// Device is expiring within 7 days. - ExpiringSoon, - /// Device authorization has expired. - Expired, - /// Device has been revoked. - Revoked, -} - -/// Per-device status for reporting. -/// -/// Usage: -/// ```ignore -/// for device in report.devices { -/// println!("{}: {}", device.device_did, device.readiness); -/// } -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeviceStatus { - /// The device DID. - pub device_did: DeviceDID, - /// Current device readiness status. - pub readiness: DeviceReadiness, - /// Expiration timestamp, if set. - pub expires_at: Option>, - /// Seconds until expiration (RFC 6749 format). - pub expires_in: Option, - /// Revocation timestamp, if revoked. - pub revoked_at: Option>, -} - -/// Identity status for status report. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IdentityStatus { - /// The controller DID. - pub controller_did: IdentityDID, - /// Key aliases available in keychain. - pub key_aliases: Vec, -} - -/// Agent status for status report. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentStatus { - /// Whether the agent is currently running. - pub running: bool, - /// Process ID if running. - pub pid: Option, - /// Socket path if running. - pub socket_path: Option, -} - -/// Next step recommendation for users. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NextStep { - /// Summary of what to do. - pub summary: String, - /// Command to run. - pub command: String, -} - -/// Full status report combining identity, devices, and agent state. -/// -/// Usage: -/// ```ignore -/// let report = StatusWorkflow::query(&ctx, now)?; -/// println!("Identity: {}", report.identity.controller_did); -/// println!("Devices: {} linked", report.devices.len()); -/// for step in report.next_steps { -/// println!("Try: {}", step.command); -/// } -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StatusReport { - /// Current identity status, if initialized. - pub identity: Option, - /// Per-device authorization status. - pub devices: Vec, - /// Agent/SSH-agent status. - pub agent: AgentStatus, - /// Suggested next steps for the user. - pub next_steps: Vec, -} +// Re-export diagnostics result types +pub use crate::domains::diagnostics::types::{AgentStatus, IdentityStatus, NextStep, StatusReport}; diff --git a/crates/auths-sdk/src/setup.rs b/crates/auths-sdk/src/setup.rs index eb3e8e8d..c4ef558d 100644 --- a/crates/auths-sdk/src/setup.rs +++ b/crates/auths-sdk/src/setup.rs @@ -1,482 +1,3 @@ -use std::convert::TryInto; -use std::path::Path; -use std::sync::Arc; +//! Re-exports from the identity domain for backwards compatibility. -use auths_core::signing::{PassphraseProvider, SecureSigner}; -use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage}; -use auths_id::attestation::create::create_signed_attestation; -use auths_id::identity::initialize::initialize_registry_identity; -use auths_id::storage::git_refs::AttestationMetadata; -use auths_id::storage::registry::install_linearity_hook; -use auths_verifier::types::DeviceDID; -use chrono::{DateTime, Utc}; - -use crate::context::AuthsContext; -use crate::error::SetupError; -use crate::ports::git_config::GitConfigProvider; -use crate::result::{ - AgentIdentityResult, CiIdentityResult, DeveloperIdentityResult, InitializeResult, - PlatformClaimResult, RegistrationOutcome, -}; -use crate::types::{ - CiEnvironment, CiIdentityConfig, CreateAgentIdentityConfig, CreateDeveloperIdentityConfig, - GitSigningScope, IdentityConfig, IdentityConflictPolicy, PlatformVerification, -}; - -/// Provisions a new identity for the requested persona. -/// -/// Dispatches to the appropriate setup path based on the `config` variant. -/// No deprecated shims — callers migrate directly to this function. -/// -/// Args: -/// * `config`: Identity persona and all setup parameters. -/// * `ctx`: Injected infrastructure adapters (registry, identity storage, attestation sink, clock). -/// * `keychain`: Platform keychain for key storage and retrieval. -/// * `signer`: Secure signer for creating attestation signatures. -/// * `passphrase_provider`: Provides passphrases for key encryption/decryption. -/// * `git_config`: Git configuration provider; required when git signing is configured. -/// -/// Usage: -/// ```ignore -/// let keychain: Arc = Arc::new(platform_keychain); -/// let result = initialize(IdentityConfig::developer(alias), &ctx, keychain, &signer, &provider, git_cfg)?; -/// match result { -/// InitializeResult::Developer(r) => println!("Identity: {}", r.identity_did), -/// InitializeResult::Ci(r) => println!("CI env block: {} lines", r.env_block.len()), -/// InitializeResult::Agent(r) => println!("Agent: {}", r.agent_did), -/// } -/// ``` -pub fn initialize( - config: IdentityConfig, - ctx: &AuthsContext, - keychain: Arc, - signer: &dyn SecureSigner, - passphrase_provider: &dyn PassphraseProvider, - git_config: Option<&dyn GitConfigProvider>, -) -> Result { - match config { - IdentityConfig::Developer(dev_config) => initialize_developer( - dev_config, - ctx, - keychain.as_ref(), - signer, - passphrase_provider, - git_config, - ) - .map(InitializeResult::Developer), - IdentityConfig::Ci(ci_config) => initialize_ci( - ci_config, - ctx, - keychain.as_ref(), - signer, - passphrase_provider, - ) - .map(InitializeResult::Ci), - IdentityConfig::Agent(agent_config) => { - initialize_agent(agent_config, ctx, Box::new(keychain), passphrase_provider) - .map(InitializeResult::Agent) - } - } -} - -fn initialize_developer( - config: CreateDeveloperIdentityConfig, - ctx: &AuthsContext, - keychain: &(dyn KeyStorage + Send + Sync), - signer: &dyn SecureSigner, - passphrase_provider: &dyn PassphraseProvider, - git_config: Option<&dyn GitConfigProvider>, -) -> Result { - let now = ctx.clock.now(); - let (controller_did, key_alias, reused) = - resolve_or_create_identity(&config, ctx, keychain, passphrase_provider, now)?; - let device_did = if reused { - derive_device_did(&key_alias, keychain, passphrase_provider)? - } else { - bind_device(&key_alias, ctx, keychain, signer, passphrase_provider, now)? - }; - let platform_claim = bind_platform_claim(&config.platform); - let git_configured = configure_git_signing( - &config.git_signing_scope, - &key_alias, - git_config, - config.sign_binary_path.as_deref(), - )?; - let registered = submit_registration(&config); - - Ok(DeveloperIdentityResult { - #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did originates from initialize_registry_identity() which returns a validated IdentityDID; into_inner() only unwraps it - identity_did: IdentityDID::new_unchecked(controller_did), - device_did, - key_alias, - platform_claim, - git_signing_configured: git_configured, - registered, - }) -} - -fn initialize_ci( - config: CiIdentityConfig, - ctx: &AuthsContext, - keychain: &(dyn KeyStorage + Send + Sync), - signer: &dyn SecureSigner, - passphrase_provider: &dyn PassphraseProvider, -) -> Result { - let now = ctx.clock.now(); - let (controller_did, key_alias) = initialize_ci_keys(ctx, keychain, passphrase_provider, now)?; - let device_did = bind_device(&key_alias, ctx, keychain, signer, passphrase_provider, now)?; - let env_block = - generate_ci_env_block(&key_alias, &config.registry_path, &config.ci_environment); - - Ok(CiIdentityResult { - #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did originates from initialize_registry_identity() which returns a validated IdentityDID; into_inner() only unwraps it - identity_did: IdentityDID::new_unchecked(controller_did), - device_did, - env_block, - }) -} - -fn initialize_agent( - config: CreateAgentIdentityConfig, - ctx: &AuthsContext, - keychain: Box, - passphrase_provider: &dyn PassphraseProvider, -) -> Result { - use auths_id::agent_identity::{AgentProvisioningConfig, AgentStorageMode}; - - let cap_strings: Vec = config.capabilities.iter().map(|c| c.to_string()).collect(); - let provisioning_config = AgentProvisioningConfig { - agent_name: config.alias.to_string(), - capabilities: cap_strings, - expires_in: config.expires_in, - delegated_by: config.parent_identity_did.clone().map(|did| { - #[allow(clippy::disallowed_methods)] - // INVARIANT: parent_identity_did is supplied by the CLI after resolving from identity storage, which stores only validated did:keri: DIDs - IdentityDID::new_unchecked(did) - }), - storage_mode: AgentStorageMode::Persistent { - repo_path: Some(config.registry_path.clone()), - }, - }; - - let proposed = build_agent_identity_proposal(&provisioning_config, &config)?; - - if !config.dry_run { - let bundle = auths_id::agent_identity::provision_agent_identity( - ctx.clock.now(), - std::sync::Arc::clone(&ctx.registry), - provisioning_config, - passphrase_provider, - keychain, - ) - .map_err(|e| SetupError::StorageError(e.into()))?; - - return Ok(AgentIdentityResult { - agent_did: Some(bundle.agent_did), - parent_did: config - .parent_identity_did - .and_then(|s| IdentityDID::parse(&s).ok()), - capabilities: config.capabilities, - }); - } - - Ok(proposed) -} - -/// Install the linearity hook in a registry directory. -/// -/// This is called by the CLI after initializing the git repository to prevent -/// non-linear KEL history. -/// -/// Args: -/// * `registry_path`: Path to the initialized git repository. -/// -/// Usage: -/// ```ignore -/// auths_sdk::setup::install_registry_hook(®istry_path); -/// ``` -pub fn install_registry_hook(registry_path: &Path) { - let _ = install_linearity_hook(registry_path); -} - -// ── Private helpers ────────────────────────────────────────────────────── - -/// Returns (controller_did, key_alias, reused). -fn resolve_or_create_identity( - config: &CreateDeveloperIdentityConfig, - ctx: &AuthsContext, - keychain: &(dyn KeyStorage + Send + Sync), - passphrase_provider: &dyn PassphraseProvider, - now: DateTime, -) -> Result<(String, KeyAlias, bool), SetupError> { - if let Ok(existing) = ctx.identity_storage.load_identity() { - match config.conflict_policy { - IdentityConflictPolicy::Error => { - return Err(SetupError::IdentityAlreadyExists { - did: existing.controller_did.into_inner(), - }); - } - IdentityConflictPolicy::ReuseExisting => { - return Ok(( - existing.controller_did.into_inner(), - config.key_alias.clone(), - true, - )); - } - IdentityConflictPolicy::ForceNew => {} - } - } - - let (did, alias) = derive_keys(config, ctx, keychain, passphrase_provider, now)?; - Ok((did, alias, false)) -} - -fn derive_keys( - config: &CreateDeveloperIdentityConfig, - ctx: &AuthsContext, - keychain: &(dyn KeyStorage + Send + Sync), - passphrase_provider: &dyn PassphraseProvider, - _now: DateTime, -) -> Result<(String, KeyAlias), SetupError> { - let (controller_did, _key_event) = initialize_registry_identity( - std::sync::Arc::clone(&ctx.registry), - &config.key_alias, - passphrase_provider, - keychain, - config.witness_config.as_ref(), - ) - .map_err(|e| SetupError::StorageError(e.into()))?; - - let did_str = controller_did.into_inner(); - ctx.identity_storage - .create_identity(&did_str, None) - .map_err(|e| SetupError::StorageError(e.into()))?; - - Ok((did_str, config.key_alias.clone())) -} - -fn derive_device_did( - key_alias: &KeyAlias, - keychain: &(dyn KeyStorage + Send + Sync), - passphrase_provider: &dyn PassphraseProvider, -) -> Result { - let pk_bytes = auths_core::storage::keychain::extract_public_key_bytes( - keychain, - key_alias, - passphrase_provider, - )?; - - let device_did = DeviceDID::from_ed25519(pk_bytes.as_slice().try_into().map_err(|_| { - SetupError::CryptoError(auths_core::AgentError::InvalidInput( - "public key is not 32 bytes".into(), - )) - })?); - - Ok(device_did) -} - -fn bind_device( - key_alias: &KeyAlias, - ctx: &AuthsContext, - keychain: &(dyn KeyStorage + Send + Sync), - signer: &dyn SecureSigner, - passphrase_provider: &dyn PassphraseProvider, - now: DateTime, -) -> Result { - let managed = ctx - .identity_storage - .load_identity() - .map_err(|e| SetupError::StorageError(e.into()))?; - - let pk_bytes = auths_core::storage::keychain::extract_public_key_bytes( - keychain, - key_alias, - passphrase_provider, - )?; - - let device_did = DeviceDID::from_ed25519(pk_bytes.as_slice().try_into().map_err(|_| { - SetupError::CryptoError(auths_core::AgentError::InvalidInput( - "public key is not 32 bytes".into(), - )) - })?); - - let meta = AttestationMetadata { - timestamp: Some(now), - expires_at: None, - note: Some("Linked by auths-sdk setup".to_string()), - }; - - let attestation = create_signed_attestation( - now, - &managed.storage_id, - &managed.controller_did, - &device_did, - &pk_bytes, - None, - &meta, - signer, - passphrase_provider, - Some(key_alias), - Some(key_alias), - vec![], - None, - None, - ) - .map_err(|e| SetupError::StorageError(e.into()))?; - - ctx.attestation_sink - .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(attestation)) - .map_err(|e| SetupError::StorageError(e.into()))?; - - Ok(device_did) -} - -fn bind_platform_claim(platform: &Option) -> Option { - match platform { - Some(PlatformVerification::GitHub { .. }) => None, - Some(PlatformVerification::GitLab { .. }) => None, - Some(PlatformVerification::Skip) | None => None, - } -} - -fn configure_git_signing( - scope: &GitSigningScope, - key_alias: &KeyAlias, - git_config: Option<&dyn GitConfigProvider>, - sign_binary_path: Option<&Path>, -) -> Result { - if matches!(scope, GitSigningScope::Skip) { - return Ok(false); - } - let git_config = git_config.ok_or_else(|| { - SetupError::InvalidSetupConfig("GitConfigProvider required for non-Skip scope".into()) - })?; - let sign_binary_path = sign_binary_path.ok_or_else(|| { - SetupError::InvalidSetupConfig("sign_binary_path required for non-Skip scope".into()) - })?; - set_git_signing_config(key_alias, git_config, sign_binary_path)?; - Ok(true) -} - -fn set_git_signing_config( - key_alias: &KeyAlias, - git_config: &dyn GitConfigProvider, - sign_binary_path: &Path, -) -> Result<(), SetupError> { - let auths_sign_str = sign_binary_path.to_str().ok_or_else(|| { - SetupError::InvalidSetupConfig("auths-sign path is not valid UTF-8".into()) - })?; - let signing_key = format!("auths:{}", key_alias); - let configs: &[(&str, &str)] = &[ - ("gpg.format", "ssh"), - ("gpg.ssh.program", auths_sign_str), - ("user.signingkey", &signing_key), - ("commit.gpgsign", "true"), - ("tag.gpgsign", "true"), - ]; - for (key, val) in configs { - git_config - .set(key, val) - .map_err(SetupError::GitConfigError)?; - } - Ok(()) -} - -fn submit_registration(config: &CreateDeveloperIdentityConfig) -> Option { - if !config.register_on_registry { - return None; - } - None -} - -fn initialize_ci_keys( - ctx: &AuthsContext, - keychain: &(dyn KeyStorage + Send + Sync), - passphrase_provider: &dyn PassphraseProvider, - _now: DateTime, -) -> Result<(String, KeyAlias), SetupError> { - let key_alias = KeyAlias::new_unchecked("ci-key"); - - let (controller_did, _) = initialize_registry_identity( - std::sync::Arc::clone(&ctx.registry), - &key_alias, - passphrase_provider, - keychain, - None, - ) - .map_err(|e| SetupError::StorageError(e.into()))?; - - Ok((controller_did.into_inner(), key_alias)) -} - -fn generate_ci_env_block( - key_alias: &KeyAlias, - repo_path: &Path, - environment: &CiEnvironment, -) -> Vec { - match environment { - CiEnvironment::GitHubActions => generate_github_env_block(key_alias, repo_path), - CiEnvironment::GitLabCi => generate_gitlab_env_block(key_alias, repo_path), - CiEnvironment::Custom { name } => generate_generic_env_block(key_alias, repo_path, name), - CiEnvironment::Unknown => generate_generic_env_block(key_alias, repo_path, "ci"), - } -} - -fn generate_github_env_block(key_alias: &KeyAlias, repo_path: &Path) -> Vec { - let mut lines = base_env_lines(key_alias, repo_path); - lines.push(String::new()); - lines.push("# GitHub Actions: add these as repository secrets".to_string()); - lines.push("# then reference them in your workflow env: block".to_string()); - lines -} - -fn generate_gitlab_env_block(key_alias: &KeyAlias, repo_path: &Path) -> Vec { - let mut lines = base_env_lines(key_alias, repo_path); - lines.push(String::new()); - lines.push("# GitLab CI: add these as CI/CD variables".to_string()); - lines.push("# in Settings > CI/CD > Variables".to_string()); - lines -} - -fn generate_generic_env_block( - key_alias: &KeyAlias, - repo_path: &Path, - platform: &str, -) -> Vec { - let mut lines = base_env_lines(key_alias, repo_path); - lines.push(String::new()); - lines.push(format!("# {platform}: add these as environment variables")); - lines -} - -fn base_env_lines(key_alias: &KeyAlias, repo_path: &Path) -> Vec { - vec![ - format!("export AUTHS_KEYCHAIN_BACKEND=\"memory\""), - format!("export AUTHS_REPO=\"{}\"", repo_path.display()), - format!("export AUTHS_KEY_ALIAS=\"{key_alias}\""), - String::new(), - format!("export GIT_CONFIG_COUNT=4"), - format!("export GIT_CONFIG_KEY_0=\"gpg.format\""), - format!("export GIT_CONFIG_VALUE_0=\"ssh\""), - format!("export GIT_CONFIG_KEY_1=\"gpg.ssh.program\""), - format!("export GIT_CONFIG_VALUE_1=\"auths-sign\""), - format!("export GIT_CONFIG_KEY_2=\"user.signingKey\""), - format!("export GIT_CONFIG_VALUE_2=\"auths:{key_alias}\""), - format!("export GIT_CONFIG_KEY_3=\"commit.gpgSign\""), - format!("export GIT_CONFIG_VALUE_3=\"true\""), - ] -} - -fn build_agent_identity_proposal( - _provisioning_config: &auths_id::agent_identity::AgentProvisioningConfig, - config: &CreateAgentIdentityConfig, -) -> Result { - Ok(AgentIdentityResult { - agent_did: None, - parent_did: config - .parent_identity_did - .as_deref() - .and_then(|s| IdentityDID::parse(s).ok()), - capabilities: config.capabilities.clone(), - }) -} +pub use crate::domains::identity::service::{initialize, install_registry_hook}; diff --git a/crates/auths-sdk/src/signing.rs b/crates/auths-sdk/src/signing.rs index 045336a5..05a737b7 100644 --- a/crates/auths-sdk/src/signing.rs +++ b/crates/auths-sdk/src/signing.rs @@ -1,514 +1,7 @@ -//! Signing pipeline orchestration. -//! -//! Composed pipeline: validate freeze → sign data → format SSHSIG. -//! Agent communication and passphrase prompting remain in the CLI. +//! Re-exports from the signing domain for backwards compatibility. -use crate::context::AuthsContext; -use crate::ports::artifact::ArtifactSource; -use auths_core::crypto::ssh::{self, SecureSeed}; -use auths_core::crypto::{provider_bridge, signer as core_signer}; -use auths_core::signing::{PassphraseProvider, SecureSigner}; -use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage}; -use auths_id::attestation::core::resign_attestation; -use auths_id::attestation::create::create_signed_attestation; -use auths_id::storage::git_refs::AttestationMetadata; -use auths_verifier::core::{Capability, ResourceId}; -use auths_verifier::types::DeviceDID; -use std::collections::HashMap; -use std::path::Path; -use std::sync::Arc; - -/// Errors from the signing pipeline. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum SigningError { - /// The identity is in a freeze state and signing is not permitted. - #[error("identity is frozen: {0}")] - IdentityFrozen(String), - /// The requested key alias could not be resolved from the keychain. - #[error("key resolution failed: {0}")] - KeyResolution(String), - /// The cryptographic signing operation failed. - #[error("signing operation failed: {0}")] - SigningFailed(String), - /// The supplied passphrase was incorrect. - #[error("invalid passphrase")] - InvalidPassphrase, - /// SSHSIG PEM encoding failed after signing. - #[error("PEM encoding failed: {0}")] - PemEncoding(String), - /// The agent is not available (platform unsupported, not installed, or not reachable). - #[error("agent unavailable: {0}")] - AgentUnavailable(String), - /// The agent accepted the signing request but it failed. - #[error("agent signing failed")] - AgentSigningFailed(#[source] crate::ports::agent::AgentSigningError), - /// All passphrase attempts were exhausted without a successful decryption. - #[error("passphrase exhausted after {attempts} attempt(s)")] - PassphraseExhausted { - /// Number of failed attempts before giving up. - attempts: usize, - }, - /// The platform keychain could not be accessed. - #[error("keychain unavailable: {0}")] - KeychainUnavailable(String), - /// The encrypted key material could not be decrypted. - #[error("key decryption failed: {0}")] - KeyDecryptionFailed(String), -} - -impl auths_core::error::AuthsErrorInfo for SigningError { - fn error_code(&self) -> &'static str { - match self { - Self::IdentityFrozen(_) => "AUTHS-E5901", - Self::KeyResolution(_) => "AUTHS-E5902", - Self::SigningFailed(_) => "AUTHS-E5903", - Self::InvalidPassphrase => "AUTHS-E5904", - Self::PemEncoding(_) => "AUTHS-E5905", - Self::AgentUnavailable(_) => "AUTHS-E5906", - Self::AgentSigningFailed(_) => "AUTHS-E5907", - Self::PassphraseExhausted { .. } => "AUTHS-E5908", - Self::KeychainUnavailable(_) => "AUTHS-E5909", - Self::KeyDecryptionFailed(_) => "AUTHS-E5910", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::IdentityFrozen(_) => Some("To unfreeze: auths emergency unfreeze"), - Self::KeyResolution(_) => Some("Run `auths key list` to check available keys"), - Self::SigningFailed(_) => Some( - "The signing operation failed; verify your key is accessible with `auths key list`", - ), - Self::InvalidPassphrase => Some("Check your passphrase and try again"), - Self::PemEncoding(_) => { - Some("Failed to encode the key in PEM format; the key material may be corrupted") - } - Self::AgentUnavailable(_) => Some("Start the agent with `auths agent start`"), - Self::AgentSigningFailed(_) => Some("Check agent logs with `auths agent status`"), - Self::PassphraseExhausted { .. } => Some( - "The passphrase you entered is incorrect (tried 3 times). Verify it matches what you set during init, or try: auths key export --key-alias --format pub", - ), - Self::KeychainUnavailable(_) => Some("Run `auths doctor` to diagnose keychain issues"), - Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"), - } - } -} - -/// Configuration for a signing operation. -/// -/// Args: -/// * `namespace`: The SSHSIG namespace (typically "git"). -/// -/// Usage: -/// ```ignore -/// let config = SigningConfig { -/// namespace: "git".to_string(), -/// }; -/// ``` -pub struct SigningConfig { - /// SSHSIG namespace string (e.g. `"git"` for commit signing). - pub namespace: String, -} - -/// Validate that the identity is not frozen. -/// -/// Args: -/// * `repo_path`: Path to the auths repository (typically `~/.auths`). -/// * `now`: The reference time used to check if the freeze is active. -/// -/// Usage: -/// ```ignore -/// validate_freeze_state(&repo_path, clock.now())?; -/// ``` -pub fn validate_freeze_state( - repo_path: &Path, - now: chrono::DateTime, -) -> Result<(), SigningError> { - use auths_id::freeze::load_active_freeze; - - if let Some(state) = load_active_freeze(repo_path, now) - .map_err(|e| SigningError::IdentityFrozen(e.to_string()))? - { - return Err(SigningError::IdentityFrozen(format!( - "frozen until {}. Remaining: {}. To unfreeze: auths emergency unfreeze", - state.frozen_until.format("%Y-%m-%d %H:%M UTC"), - state.expires_description(now), - ))); - } - - Ok(()) -} - -/// Construct the SSHSIG signed-data payload for the given data and namespace. -/// -/// Args: -/// * `data`: The raw bytes to sign. -/// * `namespace`: The SSHSIG namespace (e.g. "git"). -/// -/// Usage: -/// ```ignore -/// let payload = construct_signature_payload(b"data", "git")?; -/// ``` -pub fn construct_signature_payload(data: &[u8], namespace: &str) -> Result, SigningError> { - ssh::construct_sshsig_signed_data(data, namespace) - .map_err(|e| SigningError::SigningFailed(e.to_string())) -} - -/// Create a complete SSHSIG PEM signature from a seed and data. -/// -/// Args: -/// * `seed`: The Ed25519 signing seed. -/// * `data`: The raw bytes to sign. -/// * `namespace`: The SSHSIG namespace. -/// -/// Usage: -/// ```ignore -/// let pem = sign_with_seed(&seed, b"data to sign", "git")?; -/// ``` -pub fn sign_with_seed( - seed: &SecureSeed, - data: &[u8], - namespace: &str, -) -> Result { - ssh::create_sshsig(seed, data, namespace).map_err(|e| SigningError::PemEncoding(e.to_string())) -} - -// --------------------------------------------------------------------------- -// Artifact attestation signing -// --------------------------------------------------------------------------- - -/// Selects how a signing key is supplied to `sign_artifact`. -/// -/// `Alias` resolves the key from the platform keychain at call time. -/// `Direct` injects a raw seed, bypassing the keychain — intended for headless -/// CI/CD runners that have no platform keychain available. -pub enum SigningKeyMaterial { - /// Resolve by alias from the platform keychain. - Alias(KeyAlias), - /// Inject a raw Ed25519 seed directly. The passphrase provider is not called. - Direct(SecureSeed), -} - -/// Parameters for the artifact attestation signing workflow. -/// -/// Usage: -/// ```ignore -/// let params = ArtifactSigningParams { -/// artifact: Arc::new(my_artifact), -/// identity_key: Some(SigningKeyMaterial::Alias("my-identity".into())), -/// device_key: SigningKeyMaterial::Direct(my_seed), -/// expires_in: Some(31_536_000), -/// note: None, -/// }; -/// ``` -pub struct ArtifactSigningParams { - /// The artifact to attest. Provides the canonical digest and metadata. - pub artifact: Arc, - /// Identity key source. `None` skips the identity signature. - pub identity_key: Option, - /// Device key source. Required to produce a dual-signed attestation. - pub device_key: SigningKeyMaterial, - /// Duration in seconds until expiration (per RFC 6749). - pub expires_in: Option, - /// Optional human-readable annotation embedded in the attestation. - pub note: Option, -} - -/// Result of a successful artifact attestation signing operation. -/// -/// Usage: -/// ```ignore -/// let result = sign_artifact(params, &ctx)?; -/// std::fs::write(&output_path, &result.attestation_json)?; -/// println!("Signed {} (sha256:{})", result.rid, result.digest); -/// ``` -#[derive(Debug)] -pub struct ArtifactSigningResult { - /// Canonical JSON of the signed attestation. - pub attestation_json: String, - /// Resource identifier assigned to the attestation in the identity store. - pub rid: ResourceId, - /// Hex-encoded SHA-256 digest of the attested artifact. - pub digest: String, -} - -/// Errors from the artifact attestation signing workflow. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum ArtifactSigningError { - /// No auths identity was found in the configured identity storage. - #[error("identity not found in configured identity storage")] - IdentityNotFound, - - /// The key alias could not be resolved to usable key material. - #[error("key resolution failed: {0}")] - KeyResolutionFailed(String), - - /// The encrypted key material could not be decrypted (e.g. wrong passphrase). - #[error("key decryption failed: {0}")] - KeyDecryptionFailed(String), - - /// Computing the artifact digest failed. - #[error("digest computation failed: {0}")] - DigestFailed(String), - - /// Building or serializing the attestation failed. - #[error("attestation creation failed: {0}")] - AttestationFailed(String), - - /// Adding the device signature to a partially-signed attestation failed. - #[error("attestation re-signing failed: {0}")] - ResignFailed(String), -} - -impl auths_core::error::AuthsErrorInfo for ArtifactSigningError { - fn error_code(&self) -> &'static str { - match self { - Self::IdentityNotFound => "AUTHS-E5801", - Self::KeyResolutionFailed(_) => "AUTHS-E5802", - Self::KeyDecryptionFailed(_) => "AUTHS-E5803", - Self::DigestFailed(_) => "AUTHS-E5804", - Self::AttestationFailed(_) => "AUTHS-E5805", - Self::ResignFailed(_) => "AUTHS-E5806", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::IdentityNotFound => { - Some("Run `auths init` to create an identity, or `auths key import` to restore one") - } - Self::KeyResolutionFailed(_) => { - Some("Run `auths status` to see available device aliases") - } - Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"), - Self::DigestFailed(_) => Some("Verify the file exists and is readable"), - Self::AttestationFailed(_) => Some("Check identity storage with `auths status`"), - Self::ResignFailed(_) => { - Some("Verify your device key is accessible with `auths status`") - } - } - } -} - -/// A `SecureSigner` backed by pre-resolved in-memory seeds. -/// -/// Seeds are keyed by alias. The passphrase provider is never called because -/// all key material was resolved before construction. -struct SeedMapSigner { - seeds: HashMap, -} - -impl SecureSigner for SeedMapSigner { - fn sign_with_alias( - &self, - alias: &auths_core::storage::keychain::KeyAlias, - _passphrase_provider: &dyn PassphraseProvider, - message: &[u8], - ) -> Result, auths_core::AgentError> { - let seed = self - .seeds - .get(alias.as_str()) - .ok_or(auths_core::AgentError::KeyNotFound)?; - provider_bridge::sign_ed25519_sync(seed, message) - .map_err(|e| auths_core::AgentError::CryptoError(e.to_string())) - } - - fn sign_for_identity( - &self, - _identity_did: &IdentityDID, - _passphrase_provider: &dyn PassphraseProvider, - _message: &[u8], - ) -> Result, auths_core::AgentError> { - Err(auths_core::AgentError::KeyNotFound) - } -} - -struct ResolvedKey { - alias: KeyAlias, - seed: SecureSeed, - public_key_bytes: Vec, -} - -fn resolve_optional_key( - material: Option<&SigningKeyMaterial>, - synthetic_alias: &'static str, - keychain: &(dyn KeyStorage + Send + Sync), - passphrase_provider: &dyn PassphraseProvider, - passphrase_prompt: &str, -) -> Result, ArtifactSigningError> { - match material { - None => Ok(None), - Some(SigningKeyMaterial::Alias(alias)) => { - let (_, _role, encrypted) = keychain - .load_key(alias) - .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?; - let passphrase = passphrase_provider - .get_passphrase(passphrase_prompt) - .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?; - let pkcs8 = core_signer::decrypt_keypair(&encrypted, &passphrase) - .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?; - let (seed, pubkey) = core_signer::load_seed_and_pubkey(&pkcs8) - .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?; - Ok(Some(ResolvedKey { - alias: alias.clone(), - seed, - public_key_bytes: pubkey.to_vec(), - })) - } - Some(SigningKeyMaterial::Direct(seed)) => { - let pubkey = provider_bridge::ed25519_public_key_from_seed_sync(seed) - .map_err(|e| ArtifactSigningError::KeyDecryptionFailed(e.to_string()))?; - Ok(Some(ResolvedKey { - alias: KeyAlias::new_unchecked(synthetic_alias), - seed: SecureSeed::new(*seed.as_bytes()), - public_key_bytes: pubkey.to_vec(), - })) - } - } -} - -fn resolve_required_key( - material: &SigningKeyMaterial, - synthetic_alias: &'static str, - keychain: &(dyn KeyStorage + Send + Sync), - passphrase_provider: &dyn PassphraseProvider, - passphrase_prompt: &str, -) -> Result { - resolve_optional_key( - Some(material), - synthetic_alias, - keychain, - passphrase_provider, - passphrase_prompt, - ) - .map(|opt| { - opt.ok_or(ArtifactSigningError::KeyDecryptionFailed( - "expected key material but got None".into(), - )) - })? -} - -/// Full artifact attestation signing pipeline. -/// -/// Loads the identity, resolves key material (supporting both keychain aliases -/// and direct in-memory seed injection), computes the artifact digest, and -/// produces a dual-signed attestation JSON. -/// -/// Args: -/// * `params`: All inputs required for signing, including key material and artifact source. -/// * `ctx`: Runtime context providing identity storage, key storage, passphrase provider, and clock. -/// -/// Usage: -/// ```ignore -/// let params = ArtifactSigningParams { -/// artifact: Arc::new(FileArtifact::new(Path::new("release.tar.gz"))), -/// identity_key: Some(SigningKeyMaterial::Alias("my-key".into())), -/// device_key: SigningKeyMaterial::Direct(seed), -/// expires_in: Some(31_536_000), -/// note: None, -/// }; -/// let result = sign_artifact(params, &ctx)?; -/// ``` -pub fn sign_artifact( - params: ArtifactSigningParams, - ctx: &AuthsContext, -) -> Result { - let managed = ctx - .identity_storage - .load_identity() - .map_err(|_| ArtifactSigningError::IdentityNotFound)?; - - let keychain = ctx.key_storage.as_ref(); - let passphrase_provider = ctx.passphrase_provider.as_ref(); - - let identity_resolved = resolve_optional_key( - params.identity_key.as_ref(), - "__artifact_identity__", - keychain, - passphrase_provider, - "Enter passphrase for identity key:", - )?; - - let device_resolved = resolve_required_key( - ¶ms.device_key, - "__artifact_device__", - keychain, - passphrase_provider, - "Enter passphrase for device key:", - )?; - - let mut seeds: HashMap = HashMap::new(); - let identity_alias: Option = identity_resolved.map(|r| { - let alias = r.alias.clone(); - seeds.insert(r.alias.into_inner(), r.seed); - alias - }); - let device_alias = device_resolved.alias.clone(); - seeds.insert(device_resolved.alias.into_inner(), device_resolved.seed); - let device_pk_bytes = device_resolved.public_key_bytes; - - let device_did = - DeviceDID::from_ed25519(device_pk_bytes.as_slice().try_into().map_err(|_| { - ArtifactSigningError::AttestationFailed("device public key must be 32 bytes".into()) - })?); - - let artifact_meta = params - .artifact - .metadata() - .map_err(|e| ArtifactSigningError::DigestFailed(e.to_string()))?; - - let rid = ResourceId::new(format!("sha256:{}", artifact_meta.digest.hex)); - let now = ctx.clock.now(); - let meta = AttestationMetadata { - timestamp: Some(now), - expires_at: params - .expires_in - .map(|s| now + chrono::Duration::seconds(s as i64)), - note: params.note, - }; - - let payload = serde_json::to_value(&artifact_meta) - .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; - - let signer = SeedMapSigner { seeds }; - // Seeds are already resolved — passphrase provider will not be called. - let noop_provider = auths_core::PrefilledPassphraseProvider::new(""); - - let mut attestation = create_signed_attestation( - now, - &rid, - &managed.controller_did, - &device_did, - &device_pk_bytes, - Some(payload), - &meta, - &signer, - &noop_provider, - identity_alias.as_ref(), - Some(&device_alias), - vec![Capability::sign_release()], - None, - None, - ) - .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; - - resign_attestation( - &mut attestation, - &signer, - &noop_provider, - identity_alias.as_ref(), - &device_alias, - ) - .map_err(|e| ArtifactSigningError::ResignFailed(e.to_string()))?; - - let attestation_json = serde_json::to_string_pretty(&attestation) - .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; - - Ok(ArtifactSigningResult { - attestation_json, - rid, - digest: artifact_meta.digest.hex, - }) -} +pub use crate::domains::signing::service::{ + ArtifactSigningError, ArtifactSigningParams, ArtifactSigningResult, SigningConfig, + SigningError, SigningKeyMaterial, construct_signature_payload, sign_artifact, sign_with_seed, + validate_freeze_state, +}; diff --git a/crates/auths-sdk/src/types.rs b/crates/auths-sdk/src/types.rs index 58e54286..1a3c514b 100644 --- a/crates/auths-sdk/src/types.rs +++ b/crates/auths-sdk/src/types.rs @@ -1,659 +1,14 @@ -use auths_core::storage::keychain::KeyAlias; -use auths_verifier::Capability; -use auths_verifier::types::DeviceDID; -use std::path::PathBuf; +//! Re-exports of domain configuration types for backwards compatibility. -/// Policy for handling an existing identity during developer setup. -/// -/// Replaces interactive CLI prompts with a typed enum that headless consumers -/// can set programmatically. -/// -/// Usage: -/// ```ignore -/// let config = CreateDeveloperIdentityConfig::builder("my-key") -/// .with_conflict_policy(IdentityConflictPolicy::ReuseExisting) -/// .build(); -/// ``` -#[derive(Debug, Clone, Default)] -pub enum IdentityConflictPolicy { - /// Return an error if an identity already exists (default). - #[default] - Error, - /// Reuse the existing identity silently. - ReuseExisting, - /// Overwrite the existing identity with a new one. - ForceNew, -} +// Re-export all identity config types +pub use crate::domains::identity::types::{ + CiEnvironment, CiIdentityConfig, CreateAgentIdentityConfig, CreateAgentIdentityConfigBuilder, + CreateDeveloperIdentityConfig, CreateDeveloperIdentityConfigBuilder, IdentityConfig, + IdentityConflictPolicy, IdentityRotationConfig, +}; -/// How to verify a platform identity. -/// -/// The CLI obtains tokens interactively (OAuth device flow, browser open). -/// The SDK accepts the resulting token — it never opens a browser. -/// -/// Usage: -/// ```ignore -/// let platform = PlatformVerification::GitHub { -/// access_token: "ghp_abc123".into(), -/// }; -/// ``` -#[derive(Debug, Clone)] -pub enum PlatformVerification { - /// Verify via GitHub using a personal access token. - GitHub { - /// The GitHub personal access token. - access_token: String, - }, - /// Verify via GitLab using a personal access token. - GitLab { - /// The GitLab personal access token. - access_token: String, - }, - /// Skip platform verification. - Skip, -} +// Re-export signing types +pub use crate::domains::signing::types::{GitSigningScope, PlatformVerification}; -/// Whether and how to configure Git commit signing. -/// -/// Usage: -/// ```ignore -/// let scope = GitSigningScope::Global; -/// ``` -#[derive(Debug, Clone, Default)] -pub enum GitSigningScope { - /// Configure signing for a specific repository only. - Local { - /// Path to the repository to configure. - repo_path: PathBuf, - }, - /// Configure signing globally for all repositories (default). - #[default] - Global, - /// Do not configure git signing. - Skip, -} - -/// CI platform environment. -/// -/// Usage: -/// ```ignore -/// let env = CiEnvironment::GitHubActions; -/// ``` -#[derive(Debug, Clone)] -pub enum CiEnvironment { - /// GitHub Actions CI environment. - GitHubActions, - /// GitLab CI/CD environment. - GitLabCi, - /// A custom CI platform with a user-provided name. - Custom { - /// The name of the custom CI platform. - name: String, - }, - /// The CI platform could not be detected. - Unknown, -} - -/// Configuration for provisioning a new developer identity. -/// -/// Use [`CreateDeveloperIdentityConfigBuilder`] to construct this with optional fields. -/// The registry backend is injected via [`crate::context::AuthsContext`] — this -/// struct carries only serializable configuration values. -/// -/// Args: -/// * `key_alias`: Human-readable name for the key (e.g. "work-laptop"). -/// -/// Usage: -/// ```ignore -/// let config = CreateDeveloperIdentityConfig::builder("work-laptop") -/// .with_platform(PlatformVerification::GitHub { access_token: "ghp_abc".into() }) -/// .with_git_signing_scope(GitSigningScope::Global) -/// .build(); -/// ``` -#[derive(Debug)] -pub struct CreateDeveloperIdentityConfig { - /// Human-readable name for the key (e.g. "work-laptop"). - pub key_alias: KeyAlias, - /// Optional platform verification configuration. - pub platform: Option, - /// How to configure git commit signing. - pub git_signing_scope: GitSigningScope, - /// Whether to register the identity on a remote registry. - pub register_on_registry: bool, - /// Remote registry URL, if registration is enabled. - pub registry_url: Option, - /// What to do if an identity already exists. - pub conflict_policy: IdentityConflictPolicy, - /// Optional KERI witness configuration for the inception event. - pub witness_config: Option, - /// Optional JSON metadata to attach to the identity. - pub metadata: Option, - /// Path to the `auths-sign` binary, required when git signing is configured. - /// The CLI resolves this via `which::which("auths-sign")`. - pub sign_binary_path: Option, -} - -impl CreateDeveloperIdentityConfig { - /// Creates a builder with the required key alias. - /// - /// Args: - /// * `key_alias`: Human-readable name for the key. - /// - /// Usage: - /// ```ignore - /// let builder = CreateDeveloperIdentityConfig::builder("my-key"); - /// ``` - pub fn builder(key_alias: KeyAlias) -> CreateDeveloperIdentityConfigBuilder { - CreateDeveloperIdentityConfigBuilder { - key_alias, - platform: None, - git_signing_scope: GitSigningScope::Global, - register_on_registry: false, - registry_url: None, - conflict_policy: IdentityConflictPolicy::Error, - witness_config: None, - metadata: None, - sign_binary_path: None, - } - } -} - -/// Builder for [`CreateDeveloperIdentityConfig`]. -#[derive(Debug)] -pub struct CreateDeveloperIdentityConfigBuilder { - key_alias: KeyAlias, - platform: Option, - git_signing_scope: GitSigningScope, - register_on_registry: bool, - registry_url: Option, - conflict_policy: IdentityConflictPolicy, - witness_config: Option, - metadata: Option, - sign_binary_path: Option, -} - -impl CreateDeveloperIdentityConfigBuilder { - /// Configures platform identity verification for the new identity. - /// - /// The SDK never opens a browser or runs OAuth flows. The caller must - /// obtain the access token beforehand and pass it here. - /// - /// Args: - /// * `platform`: The platform and access token to verify against. - /// - /// Usage: - /// ```ignore - /// let config = CreateDeveloperIdentityConfig::builder("my-key") - /// .with_platform(PlatformVerification::GitHub { - /// access_token: "ghp_abc123".into(), - /// }) - /// .build(); - /// ``` - pub fn with_platform(mut self, platform: PlatformVerification) -> Self { - self.platform = Some(platform); - self - } - - /// Sets the Git signing scope (local, global, or skip). - /// - /// Args: - /// * `scope`: How to configure `git config` for commit signing. - /// - /// Usage: - /// ```ignore - /// let config = CreateDeveloperIdentityConfig::builder("my-key") - /// .with_git_signing_scope(GitSigningScope::Local { - /// repo_path: PathBuf::from("/path/to/repo"), - /// }) - /// .build(); - /// ``` - pub fn with_git_signing_scope(mut self, scope: GitSigningScope) -> Self { - self.git_signing_scope = scope; - self - } - - /// Enables registration on a remote auths registry after identity creation. - /// - /// Args: - /// * `url`: The registry URL to register with. - /// - /// Usage: - /// ```ignore - /// let config = CreateDeveloperIdentityConfig::builder("my-key") - /// .with_registration("https://registry.auths.dev") - /// .build(); - /// ``` - pub fn with_registration(mut self, url: impl Into) -> Self { - self.register_on_registry = true; - self.registry_url = Some(url.into()); - self - } - - /// Sets the policy for handling an existing identity at the registry path. - /// - /// Args: - /// * `policy`: What to do if an identity already exists. - /// - /// Usage: - /// ```ignore - /// let config = CreateDeveloperIdentityConfig::builder("my-key") - /// .with_conflict_policy(IdentityConflictPolicy::ReuseExisting) - /// .build(); - /// ``` - pub fn with_conflict_policy(mut self, policy: IdentityConflictPolicy) -> Self { - self.conflict_policy = policy; - self - } - - /// Sets the witness configuration for the KERI inception event. - /// - /// Args: - /// * `config`: Witness endpoints and thresholds. - /// - /// Usage: - /// ```ignore - /// let config = CreateDeveloperIdentityConfig::builder("my-key") - /// .with_witness_config(witness_cfg) - /// .build(); - /// ``` - pub fn with_witness_config(mut self, config: auths_id::witness_config::WitnessConfig) -> Self { - self.witness_config = Some(config); - self - } - - /// Attaches custom metadata to the identity (e.g. `created_at`, `setup_profile`). - /// - /// Args: - /// * `metadata`: Arbitrary JSON metadata. - /// - /// Usage: - /// ```ignore - /// let config = CreateDeveloperIdentityConfig::builder("my-key") - /// .with_metadata(serde_json::json!({"team": "platform"})) - /// .build(); - /// ``` - pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self { - self.metadata = Some(metadata); - self - } - - /// Sets the path to the `auths-sign` binary used for git signing configuration. - /// - /// Required when `git_signing_scope` is not `Skip`. The CLI resolves this via - /// `which::which("auths-sign")`. - /// - /// Args: - /// * `path`: Absolute path to the `auths-sign` binary. - /// - /// Usage: - /// ```ignore - /// let config = CreateDeveloperIdentityConfig::builder("my-key") - /// .with_sign_binary_path(PathBuf::from("/usr/local/bin/auths-sign")) - /// .build(); - /// ``` - pub fn with_sign_binary_path(mut self, path: PathBuf) -> Self { - self.sign_binary_path = Some(path); - self - } - - /// Builds the final [`CreateDeveloperIdentityConfig`]. - /// - /// Usage: - /// ```ignore - /// let config = CreateDeveloperIdentityConfig::builder("my-key").build(); - /// ``` - pub fn build(self) -> CreateDeveloperIdentityConfig { - CreateDeveloperIdentityConfig { - key_alias: self.key_alias, - platform: self.platform, - git_signing_scope: self.git_signing_scope, - register_on_registry: self.register_on_registry, - registry_url: self.registry_url, - conflict_policy: self.conflict_policy, - witness_config: self.witness_config, - metadata: self.metadata, - sign_binary_path: self.sign_binary_path, - } - } -} - -/// Configuration for CI/ephemeral identity. -/// -/// The keychain and passphrase are passed separately to [`crate::setup::initialize`] — -/// this struct carries only the CI-specific configuration values. -/// -/// Args: -/// * `ci_environment`: The detected or specified CI platform. -/// * `registry_path`: Path to the ephemeral auths registry. -/// -/// Usage: -/// ```ignore -/// let config = CiIdentityConfig { -/// ci_environment: CiEnvironment::GitHubActions, -/// registry_path: PathBuf::from("/tmp/.auths"), -/// }; -/// ``` -#[derive(Debug, Clone)] -pub struct CiIdentityConfig { - /// The detected or specified CI platform. - pub ci_environment: CiEnvironment, - /// Path to the ephemeral auths registry directory. - pub registry_path: PathBuf, -} - -/// Selects which identity persona to provision via [`crate::setup::initialize`]. -/// -/// Usage: -/// ```ignore -/// // Developer preset (platform keychain, git signing): -/// let config = IdentityConfig::developer(KeyAlias::new_unchecked("work-laptop")); -/// -/// // CI preset (memory keychain, ephemeral): -/// let config = IdentityConfig::ci(PathBuf::from("/tmp/.auths-ci")); -/// -/// // Agent preset (minimal capabilities, long-lived): -/// let config = IdentityConfig::agent(KeyAlias::new_unchecked("deploy-bot"), registry_path); -/// -/// // Custom configuration: -/// let config = IdentityConfig::Developer( -/// CreateDeveloperIdentityConfig::builder(alias) -/// .with_platform(PlatformVerification::GitHub { access_token: token }) -/// .build() -/// ); -/// ``` -#[derive(Debug)] -pub enum IdentityConfig { - /// Full local developer setup: platform keychain, git signing, passphrase. - Developer(CreateDeveloperIdentityConfig), - /// Ephemeral CI setup: memory keychain, no git signing. - Ci(CiIdentityConfig), - /// Agent setup: file keychain, scoped capabilities, long-lived. - Agent(CreateAgentIdentityConfig), -} - -impl IdentityConfig { - /// Create a developer identity config with sensible defaults. - /// - /// Args: - /// * `alias`: Human-readable key alias (e.g. `"work-laptop"`). - /// - /// Usage: - /// ```ignore - /// let config = IdentityConfig::developer(KeyAlias::new_unchecked("work-laptop")); - /// ``` - pub fn developer(alias: KeyAlias) -> Self { - Self::Developer(CreateDeveloperIdentityConfig::builder(alias).build()) - } - - /// Create a CI/ephemeral identity config. - /// - /// Args: - /// * `registry_path`: Path to the ephemeral auths registry directory. - /// - /// Usage: - /// ```ignore - /// let config = IdentityConfig::ci(PathBuf::from("/tmp/.auths-ci")); - /// ``` - pub fn ci(registry_path: impl Into) -> Self { - Self::Ci(CiIdentityConfig { - ci_environment: CiEnvironment::Unknown, - registry_path: registry_path.into(), - }) - } - - /// Create an agent identity config with sensible defaults. - /// - /// Args: - /// * `alias`: Human-readable agent name. - /// * `registry_path`: Path to the auths registry directory. - /// - /// Usage: - /// ```ignore - /// let config = IdentityConfig::agent(KeyAlias::new_unchecked("deploy-bot"), registry_path); - /// ``` - pub fn agent(alias: KeyAlias, registry_path: impl Into) -> Self { - Self::Agent(CreateAgentIdentityConfig::builder(alias, registry_path).build()) - } -} - -/// Configuration for agent identity. -/// -/// Use [`CreateAgentIdentityConfigBuilder`] to construct this with optional fields. -/// -/// Args: -/// * `alias`: Human-readable name for the agent. -/// * `parent_identity_did`: The DID of the identity that owns this agent. -/// * `registry_path`: Path to the auths registry. -/// -/// Usage: -/// ```ignore -/// let config = CreateAgentIdentityConfig::builder("deploy-bot", "did:keri:abc123", path) -/// .with_capabilities(vec!["sign-commit".into()]) -/// .build(); -/// ``` -#[derive(Debug)] -pub struct CreateAgentIdentityConfig { - /// Human-readable name for the agent. - pub alias: KeyAlias, - /// Capabilities granted to the agent. - pub capabilities: Vec, - /// DID of the parent identity that delegates authority. - pub parent_identity_did: Option, - /// Path to the auths registry directory. - pub registry_path: PathBuf, - /// Duration in seconds until expiration (per RFC 6749). - pub expires_in: Option, - /// If true, construct state without persisting. - pub dry_run: bool, -} - -impl CreateAgentIdentityConfig { - /// Creates a builder with alias and registry path. - /// - /// Args: - /// * `alias`: Human-readable name for the agent. - /// * `registry_path`: Path to the auths registry directory. - /// - /// Usage: - /// ```ignore - /// let builder = CreateAgentIdentityConfig::builder("deploy-bot", path); - /// ``` - pub fn builder( - alias: KeyAlias, - registry_path: impl Into, - ) -> CreateAgentIdentityConfigBuilder { - CreateAgentIdentityConfigBuilder { - alias, - capabilities: Vec::new(), - parent_identity_did: None, - registry_path: registry_path.into(), - expires_in: None, - dry_run: false, - } - } -} - -/// Builder for [`CreateAgentIdentityConfig`]. -#[derive(Debug)] -pub struct CreateAgentIdentityConfigBuilder { - alias: KeyAlias, - capabilities: Vec, - parent_identity_did: Option, - registry_path: PathBuf, - expires_in: Option, - dry_run: bool, -} - -impl CreateAgentIdentityConfigBuilder { - /// Sets the parent identity DID that delegates authority to this agent. - /// - /// Args: - /// * `did`: The DID of the owning identity. - /// - /// Usage: - /// ```ignore - /// let config = CreateAgentIdentityConfig::builder("bot", path) - /// .with_parent_did("did:keri:abc123") - /// .build(); - /// ``` - pub fn with_parent_did(mut self, did: impl Into) -> Self { - self.parent_identity_did = Some(did.into()); - self - } - - /// Sets the capabilities granted to the agent. - /// - /// Args: - /// * `capabilities`: List of capabilities. - /// - /// Usage: - /// ```ignore - /// let config = CreateAgentIdentityConfig::builder("bot", path) - /// .with_capabilities(vec![Capability::sign_commit()]) - /// .build(); - /// ``` - pub fn with_capabilities(mut self, capabilities: Vec) -> Self { - self.capabilities = capabilities; - self - } - - /// Sets the agent key expiration time in seconds. - /// - /// Args: - /// * `secs`: Seconds until the agent identity expires. - /// - /// Usage: - /// ```ignore - /// let config = CreateAgentIdentityConfig::builder("bot", path) - /// .with_expiry(86400) // 24 hours - /// .build(); - /// ``` - pub fn with_expiry(mut self, secs: u64) -> Self { - self.expires_in = Some(secs); - self - } - - /// Enables dry-run mode (constructs state without persisting). - /// - /// Usage: - /// ```ignore - /// let config = CreateAgentIdentityConfig::builder("bot", path) - /// .dry_run(true) - /// .build(); - /// ``` - pub fn dry_run(mut self, enabled: bool) -> Self { - self.dry_run = enabled; - self - } - - /// Builds the final [`CreateAgentIdentityConfig`]. - /// - /// Usage: - /// ```ignore - /// let config = CreateAgentIdentityConfig::builder("bot", path).build(); - /// ``` - pub fn build(self) -> CreateAgentIdentityConfig { - CreateAgentIdentityConfig { - alias: self.alias, - capabilities: self.capabilities, - parent_identity_did: self.parent_identity_did, - registry_path: self.registry_path, - expires_in: self.expires_in, - dry_run: self.dry_run, - } - } -} - -/// Configuration for extending a device authorization's expiration. -/// -/// Args: -/// * `repo_path`: Path to the auths registry. -/// * `device_did`: The DID of the device whose authorization to extend. -/// * `expires_in`: Duration in seconds until expiration (per RFC 6749). -/// * `identity_key_alias`: Keychain alias for the identity key (for re-signing). -/// * `device_key_alias`: Keychain alias for the device key (for re-signing). -/// -/// Usage: -/// ```ignore -/// let config = DeviceExtensionConfig { -/// repo_path: PathBuf::from("/home/user/.auths"), -/// device_did: "did:key:z6Mk...".into(), -/// expires_in: 31_536_000, -/// identity_key_alias: "my-identity".into(), -/// device_key_alias: "my-device".into(), -/// }; -/// ``` -#[derive(Debug)] -pub struct DeviceExtensionConfig { - /// Path to the auths registry. - pub repo_path: PathBuf, - /// DID of the device whose authorization to extend. - pub device_did: DeviceDID, - /// Duration in seconds until expiration (per RFC 6749). - pub expires_in: u64, - /// Keychain alias for the identity signing key. - pub identity_key_alias: KeyAlias, - /// Keychain alias for the device signing key (pass `None` to skip device co-signing). - pub device_key_alias: Option, -} - -/// Configuration for rotating an identity's signing keys. -/// -/// Args: -/// * `repo_path`: Path to the auths registry (typically `~/.auths`). -/// * `identity_key_alias`: Keychain alias of the current signing key. -/// If `None`, the first non-next alias for the identity is used. -/// * `next_key_alias`: Keychain alias to store the new key under. -/// Defaults to `-rotated-`. -/// -/// Usage: -/// ```ignore -/// let config = IdentityRotationConfig { -/// repo_path: PathBuf::from("/home/user/.auths"), -/// identity_key_alias: Some("main".into()), -/// next_key_alias: None, -/// }; -/// ``` -#[derive(Debug)] -pub struct IdentityRotationConfig { - /// Path to the auths registry (typically `~/.auths`). - pub repo_path: PathBuf, - /// Keychain alias of the current signing key (auto-detected if `None`). - pub identity_key_alias: Option, - /// Keychain alias for the new rotated key (auto-generated if `None`). - pub next_key_alias: Option, -} - -/// Configuration for linking a device to an existing identity. -/// -/// Args: -/// * `identity_key_alias`: Alias of the identity key in the keychain. -/// -/// Usage: -/// ```ignore -/// let config = DeviceLinkConfig { -/// identity_key_alias: "my-identity".into(), -/// device_key_alias: Some("macbook-pro".into()), -/// device_did: None, -/// capabilities: vec!["sign-commit".into()], -/// expires_in: Some(31_536_000), -/// note: Some("Work laptop".into()), -/// payload: None, -/// }; -/// ``` -#[derive(Debug)] -pub struct DeviceLinkConfig { - /// Alias of the identity key in the keychain. - pub identity_key_alias: KeyAlias, - /// Optional alias for the device key (defaults to identity alias). - pub device_key_alias: Option, - /// Optional pre-existing device DID (not yet supported). - pub device_did: Option, - /// Capabilities to grant to the linked device. - pub capabilities: Vec, - /// Duration in seconds until expiration (per RFC 6749). - pub expires_in: Option, - /// Optional human-readable note for the attestation. - pub note: Option, - /// Optional JSON payload to embed in the attestation. - pub payload: Option, -} +// Re-export device config types +pub use crate::domains::device::types::{DeviceExtensionConfig, DeviceLinkConfig}; diff --git a/crates/auths-sdk/tests/cases/artifact.rs b/crates/auths-sdk/tests/cases/artifact.rs index 280b2bbe..bdd341c4 100644 --- a/crates/auths-sdk/tests/cases/artifact.rs +++ b/crates/auths-sdk/tests/cases/artifact.rs @@ -1,8 +1,8 @@ use auths_core::crypto::ssh::SecureSeed; -use auths_sdk::ports::artifact::{ArtifactDigest, ArtifactError, ArtifactMetadata, ArtifactSource}; -use auths_sdk::signing::{ +use auths_sdk::domains::signing::service::{ ArtifactSigningError, ArtifactSigningParams, SigningKeyMaterial, sign_artifact, }; +use auths_sdk::ports::artifact::{ArtifactDigest, ArtifactError, ArtifactMetadata, ArtifactSource}; use auths_sdk::testing::fakes::FakeArtifactSource; use auths_sdk::workflows::artifact::compute_digest; use std::sync::Arc; diff --git a/crates/auths-sdk/tests/cases/ci_setup.rs b/crates/auths-sdk/tests/cases/ci_setup.rs index e0e693f5..56a758aa 100644 --- a/crates/auths-sdk/tests/cases/ci_setup.rs +++ b/crates/auths-sdk/tests/cases/ci_setup.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use auths_core::PrefilledPassphraseProvider; use auths_core::signing::StorageSigner; use auths_core::storage::memory::{MEMORY_KEYCHAIN, MemoryKeychainHandle}; -use auths_sdk::result::InitializeResult; -use auths_sdk::setup::initialize; -use auths_sdk::types::{CiEnvironment, CiIdentityConfig, IdentityConfig}; +use auths_sdk::domains::identity::service::initialize; +use auths_sdk::domains::identity::types::InitializeResult; +use auths_sdk::domains::identity::types::{CiEnvironment, CiIdentityConfig, IdentityConfig}; use crate::cases::helpers::build_test_context; diff --git a/crates/auths-sdk/tests/cases/device.rs b/crates/auths-sdk/tests/cases/device.rs index 025f8146..be3063fa 100644 --- a/crates/auths-sdk/tests/cases/device.rs +++ b/crates/auths-sdk/tests/cases/device.rs @@ -5,14 +5,13 @@ use auths_core::ports::clock::SystemClock; use auths_core::signing::{PassphraseProvider, StorageSigner}; use auths_core::storage::keychain::KeyAlias; use auths_core::storage::memory::{MEMORY_KEYCHAIN, MemoryKeychainHandle}; -use auths_sdk::device::{extend_device, link_device}; -use auths_sdk::error::DeviceExtensionError; -use auths_sdk::result::InitializeResult; -use auths_sdk::setup::initialize; -use auths_sdk::types::{ - CreateDeveloperIdentityConfig, DeviceExtensionConfig, DeviceLinkConfig, GitSigningScope, - IdentityConfig, -}; +use auths_sdk::domains::device::error::DeviceExtensionError; +use auths_sdk::domains::device::service::{extend_device, link_device}; +use auths_sdk::domains::device::types::{DeviceExtensionConfig, DeviceLinkConfig}; +use auths_sdk::domains::identity::service::initialize; +use auths_sdk::domains::identity::types::InitializeResult; +use auths_sdk::domains::identity::types::{CreateDeveloperIdentityConfig, IdentityConfig}; +use auths_sdk::domains::signing::types::GitSigningScope; use crate::cases::helpers::{build_test_context, build_test_context_with_provider}; @@ -45,7 +44,7 @@ fn link_test_device(registry_path: &std::path::Path, key_alias: &KeyAlias) -> St let provider = PrefilledPassphraseProvider::new("Test-passphrase1!"); let config = CreateDeveloperIdentityConfig::builder(KeyAlias::new_unchecked("device-key")) .with_git_signing_scope(GitSigningScope::Skip) - .with_conflict_policy(auths_sdk::types::IdentityConflictPolicy::ForceNew) + .with_conflict_policy(auths_sdk::domains::identity::types::IdentityConflictPolicy::ForceNew) .build(); let ctx = build_test_context(registry_path, Arc::new(MemoryKeychainHandle)); initialize( diff --git a/crates/auths-sdk/tests/cases/helpers.rs b/crates/auths-sdk/tests/cases/helpers.rs index b38a5afb..203f8bd2 100644 --- a/crates/auths-sdk/tests/cases/helpers.rs +++ b/crates/auths-sdk/tests/cases/helpers.rs @@ -11,9 +11,10 @@ use auths_id::ports::registry::RegistryBackend; use auths_id::storage::attestation::AttestationSource; use auths_id::storage::identity::IdentityStorage; use auths_sdk::context::AuthsContext; -use auths_sdk::result::InitializeResult; -use auths_sdk::setup::initialize; -use auths_sdk::types::{CreateDeveloperIdentityConfig, GitSigningScope, IdentityConfig}; +use auths_sdk::domains::identity::service::initialize; +use auths_sdk::domains::identity::types::InitializeResult; +use auths_sdk::domains::identity::types::{CreateDeveloperIdentityConfig, IdentityConfig}; +use auths_sdk::domains::signing::types::GitSigningScope; use auths_storage::git::{ GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage, }; diff --git a/crates/auths-sdk/tests/cases/org.rs b/crates/auths-sdk/tests/cases/org.rs index 0bebbf3b..c5a7c6d4 100644 --- a/crates/auths-sdk/tests/cases/org.rs +++ b/crates/auths-sdk/tests/cases/org.rs @@ -4,7 +4,7 @@ use auths_core::storage::keychain::KeyAlias; use auths_core::testing::DeterministicUuidProvider; use auths_id::ports::registry::RegistryBackend; use auths_id::testing::fakes::FakeRegistryBackend; -use auths_sdk::error::OrgError; +use auths_sdk::domains::org::error::OrgError; use auths_sdk::testing::fakes::FakeSecureSigner; use auths_sdk::workflows::org::{ AddMemberCommand, OrgContext, RevokeMemberCommand, Role, UpdateCapabilitiesCommand, diff --git a/crates/auths-sdk/tests/cases/rotation.rs b/crates/auths-sdk/tests/cases/rotation.rs index f5332831..2f218457 100644 --- a/crates/auths-sdk/tests/cases/rotation.rs +++ b/crates/auths-sdk/tests/cases/rotation.rs @@ -10,11 +10,13 @@ use auths_core::storage::memory::{MEMORY_KEYCHAIN, MemoryKeychainHandle}; use auths_id::keri::{KeyState, Prefix, Said}; use auths_id::ports::registry::RegistryBackend; use auths_id::testing::fakes::FakeRegistryBackend; -use auths_sdk::error::RotationError; -use auths_sdk::result::InitializeResult; -use auths_sdk::setup::initialize; -use auths_sdk::types::IdentityConfig; -use auths_sdk::types::{CreateDeveloperIdentityConfig, GitSigningScope, IdentityRotationConfig}; +use auths_sdk::domains::identity::error::RotationError; +use auths_sdk::domains::identity::service::initialize; +use auths_sdk::domains::identity::types::InitializeResult; +use auths_sdk::domains::identity::types::{ + CreateDeveloperIdentityConfig, IdentityConfig, IdentityRotationConfig, +}; +use auths_sdk::domains::signing::types::GitSigningScope; use auths_sdk::workflows::rotation::{ RotationKeyMaterial, apply_rotation, compute_rotation_event, rotate_identity, }; diff --git a/crates/auths-sdk/tests/cases/setup.rs b/crates/auths-sdk/tests/cases/setup.rs index cac9915c..26e84d80 100644 --- a/crates/auths-sdk/tests/cases/setup.rs +++ b/crates/auths-sdk/tests/cases/setup.rs @@ -4,11 +4,12 @@ use auths_core::PrefilledPassphraseProvider; use auths_core::signing::StorageSigner; use auths_core::storage::keychain::KeyAlias; use auths_core::storage::memory::{MEMORY_KEYCHAIN, MemoryKeychainHandle}; -use auths_sdk::result::InitializeResult; -use auths_sdk::setup::initialize; -use auths_sdk::types::{ - CreateDeveloperIdentityConfig, GitSigningScope, IdentityConfig, IdentityConflictPolicy, +use auths_sdk::domains::identity::service::initialize; +use auths_sdk::domains::identity::types::InitializeResult; +use auths_sdk::domains::identity::types::{ + CreateDeveloperIdentityConfig, IdentityConfig, IdentityConflictPolicy, }; +use auths_sdk::domains::signing::types::GitSigningScope; use crate::cases::helpers::build_test_context; @@ -21,7 +22,7 @@ fn dev_result( ctx: &auths_sdk::context::AuthsContext, signer: &dyn auths_core::signing::SecureSigner, provider: &dyn auths_core::signing::PassphraseProvider, -) -> auths_sdk::result::DeveloperIdentityResult { +) -> auths_sdk::domains::identity::types::DeveloperIdentityResult { match initialize( IdentityConfig::Developer(config), ctx, diff --git a/crates/auths-sdk/tests/cases/signing.rs b/crates/auths-sdk/tests/cases/signing.rs index 954244db..6564c3d8 100644 --- a/crates/auths-sdk/tests/cases/signing.rs +++ b/crates/auths-sdk/tests/cases/signing.rs @@ -1,4 +1,4 @@ -use auths_sdk::signing::{self, SigningConfig, SigningError}; +use auths_sdk::domains::signing::service::{self as signing, SigningConfig, SigningError}; use auths_sdk::workflows::signing::{ CommitSigningContext, CommitSigningParams, CommitSigningWorkflow, };