diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 67657da7..1bb1cc64 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,12 +35,12 @@ repos: types: [rust] pass_filenames: false - - id: gen-docs - name: cargo xtask gen-docs (auto-fix) - entry: bash -c 'cargo run --package xtask -- gen-docs && git add docs/cli/commands/' - language: system - files: (crates/auths-cli/src/|crates/xtask/src/gen_docs|docs/cli/commands/) - pass_filenames: false + # - id: gen-docs + # name: cargo xtask gen-docs (auto-fix) + # entry: bash -c 'cargo run --package xtask -- gen-docs && git add docs/cli/commands/' + # language: system + # files: (crates/auths-cli/src/|crates/xtask/src/gen_docs|docs/cli/commands/) + # pass_filenames: false - id: check-clippy-sync name: cargo xtask check-clippy-sync diff --git a/Cargo.lock b/Cargo.lock index d62efaaa..59a98e85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -416,6 +416,7 @@ dependencies = [ "toml 1.0.6+spec-1.1.0", "tower", "tower-http", + "url", "uuid", "windows", "x25519-dalek", @@ -525,8 +526,10 @@ dependencies = [ "auths-core", "auths-verifier", "axum", + "chrono", "futures-util", "hex", + "rand 0.8.5", "reqwest 0.13.2", "ring", "serde", @@ -534,6 +537,8 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-tungstenite", + "url", + "urlencoding", ] [[package]] @@ -675,9 +680,11 @@ dependencies = [ name = "auths-sdk" version = "0.0.1-rc.9" dependencies = [ + "async-trait", "auths-core", "auths-crypto", "auths-id", + "auths-infra-http", "auths-pairing-daemon", "auths-policy", "auths-sdk", @@ -699,6 +706,7 @@ dependencies = [ "ssh-key", "tempfile", "thiserror 2.0.18", + "url", "zeroize", ] diff --git a/crates/auths-cli/src/commands/namespace.rs b/crates/auths-cli/src/commands/namespace.rs index a7b5a9b6..3c1ae325 100644 --- a/crates/auths-cli/src/commands/namespace.rs +++ b/crates/auths-cli/src/commands/namespace.rs @@ -1,17 +1,22 @@ +use std::io::{self, Write}; + use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; +use auths_core::ports::namespace::{Ecosystem, PackageName, PlatformContext}; use auths_core::signing::StorageSigner; use auths_core::storage::keychain::{KeyAlias, get_platform_keychain}; +use auths_crypto::AuthsErrorInfo; use auths_id::storage::identity::IdentityStorage; use auths_id::storage::layout; +use auths_sdk::namespace_registry::NamespaceVerifierRegistry; use auths_sdk::registration::DEFAULT_REGISTRY_URL; use auths_sdk::workflows::namespace::{ - ClaimNamespaceCommand, DelegateNamespaceCommand, TransferNamespaceCommand, - parse_claim_response, parse_lookup_response, sign_namespace_claim, sign_namespace_delegate, - sign_namespace_transfer, + DelegateNamespaceCommand, TransferNamespaceCommand, initiate_namespace_claim, + parse_claim_response, parse_lookup_response, sign_namespace_delegate, sign_namespace_transfer, }; use auths_storage::git::RegistryIdentityStorage; +use auths_verifier::CanonicalDid; use crate::commands::executable::ExecutableCommand; use crate::config::CliConfig; @@ -43,6 +48,18 @@ pub enum NamespaceSubcommand { /// Alias of the signing key in keychain #[arg(long)] signer_alias: Option, + + /// GitHub username for cross-referencing ownership + #[arg(long)] + github_username: Option, + + /// npm username for cross-referencing ownership + #[arg(long)] + npm_username: Option, + + /// PyPI username for cross-referencing ownership + #[arg(long)] + pypi_username: Option, }, /// Delegate namespace authority to another identity @@ -177,6 +194,7 @@ fn post_signed_entry(registry_url: &str, body: serde_json::Value) -> Result Result<()> { match cmd.subcommand { NamespaceSubcommand::Claim { @@ -184,43 +202,151 @@ pub fn handle_namespace(cmd: NamespaceCommand, ctx: &CliConfig) -> Result<()> { package_name, registry_url, signer_alias, + github_username, + npm_username, + pypi_username, } => { let registry_url = resolve_registry_url(registry_url); let (controller_did, key_alias) = load_identity_and_alias(ctx, signer_alias)?; let signer = StorageSigner::new(get_platform_keychain()?); let passphrase_provider = ctx.passphrase_provider.clone(); - println!("Claiming namespace {}/{}...", ecosystem, package_name); + let eco = Ecosystem::parse(&ecosystem).context("Failed to parse ecosystem")?; + let pkg = PackageName::parse(&package_name).context("Failed to parse package name")?; - let sdk_cmd = ClaimNamespaceCommand { - ecosystem: ecosystem.clone(), - package_name: package_name.clone(), - registry_url: registry_url.clone(), + #[allow(clippy::disallowed_methods)] + // INVARIANT: controller_did from storage is always valid + let canonical_did = CanonicalDid::new_unchecked(controller_did.as_str()); + + let platform = PlatformContext { + github_username, + npm_username, + pypi_username, }; - let signed = sign_namespace_claim( - &sdk_cmd, - &controller_did, - &signer, - passphrase_provider.as_ref(), - &key_alias, - ) - .context("Failed to sign namespace claim")?; + let registry = NamespaceVerifierRegistry::with_defaults(); + let verifier = registry + .require(eco) + .context("No verifier available for this ecosystem")?; + + println!("Verifying ownership of {}/{}...\n", eco, package_name); + + let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; + + let mut session = rt + .block_on(initiate_namespace_claim( + chrono::Utc::now(), + verifier.as_ref(), + eco, + pkg, + canonical_did, + platform, + )) + .context("Failed to initiate namespace verification")?; + + println!(" {}\n", session.challenge.instructions); + + // Try verification — on OwnershipNotConfirmed, progressively + // prompt for additional credentials (e.g. PyPI username) + let max_retries = 3; + let mut result = None; + + for attempt in 0..max_retries { + match rt.block_on(session.complete_ref( + chrono::Utc::now(), + verifier.as_ref(), + &signer, + passphrase_provider.as_ref(), + &key_alias, + )) { + Ok(r) => { + result = Some(r); + break; + } + Err(auths_sdk::workflows::namespace::NamespaceError::VerificationFailed( + ref verify_err, + )) => { + use auths_core::ports::namespace::NamespaceVerifyError; + match verify_err { + NamespaceVerifyError::OwnershipNotConfirmed { ecosystem, .. } + if attempt + 1 < max_retries => + { + // Progressive prompting: ask for ecosystem-specific username + match *ecosystem { + Ecosystem::Pypi if session.platform.pypi_username.is_none() => { + eprintln!( + "\nAutomatic verification didn't match. \ + Let's try your PyPI username." + ); + print!("What's your PyPI username? "); + io::stdout().flush().ok(); + let mut username = String::new(); + let _ = io::stdin().read_line(&mut username); + let username = username.trim().to_string(); + if !username.is_empty() { + session.platform.pypi_username = Some(username); + eprintln!("Retrying with PyPI username...\n"); + } + continue; + } + Ecosystem::Npm if session.platform.npm_username.is_none() => { + eprintln!( + "\nAutomatic verification didn't match. \ + Let's try your npm username." + ); + print!("What's your npm username? "); + io::stdout().flush().ok(); + let mut username = String::new(); + let _ = io::stdin().read_line(&mut username); + let username = username.trim().to_string(); + if !username.is_empty() { + session.platform.npm_username = Some(username); + eprintln!("Retrying with npm username...\n"); + } + continue; + } + _ => { + eprintln!( + "\nVerification not confirmed. Did you complete the step above?" + ); + print!("Press Enter to retry, or Ctrl+C to cancel..."); + io::stdout().flush().ok(); + let _ = io::stdin().read_line(&mut String::new()); + continue; + } + } + } + _ => { + eprintln!("\n✗ Verification failed [{}]", verify_err.error_code()); + eprintln!(" {verify_err}"); + if let Some(hint) = verify_err.suggestion() { + eprintln!("\n Hint: {hint}"); + } + return Err(anyhow!("{}", verify_err)); + } + } + } + Err(e) => return Err(e).context("Namespace verification failed"), + } + } + + let result = result + .ok_or_else(|| anyhow!("Verification failed after {max_retries} attempts"))?; + + println!("\nChecking... ✓ Verified!\n"); + println!("Claiming namespace {}/{}...", eco, package_name); - let response = post_signed_entry(®istry_url, signed.to_request_body())?; + let response = post_signed_entry(®istry_url, result.signed_entry.to_request_body())?; - let result = parse_claim_response( - &ecosystem, + let claim_result = parse_claim_response( + eco.as_str(), &package_name, controller_did.as_str(), &response, ); - println!("\nNamespace claimed successfully!"); - println!(" Ecosystem: {}", result.ecosystem); - println!(" Package: {}", result.package_name); - println!(" Owner: {}", result.owner_did); - println!(" Log Sequence: {}", result.log_sequence); + println!("\n✓ Namespace {}/{} claimed", eco, package_name); + println!(" Log sequence: {}", claim_result.log_sequence); Ok(()) } diff --git a/crates/auths-core/Cargo.toml b/crates/auths-core/Cargo.toml index 61a6705e..d2a96f67 100644 --- a/crates/auths-core/Cargo.toml +++ b/crates/auths-core/Cargo.toml @@ -52,6 +52,7 @@ schemars.workspace = true x25519-dalek = { version = "2", features = ["static_secrets"] } auths-verifier = { workspace = true, features = ["native"] } +url = { version = "2", features = ["serde"] } uuid.workspace = true # Optional secp256k1/BIP340 Schnorr support for Nostr diff --git a/crates/auths-core/src/ports/mod.rs b/crates/auths-core/src/ports/mod.rs index 8e994b8e..fc48551b 100644 --- a/crates/auths-core/src/ports/mod.rs +++ b/crates/auths-core/src/ports/mod.rs @@ -4,6 +4,8 @@ pub mod clock; /// Config file I/O port for reading and writing `config.toml`. pub mod config_store; pub mod id; +/// Namespace verification port traits for proof-of-ownership across package ecosystems. +pub mod namespace; pub mod network; /// Pairing relay client port for session-based device pairing. pub mod pairing; diff --git a/crates/auths-core/src/ports/namespace.rs b/crates/auths-core/src/ports/namespace.rs new file mode 100644 index 00000000..1ab81f04 --- /dev/null +++ b/crates/auths-core/src/ports/namespace.rs @@ -0,0 +1,468 @@ +//! Namespace verification port traits and types for proof-of-ownership verification. + +use std::fmt; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; + +use auths_verifier::CanonicalDid; + +/// Package ecosystem identifier for namespace claims. +/// +/// Args: +/// (no arguments — this is an enum definition) +/// +/// Usage: +/// ```ignore +/// let eco = Ecosystem::parse("crates.io")?; +/// assert_eq!(eco, Ecosystem::Cargo); +/// assert_eq!(eco.as_str(), "cargo"); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Ecosystem { + /// Node Package Manager (npmjs.com). + Npm, + /// Python Package Index (pypi.org). + Pypi, + /// Rust crate registry (crates.io). + Cargo, + /// Docker Hub container registry. + Docker, + /// Go module proxy (pkg.go.dev). + Go, + /// Maven Central (Java/JVM). + Maven, + /// NuGet (.NET). + Nuget, +} + +impl Ecosystem { + /// Returns the canonical lowercase string identifier for this ecosystem. + /// + /// Usage: + /// ```ignore + /// assert_eq!(Ecosystem::Cargo.as_str(), "cargo"); + /// ``` + pub fn as_str(&self) -> &'static str { + match self { + Self::Npm => "npm", + Self::Pypi => "pypi", + Self::Cargo => "cargo", + Self::Docker => "docker", + Self::Go => "go", + Self::Maven => "maven", + Self::Nuget => "nuget", + } + } + + /// Parse an ecosystem string, accepting canonical names and common aliases. + /// + /// Args: + /// * `s`: The ecosystem string to parse (case-insensitive). + /// + /// Usage: + /// ```ignore + /// assert_eq!(Ecosystem::parse("crates.io")?, Ecosystem::Cargo); + /// assert_eq!(Ecosystem::parse("NPM")?, Ecosystem::Npm); + /// ``` + pub fn parse(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "npm" | "npmjs" | "npmjs.com" => Ok(Self::Npm), + "pypi" | "pypi.org" => Ok(Self::Pypi), + "cargo" | "crates.io" | "crates" => Ok(Self::Cargo), + "docker" | "dockerhub" | "docker.io" => Ok(Self::Docker), + "go" | "golang" | "go.dev" | "pkg.go.dev" => Ok(Self::Go), + "maven" | "maven-central" | "mvn" => Ok(Self::Maven), + "nuget" | "nuget.org" => Ok(Self::Nuget), + _ => Err(NamespaceVerifyError::UnsupportedEcosystem { + ecosystem: s.to_string(), + }), + } + } +} + +impl fmt::Display for Ecosystem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Validated package name within an ecosystem. +/// +/// Rejects empty strings, control characters, and path traversal patterns. +/// +/// Usage: +/// ```ignore +/// let name = PackageName::parse("my-package")?; +/// assert_eq!(name.as_str(), "my-package"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PackageName(String); + +impl PackageName { + /// Parse and validate a package name string. + /// + /// Args: + /// * `s`: The package name to validate. + /// + /// Usage: + /// ```ignore + /// let name = PackageName::parse("left-pad")?; + /// ``` + pub fn parse(s: &str) -> Result { + if s.is_empty() { + return Err(NamespaceVerifyError::InvalidPackageName { + name: s.to_string(), + reason: "package name cannot be empty".to_string(), + }); + } + + if s.chars().any(|c| c.is_control()) { + return Err(NamespaceVerifyError::InvalidPackageName { + name: s.to_string(), + reason: "package name contains control characters".to_string(), + }); + } + + if s.contains("..") || s.starts_with('/') || s.starts_with('\\') { + return Err(NamespaceVerifyError::InvalidPackageName { + name: s.to_string(), + reason: "package name contains path traversal".to_string(), + }); + } + + Ok(Self(s.to_string())) + } + + /// Returns the package name as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for PackageName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// Verification token for namespace ownership challenges. +/// +/// Tokens must have the `auths-verify-` prefix followed by a hex-encoded suffix. +/// Token generation is an infrastructure concern — this type only validates and holds. +/// +/// Usage: +/// ```ignore +/// let token = VerificationToken::parse("auths-verify-abc123def456")?; +/// assert_eq!(token.as_str(), "auths-verify-abc123def456"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct VerificationToken(String); + +const TOKEN_PREFIX: &str = "auths-verify-"; + +impl VerificationToken { + /// Parse and validate a verification token string. + /// + /// Args: + /// * `s`: The token string to validate. Must have `auths-verify-` prefix and hex suffix. + /// + /// Usage: + /// ```ignore + /// let token = VerificationToken::parse("auths-verify-deadbeef")?; + /// ``` + pub fn parse(s: &str) -> Result { + let suffix = + s.strip_prefix(TOKEN_PREFIX) + .ok_or_else(|| NamespaceVerifyError::InvalidToken { + reason: format!("token must start with '{TOKEN_PREFIX}'"), + })?; + + if suffix.is_empty() { + return Err(NamespaceVerifyError::InvalidToken { + reason: "token suffix cannot be empty".to_string(), + }); + } + + if !suffix.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(NamespaceVerifyError::InvalidToken { + reason: "token suffix must be hex-encoded".to_string(), + }); + } + + Ok(Self(s.to_string())) + } + + /// Returns the token as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for VerificationToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// Method used to verify namespace ownership. +/// +/// Usage: +/// ```ignore +/// let method = VerificationMethod::ApiOwnership; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum VerificationMethod { + /// Verify by publishing a token in a release (e.g., PyPI project_urls). + PublishToken, + /// Verify via registry API ownership/collaborator endpoint. + ApiOwnership, + /// Verify via DNS TXT record (e.g., Go modules). + DnsTxt, +} + +/// Proof of namespace ownership returned after successful verification. +/// +/// Usage: +/// ```ignore +/// let proof = NamespaceOwnershipProof { +/// ecosystem: Ecosystem::Npm, +/// package_name: PackageName::parse("my-package")?, +/// proof_url: "https://registry.npmjs.org/my-package".parse()?, +/// method: VerificationMethod::ApiOwnership, +/// verified_at: now, +/// }; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceOwnershipProof { + /// The ecosystem where ownership was verified. + pub ecosystem: Ecosystem, + /// The package name that was verified. + pub package_name: PackageName, + /// URL where the proof can be independently verified. + pub proof_url: Url, + /// The method used to verify ownership. + pub method: VerificationMethod, + /// When the verification was performed. + pub verified_at: DateTime, +} + +/// Challenge issued to a user to prove namespace ownership. +/// +/// Usage: +/// ```ignore +/// let challenge = VerificationChallenge { +/// ecosystem: Ecosystem::Cargo, +/// package_name: PackageName::parse("my-crate")?, +/// did: CanonicalDid::parse("did:keri:abc123")?, +/// token: VerificationToken::parse("auths-verify-deadbeef")?, +/// instructions: "Add this token to your crate owners".to_string(), +/// expires_at: now + Duration::hours(1), +/// }; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerificationChallenge { + /// The ecosystem for this challenge. + pub ecosystem: Ecosystem, + /// The package being verified. + pub package_name: PackageName, + /// The DID claiming ownership. + pub did: CanonicalDid, + /// The verification token to place in the registry. + pub token: VerificationToken, + /// Human-readable instructions for completing the challenge. + pub instructions: String, + /// When this challenge expires. + pub expires_at: DateTime, +} + +/// Verified platform identity context for cross-referencing during namespace verification. +/// +/// Adapters that use `ApiOwnership` need to cross-reference the caller's upstream +/// identity. The SDK populates this from verified platform claims before calling the adapter. +/// +/// Usage: +/// ```ignore +/// let ctx = PlatformContext { +/// github_username: Some("octocat".to_string()), +/// npm_username: None, +/// pypi_username: None, +/// }; +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PlatformContext { + /// GitHub username from a verified platform claim. + pub github_username: Option, + /// npm username from a verified platform claim. + pub npm_username: Option, + /// PyPI username from a verified platform claim. + pub pypi_username: Option, +} + +/// Errors from namespace verification operations. +/// +/// Usage: +/// ```ignore +/// match result { +/// Err(NamespaceVerifyError::UnsupportedEcosystem { .. }) => { /* unknown ecosystem */ } +/// Err(NamespaceVerifyError::OwnershipNotConfirmed { .. }) => { /* user is not owner */ } +/// Err(e) => return Err(e.into()), +/// Ok(proof) => { /* proceed with proof */ } +/// } +/// ``` +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum NamespaceVerifyError { + /// The requested ecosystem is not supported. + #[error("unsupported ecosystem: {ecosystem}")] + UnsupportedEcosystem { + /// The ecosystem string that was not recognized. + ecosystem: String, + }, + + /// The package was not found in the upstream registry. + #[error("package '{package_name}' not found in {ecosystem}")] + PackageNotFound { + /// The ecosystem where the lookup failed. + ecosystem: Ecosystem, + /// The package name that was not found. + package_name: String, + }, + + /// Ownership could not be confirmed via the upstream registry. + #[error("ownership of '{package_name}' on {ecosystem} not confirmed for the given identity")] + OwnershipNotConfirmed { + /// The ecosystem checked. + ecosystem: Ecosystem, + /// The package name checked. + package_name: String, + }, + + /// The verification challenge has expired. + #[error("verification challenge expired")] + ChallengeExpired, + + /// The verification token is invalid. + #[error("invalid verification token: {reason}")] + InvalidToken { + /// Why the token is invalid. + reason: String, + }, + + /// The package name is invalid. + #[error("invalid package name '{name}': {reason}")] + InvalidPackageName { + /// The rejected package name. + name: String, + /// Why the name is invalid. + reason: String, + }, + + /// A network error occurred during verification. + #[error("verification network error: {message}")] + NetworkError { + /// Human-readable error detail. + message: String, + }, + + /// The upstream registry returned a rate limit response. + #[error("rate limited by {ecosystem} registry")] + RateLimited { + /// The ecosystem that rate-limited us. + ecosystem: Ecosystem, + }, +} + +impl auths_crypto::AuthsErrorInfo for NamespaceVerifyError { + fn error_code(&self) -> &'static str { + match self { + Self::UnsupportedEcosystem { .. } => "AUTHS-E4401", + Self::PackageNotFound { .. } => "AUTHS-E4402", + Self::OwnershipNotConfirmed { .. } => "AUTHS-E4403", + Self::ChallengeExpired => "AUTHS-E4404", + Self::InvalidToken { .. } => "AUTHS-E4405", + Self::InvalidPackageName { .. } => "AUTHS-E4406", + Self::NetworkError { .. } => "AUTHS-E4407", + Self::RateLimited { .. } => "AUTHS-E4408", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::UnsupportedEcosystem { .. } => { + Some("Supported ecosystems: npm, pypi, cargo, docker, go, maven, nuget") + } + Self::PackageNotFound { .. } => { + Some("Check the package name and ensure it exists on the registry") + } + Self::OwnershipNotConfirmed { .. } => { + Some("Ensure you are listed as an owner/collaborator on the upstream registry") + } + Self::ChallengeExpired => Some("Start a new verification challenge"), + Self::InvalidToken { .. } => { + Some("Tokens must start with 'auths-verify-' followed by a hex string") + } + Self::InvalidPackageName { .. } => Some( + "Package names cannot be empty, contain control characters, or use path traversal", + ), + Self::NetworkError { .. } => Some("Check your internet connection and try again"), + Self::RateLimited { .. } => Some("Wait a moment and retry the verification"), + } + } +} + +/// Verifies ownership of a namespace (package) on an upstream registry. +/// +/// Each ecosystem adapter implements this trait. The SDK stores adapters +/// as `Arc` in a registry map keyed by [`Ecosystem`]. +/// +/// Usage: +/// ```ignore +/// let verifier: Arc = registry.get(&Ecosystem::Npm)?; +/// let challenge = verifier.initiate(&package_name, &did, &platform_ctx).await?; +/// // ... user completes challenge ... +/// let proof = verifier.verify(&package_name, &did, &platform_ctx, &challenge).await?; +/// ``` +#[async_trait] +pub trait NamespaceVerifier: Send + Sync { + /// Returns the ecosystem this verifier handles. + fn ecosystem(&self) -> Ecosystem; + + /// Initiate a verification challenge for the given package. + /// + /// Args: + /// * `now`: Current time (injected, never call `Utc::now()` directly). + /// * `package_name`: The package to verify ownership of. + /// * `did`: The caller's canonical DID. + /// * `platform`: Verified platform identity context for cross-referencing. + async fn initiate( + &self, + now: DateTime, + package_name: &PackageName, + did: &CanonicalDid, + platform: &PlatformContext, + ) -> Result; + + /// Verify the challenge was completed and return ownership proof. + /// + /// Args: + /// * `now`: Current time (injected, never call `Utc::now()` directly). + /// * `package_name`: The package to verify ownership of. + /// * `did`: The caller's canonical DID. + /// * `platform`: Verified platform identity context for cross-referencing. + /// * `challenge`: The challenge previously returned by [`initiate`](Self::initiate). + async fn verify( + &self, + now: DateTime, + package_name: &PackageName, + did: &CanonicalDid, + platform: &PlatformContext, + challenge: &VerificationChallenge, + ) -> Result; +} diff --git a/crates/auths-core/tests/cases/mod.rs b/crates/auths-core/tests/cases/mod.rs index 43efadf9..75f6886c 100644 --- a/crates/auths-core/tests/cases/mod.rs +++ b/crates/auths-core/tests/cases/mod.rs @@ -1,4 +1,5 @@ mod key_export; +mod namespace; #[cfg(feature = "keychain-pkcs11")] mod pkcs11; mod said_cross_validation; diff --git a/crates/auths-core/tests/cases/namespace.rs b/crates/auths-core/tests/cases/namespace.rs new file mode 100644 index 00000000..c7e9644f --- /dev/null +++ b/crates/auths-core/tests/cases/namespace.rs @@ -0,0 +1,257 @@ +use std::sync::Arc; + +use auths_core::ports::namespace::{ + Ecosystem, NamespaceVerifier, NamespaceVerifyError, PackageName, PlatformContext, + VerificationToken, +}; +use auths_crypto::AuthsErrorInfo; + +#[test] +fn ecosystem_parse_canonical_names() { + let cases = [ + ("npm", Ecosystem::Npm), + ("pypi", Ecosystem::Pypi), + ("cargo", Ecosystem::Cargo), + ("docker", Ecosystem::Docker), + ("go", Ecosystem::Go), + ("maven", Ecosystem::Maven), + ("nuget", Ecosystem::Nuget), + ]; + + for (input, expected) in cases { + let parsed = Ecosystem::parse(input).unwrap(); + assert_eq!(parsed, expected, "parse({input})"); + assert_eq!(parsed.as_str(), input, "roundtrip for {input}"); + } +} + +#[test] +fn ecosystem_parse_aliases() { + let cases = [ + ("crates.io", Ecosystem::Cargo), + ("crates", Ecosystem::Cargo), + ("npmjs", Ecosystem::Npm), + ("npmjs.com", Ecosystem::Npm), + ("pypi.org", Ecosystem::Pypi), + ("dockerhub", Ecosystem::Docker), + ("docker.io", Ecosystem::Docker), + ("golang", Ecosystem::Go), + ("go.dev", Ecosystem::Go), + ("pkg.go.dev", Ecosystem::Go), + ("maven-central", Ecosystem::Maven), + ("mvn", Ecosystem::Maven), + ("nuget.org", Ecosystem::Nuget), + ]; + + for (alias, expected) in cases { + let parsed = Ecosystem::parse(alias).unwrap(); + assert_eq!(parsed, expected, "alias '{alias}'"); + } +} + +#[test] +fn ecosystem_parse_case_insensitive() { + assert_eq!(Ecosystem::parse("NPM").unwrap(), Ecosystem::Npm); + assert_eq!(Ecosystem::parse("Cargo").unwrap(), Ecosystem::Cargo); + assert_eq!(Ecosystem::parse("PYPI").unwrap(), Ecosystem::Pypi); +} + +#[test] +fn ecosystem_parse_unsupported() { + let err = Ecosystem::parse("rubygems").unwrap_err(); + assert!(matches!( + err, + NamespaceVerifyError::UnsupportedEcosystem { .. } + )); +} + +#[test] +fn ecosystem_display() { + assert_eq!(format!("{}", Ecosystem::Npm), "npm"); + assert_eq!(format!("{}", Ecosystem::Cargo), "cargo"); +} + +#[test] +fn ecosystem_serde_roundtrip() { + let eco = Ecosystem::Cargo; + let json = serde_json::to_string(&eco).unwrap(); + assert_eq!(json, r#""cargo""#); + let parsed: Ecosystem = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, eco); +} + +#[test] +fn package_name_valid() { + let name = PackageName::parse("my-package").unwrap(); + assert_eq!(name.as_str(), "my-package"); + + PackageName::parse("@scope/package").unwrap(); + PackageName::parse("some_crate_v2").unwrap(); + PackageName::parse("a").unwrap(); +} + +#[test] +fn package_name_empty() { + let err = PackageName::parse("").unwrap_err(); + assert!(matches!( + err, + NamespaceVerifyError::InvalidPackageName { .. } + )); +} + +#[test] +fn package_name_control_chars() { + let err = PackageName::parse("bad\x00name").unwrap_err(); + assert!(matches!( + err, + NamespaceVerifyError::InvalidPackageName { .. } + )); + + let err = PackageName::parse("tab\there").unwrap_err(); + assert!(matches!( + err, + NamespaceVerifyError::InvalidPackageName { .. } + )); +} + +#[test] +fn package_name_path_traversal() { + let err = PackageName::parse("../etc/passwd").unwrap_err(); + assert!(matches!( + err, + NamespaceVerifyError::InvalidPackageName { .. } + )); + + let err = PackageName::parse("/absolute/path").unwrap_err(); + assert!(matches!( + err, + NamespaceVerifyError::InvalidPackageName { .. } + )); + + let err = PackageName::parse("\\windows\\path").unwrap_err(); + assert!(matches!( + err, + NamespaceVerifyError::InvalidPackageName { .. } + )); + + let err = PackageName::parse("foo/../bar").unwrap_err(); + assert!(matches!( + err, + NamespaceVerifyError::InvalidPackageName { .. } + )); +} + +#[test] +fn package_name_serde_transparent() { + let name = PackageName::parse("my-pkg").unwrap(); + let json = serde_json::to_string(&name).unwrap(); + assert_eq!(json, r#""my-pkg""#); + let parsed: PackageName = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, name); +} + +#[test] +fn verification_token_valid() { + let token = VerificationToken::parse("auths-verify-deadbeef0123").unwrap(); + assert_eq!(token.as_str(), "auths-verify-deadbeef0123"); +} + +#[test] +fn verification_token_bad_prefix() { + let err = VerificationToken::parse("wrong-prefix-abc123").unwrap_err(); + assert!(matches!(err, NamespaceVerifyError::InvalidToken { .. })); +} + +#[test] +fn verification_token_empty_suffix() { + let err = VerificationToken::parse("auths-verify-").unwrap_err(); + assert!(matches!(err, NamespaceVerifyError::InvalidToken { .. })); +} + +#[test] +fn verification_token_non_hex_suffix() { + let err = VerificationToken::parse("auths-verify-notvalidhex!").unwrap_err(); + assert!(matches!(err, NamespaceVerifyError::InvalidToken { .. })); +} + +#[test] +fn namespace_verify_error_codes() { + let cases: Vec<(NamespaceVerifyError, &str)> = vec![ + ( + NamespaceVerifyError::UnsupportedEcosystem { + ecosystem: "test".to_string(), + }, + "AUTHS-E4401", + ), + ( + NamespaceVerifyError::PackageNotFound { + ecosystem: Ecosystem::Npm, + package_name: "test".to_string(), + }, + "AUTHS-E4402", + ), + ( + NamespaceVerifyError::OwnershipNotConfirmed { + ecosystem: Ecosystem::Npm, + package_name: "test".to_string(), + }, + "AUTHS-E4403", + ), + (NamespaceVerifyError::ChallengeExpired, "AUTHS-E4404"), + ( + NamespaceVerifyError::InvalidToken { + reason: "test".to_string(), + }, + "AUTHS-E4405", + ), + ( + NamespaceVerifyError::InvalidPackageName { + name: "test".to_string(), + reason: "test".to_string(), + }, + "AUTHS-E4406", + ), + ( + NamespaceVerifyError::NetworkError { + message: "test".to_string(), + }, + "AUTHS-E4407", + ), + ( + NamespaceVerifyError::RateLimited { + ecosystem: Ecosystem::Npm, + }, + "AUTHS-E4408", + ), + ]; + + for (err, expected_code) in cases { + assert_eq!(err.error_code(), expected_code, "error code for {err}"); + // All errors should have suggestions + assert!(err.suggestion().is_some(), "suggestion for {err}"); + } +} + +#[test] +fn platform_context_default() { + let ctx = PlatformContext::default(); + assert!(ctx.github_username.is_none()); + assert!(ctx.npm_username.is_none()); + assert!(ctx.pypi_username.is_none()); +} + +#[test] +fn platform_context_partial() { + let ctx = PlatformContext { + github_username: Some("octocat".to_string()), + npm_username: None, + pypi_username: None, + }; + assert_eq!(ctx.github_username.as_deref(), Some("octocat")); +} + +#[test] +fn namespace_verifier_dyn_compatible() { + // Compile-time check: Arc must be valid + fn _accepts_arc(_v: Arc) {} +} diff --git a/crates/auths-crypto/src/provider.rs b/crates/auths-crypto/src/provider.rs index 3fa53441..1b534835 100644 --- a/crates/auths-crypto/src/provider.rs +++ b/crates/auths-crypto/src/provider.rs @@ -40,7 +40,10 @@ impl crate::AuthsErrorInfo for CryptoError { fn error_code(&self) -> &'static str { match self { Self::InvalidSignature => "AUTHS-E1001", - Self::InvalidKeyLength { .. } => "AUTHS-E1002", + Self::InvalidKeyLength { .. } => { + " + " + } Self::InvalidPrivateKey(_) => "AUTHS-E1003", Self::OperationFailed(_) => "AUTHS-E1004", Self::UnsupportedTarget => "AUTHS-E1005", diff --git a/crates/auths-infra-http/Cargo.toml b/crates/auths-infra-http/Cargo.toml index df40b80b..4e2d88cd 100644 --- a/crates/auths-infra-http/Cargo.toml +++ b/crates/auths-infra-http/Cargo.toml @@ -19,8 +19,13 @@ reqwest = { version = "0.13.2", features = ["json", "form"] } thiserror.workspace = true tokio.workspace = true tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] } +chrono = { version = "0.4", features = ["serde"] } +hex = "0.4" +rand = "0.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +url = { version = "2", features = ["serde"] } +urlencoding = "2" [dev-dependencies] auths-core = { workspace = true, features = ["witness-server"] } diff --git a/crates/auths-infra-http/src/lib.rs b/crates/auths-infra-http/src/lib.rs index 9da9a090..c14cd743 100644 --- a/crates/auths-infra-http/src/lib.rs +++ b/crates/auths-infra-http/src/lib.rs @@ -18,6 +18,8 @@ mod error; mod github_gist; mod github_oauth; mod identity_resolver; +/// Namespace verification adapters for package ecosystem ownership proofs. +pub mod namespace; mod pairing_client; mod registry_client; mod request; diff --git a/crates/auths-infra-http/src/namespace/cargo_verifier.rs b/crates/auths-infra-http/src/namespace/cargo_verifier.rs new file mode 100644 index 00000000..cc424264 --- /dev/null +++ b/crates/auths-infra-http/src/namespace/cargo_verifier.rs @@ -0,0 +1,317 @@ +//! crates.io namespace verification adapter. + +use async_trait::async_trait; +use chrono::{DateTime, Duration, Utc}; +use serde::Deserialize; +use url::Url; + +use auths_core::ports::namespace::{ + Ecosystem, NamespaceOwnershipProof, NamespaceVerifier, NamespaceVerifyError, PackageName, + PlatformContext, VerificationChallenge, VerificationMethod, +}; +use auths_verifier::CanonicalDid; + +use super::generate_verification_token; + +/// crates.io namespace ownership verifier. +/// +/// Verifies crate ownership by cross-referencing the crates.io owners API +/// with the user's verified GitHub platform claim. +/// +/// Usage: +/// ```ignore +/// let verifier = CargoVerifier::new(); +/// let challenge = verifier.initiate(&package, &did, &platform).await?; +/// let proof = verifier.verify(&package, &did, &platform, &challenge).await?; +/// ``` +pub struct CargoVerifier { + client: reqwest::Client, + base_url: Url, +} + +impl CargoVerifier { + /// Create a new verifier targeting the production crates.io API. + pub fn new() -> Self { + Self { + client: crate::default_http_client(), + // INVARIANT: hardcoded valid URL + #[allow(clippy::expect_used)] + base_url: Url::parse("https://crates.io").expect("valid URL"), + } + } + + /// Create a verifier with a custom base URL (for testing). + /// + /// Args: + /// * `base_url`: The base URL to use instead of `https://crates.io`. + pub fn with_base_url(base_url: Url) -> Self { + Self { + client: crate::default_http_client(), + base_url, + } + } + + fn owners_url(&self, crate_name: &str) -> String { + format!("{}/api/v1/crates/{}/owners", self.base_url, crate_name) + } + + fn crate_url(&self, crate_name: &str) -> String { + format!("{}/api/v1/crates/{}", self.base_url, crate_name) + } + + async fn fetch_crate_exists(&self, crate_name: &str) -> Result<(), NamespaceVerifyError> { + let url = self.crate_url(crate_name); + let resp = + self.client + .get(&url) + .send() + .await + .map_err(|e| NamespaceVerifyError::NetworkError { + message: e.to_string(), + })?; + + match resp.status().as_u16() { + 200 => Ok(()), + 404 => Err(NamespaceVerifyError::PackageNotFound { + ecosystem: Ecosystem::Cargo, + package_name: crate_name.to_string(), + }), + 429 => Err(NamespaceVerifyError::RateLimited { + ecosystem: Ecosystem::Cargo, + }), + status => Err(NamespaceVerifyError::NetworkError { + message: format!("crates.io returned HTTP {status}"), + }), + } + } + + async fn fetch_owners( + &self, + crate_name: &str, + ) -> Result, NamespaceVerifyError> { + let url = self.owners_url(crate_name); + let resp = + self.client + .get(&url) + .send() + .await + .map_err(|e| NamespaceVerifyError::NetworkError { + message: e.to_string(), + })?; + + match resp.status().as_u16() { + 200 => {} + 404 => { + return Err(NamespaceVerifyError::PackageNotFound { + ecosystem: Ecosystem::Cargo, + package_name: crate_name.to_string(), + }); + } + 429 => { + return Err(NamespaceVerifyError::RateLimited { + ecosystem: Ecosystem::Cargo, + }); + } + status => { + return Err(NamespaceVerifyError::NetworkError { + message: format!("crates.io owners API returned HTTP {status}"), + }); + } + } + + let body: CratesIoOwnersResponse = + resp.json() + .await + .map_err(|e| NamespaceVerifyError::NetworkError { + message: format!("failed to parse crates.io owners response: {e}"), + })?; + + Ok(body.users) + } +} + +impl Default for CargoVerifier { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl NamespaceVerifier for CargoVerifier { + fn ecosystem(&self) -> Ecosystem { + Ecosystem::Cargo + } + + async fn initiate( + &self, + now: DateTime, + package_name: &PackageName, + did: &CanonicalDid, + platform: &PlatformContext, + ) -> Result { + self.fetch_crate_exists(package_name.as_str()).await?; + + let github_username = platform.github_username.as_deref().ok_or_else(|| { + NamespaceVerifyError::OwnershipNotConfirmed { + ecosystem: Ecosystem::Cargo, + package_name: package_name.as_str().to_string(), + } + })?; + + let token = generate_verification_token(); + let expires_at = now + Duration::hours(1); + + Ok(VerificationChallenge { + ecosystem: Ecosystem::Cargo, + package_name: package_name.clone(), + did: did.clone(), + token, + instructions: format!( + "Verify your GitHub account ({github_username}) is listed as an owner \ + of crate '{}' on crates.io", + package_name.as_str() + ), + expires_at, + }) + } + + async fn verify( + &self, + now: DateTime, + package_name: &PackageName, + _did: &CanonicalDid, + platform: &PlatformContext, + _challenge: &VerificationChallenge, + ) -> Result { + let github_username = platform.github_username.as_deref().ok_or_else(|| { + NamespaceVerifyError::OwnershipNotConfirmed { + ecosystem: Ecosystem::Cargo, + package_name: package_name.as_str().to_string(), + } + })?; + + let owners = self.fetch_owners(package_name.as_str()).await?; + + let is_owner = owners.iter().any(|owner| { + if owner.kind == "user" { + owner.login.eq_ignore_ascii_case(github_username) + } else if owner.kind == "team" { + extract_team_org(&owner.login) + .is_some_and(|org| github_username.eq_ignore_ascii_case(org)) + } else { + false + } + }); + + if !is_owner { + return Err(NamespaceVerifyError::OwnershipNotConfirmed { + ecosystem: Ecosystem::Cargo, + package_name: package_name.as_str().to_string(), + }); + } + + let owners_url = self.owners_url(package_name.as_str()); + // INVARIANT: owners_url is built from a valid base_url + #[allow(clippy::expect_used)] + let proof_url = Url::parse(&owners_url).expect("owners URL is valid"); + + Ok(NamespaceOwnershipProof { + ecosystem: Ecosystem::Cargo, + package_name: package_name.clone(), + proof_url, + method: VerificationMethod::ApiOwnership, + verified_at: now, + }) + } +} + +/// Extract org name from team login format `github:org:team`. +fn extract_team_org(login: &str) -> Option<&str> { + let parts: Vec<&str> = login.split(':').collect(); + if parts.len() >= 2 && parts[0] == "github" { + Some(parts[1]) + } else { + None + } +} + +#[derive(Debug, Deserialize)] +struct CratesIoOwnersResponse { + users: Vec, +} + +#[derive(Debug, Deserialize)] +struct CratesIoOwner { + login: String, + kind: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_team_org_from_team_login() { + assert_eq!( + extract_team_org("github:serde-rs:publish"), + Some("serde-rs") + ); + assert_eq!(extract_team_org("github:myorg:team"), Some("myorg")); + } + + #[test] + fn extract_team_org_returns_none_for_user_login() { + assert_eq!(extract_team_org("dtolnay"), None); + assert_eq!(extract_team_org(""), None); + } + + #[test] + fn owner_matching_case_insensitive() { + let owners = [ + CratesIoOwner { + login: "DTolnay".to_string(), + kind: "user".to_string(), + }, + CratesIoOwner { + login: "github:serde-rs:publish".to_string(), + kind: "team".to_string(), + }, + ]; + + let found_user = owners + .iter() + .any(|o| o.kind == "user" && o.login.eq_ignore_ascii_case("dtolnay")); + assert!(found_user); + + let found_team = owners.iter().any(|o| { + o.kind == "team" + && extract_team_org(&o.login) + .is_some_and(|org| "serde-rs".eq_ignore_ascii_case(org)) + }); + assert!(found_team); + } + + #[test] + fn owner_not_found_in_list() { + let owners = [CratesIoOwner { + login: "someone-else".to_string(), + kind: "user".to_string(), + }]; + + let found = owners + .iter() + .any(|o| o.kind == "user" && o.login.eq_ignore_ascii_case("myuser")); + assert!(!found); + } + + #[test] + fn parse_owners_response() { + let json = r#"{"users":[{"id":3618,"login":"dtolnay","kind":"user","url":"https://github.com/dtolnay","name":"David Tolnay"},{"id":8138,"login":"github:serde-rs:publish","kind":"team","url":"https://github.com/serde-rs"}]}"#; + let resp: CratesIoOwnersResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.users.len(), 2); + assert_eq!(resp.users[0].login, "dtolnay"); + assert_eq!(resp.users[0].kind, "user"); + assert_eq!(resp.users[1].login, "github:serde-rs:publish"); + assert_eq!(resp.users[1].kind, "team"); + } +} diff --git a/crates/auths-infra-http/src/namespace/mod.rs b/crates/auths-infra-http/src/namespace/mod.rs new file mode 100644 index 00000000..0c0ee66d --- /dev/null +++ b/crates/auths-infra-http/src/namespace/mod.rs @@ -0,0 +1,30 @@ +//! Namespace verification adapters for package ecosystem ownership proofs. + +mod cargo_verifier; +mod npm_verifier; +mod pypi_verifier; + +pub use cargo_verifier::CargoVerifier; +pub use npm_verifier::NpmVerifier; +pub use pypi_verifier::PypiVerifier; + +use auths_core::ports::namespace::VerificationToken; + +/// Generate a cryptographically random verification token. +/// +/// Lives in `auths-infra-http` (not `auths-core`) because token generation +/// depends on `rand` and `hex`, which are infrastructure concerns. +/// +/// Usage: +/// ```ignore +/// let token = generate_verification_token(); +/// assert!(token.as_str().starts_with("auths-verify-")); +/// ``` +pub fn generate_verification_token() -> VerificationToken { + use rand::Rng; + let bytes: [u8; 8] = rand::rngs::OsRng.r#gen(); + let raw = format!("auths-verify-{}", hex::encode(bytes)); + // INVARIANT: prefix is correct and hex::encode always produces valid hex + #[allow(clippy::expect_used)] + VerificationToken::parse(&raw).expect("generated token is always valid") +} diff --git a/crates/auths-infra-http/src/namespace/npm_verifier.rs b/crates/auths-infra-http/src/namespace/npm_verifier.rs new file mode 100644 index 00000000..1c5e957c --- /dev/null +++ b/crates/auths-infra-http/src/namespace/npm_verifier.rs @@ -0,0 +1,325 @@ +//! npm registry namespace verification adapter. + +use async_trait::async_trait; +use chrono::{DateTime, Duration, Utc}; +use serde::Deserialize; +use url::Url; + +use auths_core::ports::namespace::{ + Ecosystem, NamespaceOwnershipProof, NamespaceVerifier, NamespaceVerifyError, PackageName, + PlatformContext, VerificationChallenge, VerificationMethod, +}; +use auths_verifier::CanonicalDid; + +use super::generate_verification_token; + +/// npm registry namespace ownership verifier. +/// +/// Verifies package ownership by checking the `maintainers` field in the +/// public package metadata endpoint. Falls back to matching the `repository.url` +/// against the user's verified GitHub claim. +/// +/// Usage: +/// ```ignore +/// let verifier = NpmVerifier::new(); +/// let challenge = verifier.initiate(&package, &did, &platform).await?; +/// let proof = verifier.verify(&package, &did, &platform, &challenge).await?; +/// ``` +pub struct NpmVerifier { + client: reqwest::Client, + base_url: Url, +} + +impl NpmVerifier { + /// Create a new verifier targeting the production npm registry. + pub fn new() -> Self { + Self { + client: crate::default_http_client(), + // INVARIANT: hardcoded valid URL + #[allow(clippy::expect_used)] + base_url: Url::parse("https://registry.npmjs.org").expect("valid URL"), + } + } + + /// Create a verifier with a custom base URL (for testing). + /// + /// Args: + /// * `base_url`: The base URL to use instead of `https://registry.npmjs.org`. + pub fn with_base_url(base_url: Url) -> Self { + Self { + client: crate::default_http_client(), + base_url, + } + } + + fn package_url(&self, package_name: &str) -> String { + let encoded = urlencoding::encode(package_name); + format!("{}/{}", self.base_url, encoded) + } + + async fn fetch_metadata( + &self, + package_name: &str, + ) -> Result { + let url = self.package_url(package_name); + let resp = self + .client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| NamespaceVerifyError::NetworkError { + message: e.to_string(), + })?; + + match resp.status().as_u16() { + 200 => {} + 404 => { + return Err(NamespaceVerifyError::PackageNotFound { + ecosystem: Ecosystem::Npm, + package_name: package_name.to_string(), + }); + } + 429 => { + return Err(NamespaceVerifyError::RateLimited { + ecosystem: Ecosystem::Npm, + }); + } + status => { + return Err(NamespaceVerifyError::NetworkError { + message: format!("npm registry returned HTTP {status}"), + }); + } + } + + resp.json() + .await + .map_err(|e| NamespaceVerifyError::NetworkError { + message: format!("failed to parse npm metadata: {e}"), + }) + } +} + +impl Default for NpmVerifier { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl NamespaceVerifier for NpmVerifier { + fn ecosystem(&self) -> Ecosystem { + Ecosystem::Npm + } + + async fn initiate( + &self, + now: DateTime, + package_name: &PackageName, + did: &CanonicalDid, + platform: &PlatformContext, + ) -> Result { + self.fetch_metadata(package_name.as_str()).await?; + + if platform.npm_username.is_none() && platform.github_username.is_none() { + return Err(NamespaceVerifyError::OwnershipNotConfirmed { + ecosystem: Ecosystem::Npm, + package_name: package_name.as_str().to_string(), + }); + } + + let token = generate_verification_token(); + let expires_at = now + Duration::hours(1); + + let identity_desc = platform + .npm_username + .as_deref() + .map(|u| format!("npm user '{u}'")) + .or_else(|| { + platform + .github_username + .as_deref() + .map(|u| format!("GitHub user '{u}'")) + }) + .unwrap_or_default(); + + Ok(VerificationChallenge { + ecosystem: Ecosystem::Npm, + package_name: package_name.clone(), + did: did.clone(), + token, + instructions: format!( + "Verify your identity ({identity_desc}) is listed as a maintainer \ + of npm package '{}'", + package_name.as_str() + ), + expires_at, + }) + } + + async fn verify( + &self, + now: DateTime, + package_name: &PackageName, + _did: &CanonicalDid, + platform: &PlatformContext, + _challenge: &VerificationChallenge, + ) -> Result { + let metadata = self.fetch_metadata(package_name.as_str()).await?; + + if let Some(npm_username) = platform.npm_username.as_deref() { + let is_maintainer = metadata + .maintainers + .iter() + .any(|m| m.name.eq_ignore_ascii_case(npm_username)); + + if is_maintainer { + let package_url = self.package_url(package_name.as_str()); + // INVARIANT: package_url is built from a valid base_url + #[allow(clippy::expect_used)] + let proof_url = Url::parse(&package_url).expect("package URL is valid"); + + return Ok(NamespaceOwnershipProof { + ecosystem: Ecosystem::Npm, + package_name: package_name.clone(), + proof_url, + method: VerificationMethod::ApiOwnership, + verified_at: now, + }); + } + } + + if let Some(github_username) = platform.github_username.as_deref() { + let github_owner = metadata + .repository + .as_ref() + .and_then(|r| extract_github_owner(&r.url)); + + if let Some(owner) = github_owner + && owner.eq_ignore_ascii_case(github_username) + { + let package_url = self.package_url(package_name.as_str()); + // INVARIANT: package_url is built from a valid base_url + #[allow(clippy::expect_used)] + let proof_url = Url::parse(&package_url).expect("package URL is valid"); + + return Ok(NamespaceOwnershipProof { + ecosystem: Ecosystem::Npm, + package_name: package_name.clone(), + proof_url, + method: VerificationMethod::ApiOwnership, + verified_at: now, + }); + } + } + + Err(NamespaceVerifyError::OwnershipNotConfirmed { + ecosystem: Ecosystem::Npm, + package_name: package_name.as_str().to_string(), + }) + } +} + +/// Extract the GitHub owner from a repository URL. +fn extract_github_owner(url: &str) -> Option { + let url = url.strip_prefix("git+").unwrap_or(url); + let url = url.strip_suffix(".git").unwrap_or(url); + let parsed = Url::parse(url).ok()?; + if parsed.host_str() != Some("github.com") { + return None; + } + let segments: Vec<_> = parsed.path_segments()?.collect(); + if segments.is_empty() || segments[0].is_empty() { + return None; + } + Some(segments[0].to_string()) +} + +#[derive(Debug, Deserialize)] +struct NpmPackageMetadata { + #[serde(default)] + maintainers: Vec, + repository: Option, +} + +#[derive(Debug, Deserialize)] +struct NpmMaintainer { + name: String, +} + +#[derive(Debug, Deserialize)] +struct NpmRepository { + url: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_github_owner_standard_url() { + assert_eq!( + extract_github_owner("https://github.com/expressjs/express"), + Some("expressjs".to_string()) + ); + } + + #[test] + fn extract_github_owner_git_plus_url() { + assert_eq!( + extract_github_owner("git+https://github.com/expressjs/express.git"), + Some("expressjs".to_string()) + ); + } + + #[test] + fn extract_github_owner_non_github() { + assert_eq!(extract_github_owner("https://gitlab.com/user/repo"), None); + } + + #[test] + fn extract_github_owner_empty_path() { + assert_eq!(extract_github_owner("https://github.com/"), None); + } + + #[test] + fn parse_npm_metadata_response() { + let json = r#"{ + "name": "express", + "maintainers": [ + { "name": "dougwilson", "email": "doug@somethingdoug.com" }, + { "name": "wesleytodd", "email": "wes@wesleytodd.com" } + ], + "repository": { + "type": "git", + "url": "git+https://github.com/expressjs/express.git" + } + }"#; + let meta: NpmPackageMetadata = serde_json::from_str(json).unwrap(); + assert_eq!(meta.maintainers.len(), 2); + assert_eq!(meta.maintainers[0].name, "dougwilson"); + assert!(meta.repository.is_some()); + } + + #[test] + fn parse_npm_metadata_empty_maintainers() { + let json = r#"{"name": "empty-pkg", "maintainers": []}"#; + let meta: NpmPackageMetadata = serde_json::from_str(json).unwrap(); + assert!(meta.maintainers.is_empty()); + assert!(meta.repository.is_none()); + } + + #[test] + fn parse_npm_metadata_no_maintainers_field() { + let json = r#"{"name": "bare-pkg"}"#; + let meta: NpmPackageMetadata = serde_json::from_str(json).unwrap(); + assert!(meta.maintainers.is_empty()); + } + + #[test] + fn scoped_package_url_encoding() { + let verifier = NpmVerifier::new(); + let url = verifier.package_url("@scope/package"); + assert!(url.contains("%40scope%2Fpackage")); + } +} diff --git a/crates/auths-infra-http/src/namespace/pypi_verifier.rs b/crates/auths-infra-http/src/namespace/pypi_verifier.rs new file mode 100644 index 00000000..7d7f352b --- /dev/null +++ b/crates/auths-infra-http/src/namespace/pypi_verifier.rs @@ -0,0 +1,428 @@ +//! PyPI namespace verification adapter. + +use async_trait::async_trait; +use chrono::{DateTime, Duration, Utc}; +use serde::Deserialize; +use url::Url; + +use auths_core::ports::namespace::{ + Ecosystem, NamespaceOwnershipProof, NamespaceVerifier, NamespaceVerifyError, PackageName, + PlatformContext, VerificationChallenge, VerificationMethod, +}; +use auths_verifier::CanonicalDid; + +use super::generate_verification_token; + +/// PyPI namespace ownership verifier. +/// +/// Primary method: cross-references the GitHub repository URL in package metadata +/// with the user's verified GitHub claim. Fallback: checks `ownership.roles` if +/// the user has a verified PyPI username. +/// +/// Usage: +/// ```ignore +/// let verifier = PypiVerifier::new(); +/// let challenge = verifier.initiate(&package, &did, &platform).await?; +/// let proof = verifier.verify(&package, &did, &platform, &challenge).await?; +/// ``` +pub struct PypiVerifier { + client: reqwest::Client, + base_url: Url, +} + +impl PypiVerifier { + /// Create a new verifier targeting the production PyPI API. + pub fn new() -> Self { + Self { + client: crate::default_http_client(), + // INVARIANT: hardcoded valid URL + #[allow(clippy::expect_used)] + base_url: Url::parse("https://pypi.org").expect("valid URL"), + } + } + + /// Create a verifier with a custom base URL (for testing). + /// + /// Args: + /// * `base_url`: The base URL to use instead of `https://pypi.org`. + pub fn with_base_url(base_url: Url) -> Self { + Self { + client: crate::default_http_client(), + base_url, + } + } + + fn package_url(&self, package_name: &str) -> String { + let normalized = normalize_pypi_name(package_name); + format!("{}/pypi/{}/json", self.base_url, normalized) + } + + async fn fetch_metadata( + &self, + package_name: &str, + ) -> Result { + let url = self.package_url(package_name); + let resp = + self.client + .get(&url) + .send() + .await + .map_err(|e| NamespaceVerifyError::NetworkError { + message: e.to_string(), + })?; + + match resp.status().as_u16() { + 200 => {} + 404 => { + return Err(NamespaceVerifyError::PackageNotFound { + ecosystem: Ecosystem::Pypi, + package_name: package_name.to_string(), + }); + } + 429 => { + return Err(NamespaceVerifyError::RateLimited { + ecosystem: Ecosystem::Pypi, + }); + } + status => { + return Err(NamespaceVerifyError::NetworkError { + message: format!("PyPI returned HTTP {status}"), + }); + } + } + + resp.json() + .await + .map_err(|e| NamespaceVerifyError::NetworkError { + message: format!("failed to parse PyPI response: {e}"), + }) + } +} + +impl Default for PypiVerifier { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl NamespaceVerifier for PypiVerifier { + fn ecosystem(&self) -> Ecosystem { + Ecosystem::Pypi + } + + async fn initiate( + &self, + now: DateTime, + package_name: &PackageName, + did: &CanonicalDid, + platform: &PlatformContext, + ) -> Result { + // Check package exists — fail fast on 404 + let response = self.fetch_metadata(package_name.as_str()).await?; + + let token = generate_verification_token(); + let expires_at = now + Duration::hours(1); + + // Preview what verification paths are available + let github_owner = extract_github_owner_from_pypi(&response.info); + let has_roles = response + .ownership + .as_ref() + .is_some_and(|o| !o.roles.is_empty()); + + let instructions = match ( + platform.github_username.as_deref(), + &github_owner, + has_roles, + ) { + (Some(gh), Some(owner), _) => format!( + "Checking GitHub account ({gh}) against repo owner ({owner}) for '{}'", + package_name.as_str() + ), + (Some(_), None, true) => format!( + "No GitHub repo link found for '{}'. Will check PyPI ownership roles. \ + If that fails, you'll be asked for your PyPI username.", + package_name.as_str() + ), + (_, _, true) => format!( + "Will check PyPI ownership roles for '{}'. \ + You may be asked for your PyPI username.", + package_name.as_str() + ), + _ => format!( + "Verifying ownership of '{}'. You may be asked for your PyPI username.", + package_name.as_str() + ), + }; + + Ok(VerificationChallenge { + ecosystem: Ecosystem::Pypi, + package_name: package_name.clone(), + did: did.clone(), + token, + instructions, + expires_at, + }) + } + + async fn verify( + &self, + now: DateTime, + package_name: &PackageName, + _did: &CanonicalDid, + platform: &PlatformContext, + _challenge: &VerificationChallenge, + ) -> Result { + let response = self.fetch_metadata(package_name.as_str()).await?; + + if let Some(github_username) = platform.github_username.as_deref() { + let github_owner = extract_github_owner_from_pypi(&response.info); + if let Some(owner) = github_owner + && owner.eq_ignore_ascii_case(github_username) + { + let package_url = self.package_url(package_name.as_str()); + // INVARIANT: package_url is built from a valid base_url + #[allow(clippy::expect_used)] + let proof_url = Url::parse(&package_url).expect("package URL is valid"); + + return Ok(NamespaceOwnershipProof { + ecosystem: Ecosystem::Pypi, + package_name: package_name.clone(), + proof_url, + method: VerificationMethod::ApiOwnership, + verified_at: now, + }); + } + } + + if let Some(pypi_username) = platform.pypi_username.as_deref() + && let Some(ownership) = &response.ownership + { + let is_owner = ownership.roles.iter().any(|r| { + (r.role == "Owner" || r.role == "Maintainer") + && r.user.eq_ignore_ascii_case(pypi_username) + }); + + if is_owner { + let package_url = self.package_url(package_name.as_str()); + #[allow(clippy::expect_used)] + let proof_url = Url::parse(&package_url).expect("package URL is valid"); + + return Ok(NamespaceOwnershipProof { + ecosystem: Ecosystem::Pypi, + package_name: package_name.clone(), + proof_url, + method: VerificationMethod::ApiOwnership, + verified_at: now, + }); + } + } + + Err(NamespaceVerifyError::OwnershipNotConfirmed { + ecosystem: Ecosystem::Pypi, + package_name: package_name.as_str().to_string(), + }) + } +} + +/// Normalize a PyPI package name: lowercase, replace underscores and dots with hyphens. +fn normalize_pypi_name(name: &str) -> String { + name.to_lowercase().replace(['_', '.'], "-") +} + +/// Extract GitHub owner from PyPI package info's project_urls or home_page. +fn extract_github_owner_from_pypi(info: &PypiInfo) -> Option { + let github_keys = [ + "Source", + "Repository", + "Source Code", + "GitHub", + "Homepage", + "Code", + ]; + + if let Some(project_urls) = &info.project_urls { + for key in &github_keys { + if let Some(url) = project_urls.get(*key) + && let Some(owner) = extract_github_owner(url) + { + return Some(owner); + } + } + } + + if let Some(home_page) = &info.home_page + && let Some(owner) = extract_github_owner(home_page) + { + return Some(owner); + } + + None +} + +/// Extract the GitHub owner from a URL. +fn extract_github_owner(url: &str) -> Option { + let url = url.strip_prefix("git+").unwrap_or(url); + let url = url.strip_suffix(".git").unwrap_or(url); + let parsed = Url::parse(url).ok()?; + if parsed.host_str() != Some("github.com") { + return None; + } + let segments: Vec<_> = parsed.path_segments()?.collect(); + if segments.is_empty() || segments[0].is_empty() { + return None; + } + Some(segments[0].to_string()) +} + +#[derive(Debug, Deserialize)] +struct PypiResponse { + info: PypiInfo, + ownership: Option, +} + +#[derive(Debug, Deserialize)] +struct PypiInfo { + project_urls: Option>, + home_page: Option, +} + +#[derive(Debug, Deserialize)] +struct PypiOwnership { + #[serde(default)] + roles: Vec, +} + +#[derive(Debug, Deserialize)] +struct PypiRole { + role: String, + user: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_pypi_name_hyphens_underscores_dots() { + assert_eq!(normalize_pypi_name("My_Package"), "my-package"); + assert_eq!(normalize_pypi_name("some.thing"), "some-thing"); + assert_eq!(normalize_pypi_name("REQUESTS"), "requests"); + assert_eq!(normalize_pypi_name("my-package"), "my-package"); + assert_eq!(normalize_pypi_name("Mixed_Case.Dots"), "mixed-case-dots"); + } + + #[test] + fn extract_github_owner_from_standard_url() { + assert_eq!( + extract_github_owner("https://github.com/psf/requests"), + Some("psf".to_string()) + ); + } + + #[test] + fn extract_github_owner_from_git_plus_url() { + assert_eq!( + extract_github_owner("git+https://github.com/psf/requests.git"), + Some("psf".to_string()) + ); + } + + #[test] + fn extract_github_owner_non_github() { + assert_eq!( + extract_github_owner("https://bitbucket.org/user/repo"), + None + ); + } + + #[test] + fn extract_owner_from_pypi_project_urls() { + let info = PypiInfo { + project_urls: Some( + [( + "Source".to_string(), + "https://github.com/psf/requests".to_string(), + )] + .into_iter() + .collect(), + ), + home_page: None, + }; + assert_eq!( + extract_github_owner_from_pypi(&info), + Some("psf".to_string()) + ); + } + + #[test] + fn extract_owner_from_pypi_home_page_fallback() { + let info = PypiInfo { + project_urls: Some( + [( + "Documentation".to_string(), + "https://docs.example.com".to_string(), + )] + .into_iter() + .collect(), + ), + home_page: Some("https://github.com/owner/repo".to_string()), + }; + assert_eq!( + extract_github_owner_from_pypi(&info), + Some("owner".to_string()) + ); + } + + #[test] + fn extract_owner_from_pypi_no_github_url() { + let info = PypiInfo { + project_urls: Some( + [( + "Documentation".to_string(), + "https://docs.example.com".to_string(), + )] + .into_iter() + .collect(), + ), + home_page: Some("https://example.com".to_string()), + }; + assert_eq!(extract_github_owner_from_pypi(&info), None); + } + + #[test] + fn parse_pypi_response_with_ownership() { + let json = r#"{ + "info": { + "name": "requests", + "home_page": "https://requests.readthedocs.io", + "project_urls": { + "Source": "https://github.com/psf/requests" + } + }, + "ownership": { + "roles": [ + { "role": "Owner", "user": "Lukasa" }, + { "role": "Maintainer", "user": "nateprewitt" } + ] + } + }"#; + let resp: PypiResponse = serde_json::from_str(json).unwrap(); + assert!(resp.ownership.is_some()); + assert_eq!(resp.ownership.as_ref().unwrap().roles.len(), 2); + } + + #[test] + fn parse_pypi_response_without_ownership() { + let json = r#"{ + "info": { + "name": "some-pkg", + "project_urls": null, + "home_page": null + } + }"#; + let resp: PypiResponse = serde_json::from_str(json).unwrap(); + assert!(resp.ownership.is_none()); + } +} diff --git a/crates/auths-sdk/Cargo.toml b/crates/auths-sdk/Cargo.toml index c9733b9e..5c742fd4 100644 --- a/crates/auths-sdk/Cargo.toml +++ b/crates/auths-sdk/Cargo.toml @@ -11,8 +11,10 @@ keywords = ["sdk", "identity", "did", "cryptography", "attestation"] categories = ["cryptography", "authentication"] [dependencies] +async-trait = "0.1" auths-core.workspace = true auths-id.workspace = true +auths-infra-http.workspace = true auths-telemetry.workspace = true auths-policy.workspace = true auths-crypto.workspace = true @@ -30,6 +32,7 @@ hex = "0.4" html-escape = "0.2" ssh-key = "0.6" tempfile = "3" +url = { version = "2", features = ["serde"] } zeroize = "1.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"], optional = true } auths-pairing-daemon = { workspace = true, optional = true } diff --git a/crates/auths-sdk/src/lib.rs b/crates/auths-sdk/src/lib.rs index 1e75a735..64465cd4 100644 --- a/crates/auths-sdk/src/lib.rs +++ b/crates/auths-sdk/src/lib.rs @@ -28,6 +28,8 @@ pub mod device; pub mod error; /// Key import and management operations. pub mod keys; +/// Namespace verifier adapter registry mapping ecosystems to implementations. +pub mod namespace_registry; /// Device pairing orchestration over ephemeral ECDH sessions. pub mod pairing; /// Platform identity claim creation and verification. diff --git a/crates/auths-sdk/src/namespace_registry.rs b/crates/auths-sdk/src/namespace_registry.rs new file mode 100644 index 00000000..58c24702 --- /dev/null +++ b/crates/auths-sdk/src/namespace_registry.rs @@ -0,0 +1,111 @@ +//! Namespace verifier adapter registry. +//! +//! Maps [`Ecosystem`] variants to their corresponding [`NamespaceVerifier`] +//! implementations. The SDK uses this to dispatch verification requests +//! to the correct adapter. + +use std::collections::HashMap; +use std::sync::Arc; + +use auths_core::ports::namespace::{Ecosystem, NamespaceVerifier, NamespaceVerifyError}; +use auths_infra_http::namespace::{CargoVerifier, NpmVerifier, PypiVerifier}; + +/// Registry mapping ecosystems to their verification adapters. +/// +/// Usage: +/// ```ignore +/// let registry = NamespaceVerifierRegistry::with_defaults(); +/// let verifier = registry.require(Ecosystem::Cargo)?; +/// ``` +pub struct NamespaceVerifierRegistry { + verifiers: HashMap>, +} + +impl NamespaceVerifierRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + verifiers: HashMap::new(), + } + } + + /// Register a verifier adapter. Keyed by `verifier.ecosystem()`. + /// + /// Args: + /// * `verifier`: The adapter to register. + pub fn register(&mut self, verifier: Arc) { + self.verifiers.insert(verifier.ecosystem(), verifier); + } + + /// Look up a verifier for the given ecosystem. + /// + /// Args: + /// * `ecosystem`: The ecosystem to look up. + pub fn get(&self, ecosystem: Ecosystem) -> Option<&Arc> { + self.verifiers.get(&ecosystem) + } + + /// Look up a verifier, returning an error if the ecosystem is not registered. + /// + /// Args: + /// * `ecosystem`: The ecosystem to look up. + pub fn require( + &self, + ecosystem: Ecosystem, + ) -> Result<&Arc, NamespaceVerifyError> { + self.verifiers + .get(&ecosystem) + .ok_or_else(|| NamespaceVerifyError::UnsupportedEcosystem { + ecosystem: ecosystem.as_str().to_string(), + }) + } + + /// Create a registry pre-populated with all built-in adapters. + pub fn with_defaults() -> Self { + let mut registry = Self::new(); + registry.register(Arc::new(CargoVerifier::new())); + registry.register(Arc::new(NpmVerifier::new())); + registry.register(Arc::new(PypiVerifier::new())); + registry + } +} + +impl Default for NamespaceVerifierRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_require_missing_ecosystem() { + let registry = NamespaceVerifierRegistry::new(); + let result = registry.require(Ecosystem::Cargo); + assert!(result.is_err()); + let err = result.err().unwrap(); + assert!(matches!( + err, + NamespaceVerifyError::UnsupportedEcosystem { .. } + )); + } + + #[test] + fn registry_with_defaults_has_cargo_npm_pypi() { + let registry = NamespaceVerifierRegistry::with_defaults(); + assert!(registry.get(Ecosystem::Cargo).is_some()); + assert!(registry.get(Ecosystem::Npm).is_some()); + assert!(registry.get(Ecosystem::Pypi).is_some()); + assert!(registry.get(Ecosystem::Docker).is_none()); + assert!(registry.get(Ecosystem::Go).is_none()); + } + + #[test] + fn registry_require_registered_ecosystem() { + let registry = NamespaceVerifierRegistry::with_defaults(); + let verifier = registry.require(Ecosystem::Cargo).unwrap(); + assert_eq!(verifier.ecosystem(), Ecosystem::Cargo); + } +} diff --git a/crates/auths-sdk/src/testing/fakes/mod.rs b/crates/auths-sdk/src/testing/fakes/mod.rs index 546134f5..84cf55d7 100644 --- a/crates/auths-sdk/src/testing/fakes/mod.rs +++ b/crates/auths-sdk/src/testing/fakes/mod.rs @@ -4,6 +4,7 @@ mod artifact; mod diagnostics; mod git; mod git_config; +mod namespace; mod signer; pub use agent::FakeAgentProvider; @@ -12,4 +13,5 @@ pub use artifact::FakeArtifactSource; pub use diagnostics::{FakeCryptoDiagnosticProvider, FakeGitDiagnosticProvider}; pub use git::FakeGitLogProvider; pub use git_config::{FakeGitConfigProvider, GitConfigSetCall}; +pub use namespace::FakeNamespaceVerifier; pub use signer::FakeSecureSigner; diff --git a/crates/auths-sdk/src/testing/fakes/namespace.rs b/crates/auths-sdk/src/testing/fakes/namespace.rs new file mode 100644 index 00000000..735c7d34 --- /dev/null +++ b/crates/auths-sdk/src/testing/fakes/namespace.rs @@ -0,0 +1,99 @@ +//! Fake namespace verifier for testing SDK workflows. + +use async_trait::async_trait; +use chrono::{DateTime, Duration, Utc}; +use url::Url; + +use auths_core::ports::namespace::{ + Ecosystem, NamespaceOwnershipProof, NamespaceVerifier, NamespaceVerifyError, PackageName, + PlatformContext, VerificationChallenge, VerificationMethod, VerificationToken, +}; +use auths_verifier::CanonicalDid; + +/// Configurable fake verifier for testing namespace verification workflows. +pub struct FakeNamespaceVerifier { + /// The ecosystem this fake handles. + pub ecosystem: Ecosystem, + /// Whether `verify()` should succeed. + pub should_verify: bool, + /// Instructions text returned by `initiate()`. + pub challenge_instructions: String, +} + +impl FakeNamespaceVerifier { + /// Create a fake that always succeeds verification. + pub fn succeeding(ecosystem: Ecosystem) -> Self { + Self { + ecosystem, + should_verify: true, + challenge_instructions: "Test: complete the verification".to_string(), + } + } + + /// Create a fake that always fails verification. + pub fn failing(ecosystem: Ecosystem) -> Self { + Self { + ecosystem, + should_verify: false, + challenge_instructions: "Test: this will fail".to_string(), + } + } +} + +#[async_trait] +impl NamespaceVerifier for FakeNamespaceVerifier { + fn ecosystem(&self) -> Ecosystem { + self.ecosystem + } + + async fn initiate( + &self, + now: DateTime, + package_name: &PackageName, + did: &CanonicalDid, + _platform: &PlatformContext, + ) -> Result { + // INVARIANT: test token is always valid + #[allow(clippy::expect_used)] + let token = + VerificationToken::parse("auths-verify-deadbeef01234567").expect("test token is valid"); + + Ok(VerificationChallenge { + ecosystem: self.ecosystem, + package_name: package_name.clone(), + did: did.clone(), + token, + instructions: self.challenge_instructions.clone(), + expires_at: now + Duration::hours(1), + }) + } + + async fn verify( + &self, + now: DateTime, + package_name: &PackageName, + _did: &CanonicalDid, + _platform: &PlatformContext, + _challenge: &VerificationChallenge, + ) -> Result { + if self.should_verify { + // INVARIANT: hardcoded test URL is valid + #[allow(clippy::expect_used)] + let proof_url = + Url::parse("https://test.example.com/proof").expect("test URL is valid"); + + Ok(NamespaceOwnershipProof { + ecosystem: self.ecosystem, + package_name: package_name.clone(), + proof_url, + method: VerificationMethod::ApiOwnership, + verified_at: now, + }) + } else { + Err(NamespaceVerifyError::OwnershipNotConfirmed { + ecosystem: self.ecosystem, + package_name: package_name.as_str().to_string(), + }) + } + } +} diff --git a/crates/auths-sdk/src/workflows/namespace.rs b/crates/auths-sdk/src/workflows/namespace.rs index bdaaa9d5..6aa58803 100644 --- a/crates/auths-sdk/src/workflows/namespace.rs +++ b/crates/auths-sdk/src/workflows/namespace.rs @@ -4,6 +4,12 @@ //! canonicalize and sign them, and return the signed payload ready for //! submission to a registry server at `/v1/log/entries`. +use chrono::{DateTime, Utc}; + +use auths_core::ports::namespace::{ + Ecosystem, NamespaceOwnershipProof, NamespaceVerifier, NamespaceVerifyError, PackageName, + PlatformContext, VerificationChallenge, +}; use auths_core::signing::{PassphraseProvider, SecureSigner}; use auths_core::storage::keychain::KeyAlias; use auths_transparency::entry::{EntryBody, EntryContent, EntryType}; @@ -63,30 +69,10 @@ pub enum NamespaceError { /// Serialization or canonicalization failed. #[error("serialization error: {0}")] SerializationError(String), -} -/// Command to claim a namespace in a package ecosystem. -/// -/// Args: -/// * `ecosystem`: Package ecosystem identifier (e.g. "npm", "crates.io"). -/// * `package_name`: Package name to claim within the ecosystem. -/// * `registry_url`: Base URL of the registry server. -/// -/// Usage: -/// ```ignore -/// let cmd = ClaimNamespaceCommand { -/// ecosystem: "npm".into(), -/// package_name: "my-package".into(), -/// registry_url: "https://registry.example.com".into(), -/// }; -/// ``` -pub struct ClaimNamespaceCommand { - /// Package ecosystem identifier (e.g. "npm", "crates.io"). - pub ecosystem: String, - /// Package name to claim within the ecosystem. - pub package_name: String, - /// Base URL of the registry server. - pub registry_url: String, + /// Namespace verification failed (wraps port-level error). + #[error("verification failed: {0}")] + VerificationFailed(#[from] NamespaceVerifyError), } /// Command to delegate namespace authority to another identity. @@ -253,50 +239,6 @@ fn build_and_sign_entry( }) } -/// Build and sign a `NamespaceClaim` entry. -/// -/// Creates an `EntryContent` with a `NamespaceClaim` body, canonicalizes -/// it, and signs it with the caller's key. Returns a [`SignedEntry`] -/// ready for submission to `POST /v1/log/entries`. -/// -/// Args: -/// * `cmd`: The claim command with ecosystem, package name, and registry URL. -/// * `actor_did`: The DID of the claiming identity. -/// * `signer`: Signing backend for creating the cryptographic signature. -/// * `passphrase_provider`: Provider for obtaining key decryption passphrases. -/// * `signer_alias`: Keychain alias of the signing key. -/// -/// Usage: -/// ```ignore -/// let signed = sign_namespace_claim(cmd, &actor_did, &signer, provider, &alias)?; -/// // POST signed.to_request_body() to registry -/// ``` -pub fn sign_namespace_claim( - cmd: &ClaimNamespaceCommand, - actor_did: &IdentityDID, - signer: &dyn SecureSigner, - passphrase_provider: &dyn PassphraseProvider, - signer_alias: &KeyAlias, -) -> Result { - validate_ecosystem(&cmd.ecosystem)?; - validate_package_name(&cmd.package_name)?; - - #[allow(clippy::disallowed_methods)] - // INVARIANT: actor_did is an IdentityDID from storage, always valid - let canonical_actor = CanonicalDid::new_unchecked(actor_did.as_str()); - - let content = EntryContent { - entry_type: EntryType::NamespaceClaim, - body: EntryBody::NamespaceClaim { - ecosystem: cmd.ecosystem.clone(), - package_name: cmd.package_name.clone(), - }, - actor_did: canonical_actor, - }; - - build_and_sign_entry(&content, signer, passphrase_provider, signer_alias) -} - /// Build and sign a `NamespaceDelegate` entry. /// /// Creates an `EntryContent` with a `NamespaceDelegate` body, canonicalizes @@ -461,3 +403,125 @@ pub fn parse_lookup_response( delegates, } } + +/// An in-progress namespace verification session. +/// +/// Returned by [`initiate_namespace_claim`]. The CLI displays the challenge +/// instructions, waits for user confirmation, then calls [`complete`](Self::complete). +/// +/// Usage: +/// ```ignore +/// let session = initiate_namespace_claim(&verifier, eco, pkg, did, platform).await?; +/// println!("{}", session.challenge.instructions); +/// // wait for user... +/// let result = session.complete(&verifier, &signer, &passphrase, &alias).await?; +/// ``` +pub struct NamespaceVerificationSession { + /// The verification challenge issued by the adapter. + pub challenge: VerificationChallenge, + /// The ecosystem being verified. + pub ecosystem: Ecosystem, + /// The package being claimed. + pub package_name: PackageName, + /// The DID claiming ownership. + pub controller_did: CanonicalDid, + /// Platform identity context for cross-referencing. + pub platform: PlatformContext, +} + +impl NamespaceVerificationSession { + /// Complete the verification after the user has performed the challenge. + /// + /// Borrows `self` so the caller can retry on transient failures. + /// Calls `verifier.verify()`, then signs the namespace claim entry + /// with the proof attached. + /// + /// Args: + /// * `now`: Current time (injected at presentation boundary). + /// * `verifier`: The namespace verifier adapter. + /// * `signer`: Signing backend for creating the cryptographic signature. + /// * `passphrase_provider`: Provider for obtaining key decryption passphrases. + /// * `signer_alias`: Keychain alias of the signing key. + pub async fn complete_ref( + &self, + now: DateTime, + verifier: &dyn NamespaceVerifier, + signer: &dyn SecureSigner, + passphrase_provider: &dyn PassphraseProvider, + signer_alias: &KeyAlias, + ) -> Result { + let proof = verifier + .verify( + now, + &self.package_name, + &self.controller_did, + &self.platform, + &self.challenge, + ) + .await?; + + let content = EntryContent { + entry_type: EntryType::NamespaceClaim, + body: EntryBody::NamespaceClaim { + ecosystem: self.ecosystem.as_str().to_string(), + package_name: self.package_name.as_str().to_string(), + proof_url: proof.proof_url.to_string(), + verification_method: format!("{:?}", proof.method), + }, + actor_did: self.controller_did.clone(), + }; + + let signed = build_and_sign_entry(&content, signer, passphrase_provider, signer_alias)?; + + Ok(VerifiedClaimResult { + signed_entry: signed, + proof, + }) + } +} + +/// Result of a successful verified namespace claim. +pub struct VerifiedClaimResult { + /// The signed entry ready for submission to the registry. + pub signed_entry: SignedEntry, + /// The ownership proof from the verifier adapter. + pub proof: NamespaceOwnershipProof, +} + +/// Phase 1: Initiate namespace verification. +/// +/// Returns a [`NamespaceVerificationSession`] that the CLI can display +/// (challenge instructions), then complete after user action. +/// +/// Args: +/// * `now`: Current time (injected at presentation boundary). +/// * `verifier`: The namespace verifier adapter for the target ecosystem. +/// * `ecosystem`: The ecosystem being claimed. +/// * `package_name`: The package to claim. +/// * `controller_did`: The DID making the claim. +/// * `platform`: Verified platform identity context. +/// +/// Usage: +/// ```ignore +/// let session = initiate_namespace_claim(now, &verifier, eco, pkg, did, ctx).await?; +/// ``` +pub async fn initiate_namespace_claim( + now: DateTime, + verifier: &dyn NamespaceVerifier, + ecosystem: Ecosystem, + package_name: PackageName, + controller_did: CanonicalDid, + platform: PlatformContext, +) -> Result { + let challenge = verifier + .initiate(now, &package_name, &controller_did, &platform) + .await?; + + Ok(NamespaceVerificationSession { + challenge, + ecosystem, + package_name, + controller_did, + platform, + }) +} diff --git a/crates/auths-transparency/src/entry.rs b/crates/auths-transparency/src/entry.rs index 740374cf..421d67ac 100644 --- a/crates/auths-transparency/src/entry.rs +++ b/crates/auths-transparency/src/entry.rs @@ -92,6 +92,8 @@ pub enum EntryBody { NamespaceClaim { ecosystem: String, package_name: String, + proof_url: String, + verification_method: String, }, NamespaceDelegate { ecosystem: String, @@ -236,4 +238,19 @@ mod tests { let back: Entry = serde_json::from_str(&json).unwrap(); assert_eq!(entry.sequence, back.sequence); } + + #[test] + fn namespace_claim_with_proof_roundtrips() { + let body = EntryBody::NamespaceClaim { + ecosystem: "npm".to_string(), + package_name: "left-pad".to_string(), + proof_url: "https://registry.npmjs.org/left-pad".to_string(), + verification_method: "ApiOwnership".to_string(), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("proof_url")); + assert!(json.contains("verification_method")); + let back: EntryBody = serde_json::from_str(&json).unwrap(); + assert_eq!(body, back); + } } diff --git a/crates/auths-transparency/src/verify.rs b/crates/auths-transparency/src/verify.rs index 3970fec6..32065800 100644 --- a/crates/auths-transparency/src/verify.rs +++ b/crates/auths-transparency/src/verify.rs @@ -325,6 +325,7 @@ fn extract_namespace_from_entry(body: &EntryBody) -> Option<(&str, &str)> { EntryBody::NamespaceClaim { ecosystem, package_name, + .. } | EntryBody::NamespaceDelegate { ecosystem, @@ -435,6 +436,7 @@ fn verify_delegation_chain(bundle: &OfflineBundle) -> DelegationStatus { if let EntryBody::NamespaceClaim { ecosystem: claim_ecosystem, package_name: claim_package, + .. } = &ns_link.entry.content.body { if org_add_member.is_some() { diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index 15426233..9a59bfdf 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -161,6 +161,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml", + "url", "uuid", "x25519-dalek", "zeroize", @@ -229,6 +230,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "auths-infra-http" +version = "0.0.1-rc.9" +dependencies = [ + "async-trait", + "auths-core", + "auths-verifier", + "chrono", + "futures-util", + "hex", + "rand 0.8.5", + "reqwest 0.13.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "url", + "urlencoding", +] + [[package]] name = "auths-pairing-daemon" version = "0.0.1-rc.9" @@ -295,7 +317,7 @@ dependencies = [ "hex", "json-canon", "pyo3", - "reqwest", + "reqwest 0.12.28", "ring", "serde", "serde_json", @@ -310,9 +332,11 @@ dependencies = [ name = "auths-sdk" version = "0.0.1-rc.9" dependencies = [ + "async-trait", "auths-core", "auths-crypto", "auths-id", + "auths-infra-http", "auths-policy", "auths-telemetry", "auths-transparency", @@ -329,6 +353,7 @@ dependencies = [ "ssh-key", "tempfile", "thiserror 2.0.18", + "url", "zeroize", ] @@ -649,6 +674,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -730,6 +761,16 @@ dependencies = [ "cc", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1027,6 +1068,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1122,6 +1172,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1522,9 +1587,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1743,6 +1810,28 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "jobserver" version = "0.1.34" @@ -2038,6 +2127,23 @@ dependencies = [ "data-encoding-macro", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2155,12 +2261,50 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2493,6 +2637,7 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -2768,6 +2913,47 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -2878,6 +3064,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.9" @@ -2908,6 +3121,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -3108,6 +3330,17 @@ dependencies = [ "raunch", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3328,6 +3561,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3449,6 +3703,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3459,6 +3723,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3639,6 +3917,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3698,6 +3994,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-width" version = "0.1.8" @@ -3755,6 +4063,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3901,6 +4219,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -3926,6 +4253,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3973,6 +4309,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3991,6 +4338,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4027,6 +4383,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4060,6 +4431,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4072,6 +4449,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4084,6 +4467,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4108,6 +4497,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4120,6 +4515,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4132,6 +4533,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4144,6 +4551,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6"