Skip to main content

auths_core/ports/
namespace.rs

1//! Namespace verification port traits and types for proof-of-ownership verification.
2
3use std::fmt;
4
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9use url::Url;
10
11use auths_verifier::CanonicalDid;
12
13/// Package ecosystem identifier for namespace claims.
14///
15/// Args:
16/// (no arguments — this is an enum definition)
17///
18/// Usage:
19/// ```ignore
20/// let eco = Ecosystem::parse("crates.io")?;
21/// assert_eq!(eco, Ecosystem::Cargo);
22/// assert_eq!(eco.as_str(), "cargo");
23/// ```
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum Ecosystem {
27    /// Node Package Manager (npmjs.com).
28    Npm,
29    /// Python Package Index (pypi.org).
30    Pypi,
31    /// Rust crate registry (crates.io).
32    Cargo,
33    /// Docker Hub container registry.
34    Docker,
35    /// Go module proxy (pkg.go.dev).
36    Go,
37    /// Maven Central (Java/JVM).
38    Maven,
39    /// NuGet (.NET).
40    Nuget,
41}
42
43impl Ecosystem {
44    /// Returns the canonical lowercase string identifier for this ecosystem.
45    ///
46    /// Usage:
47    /// ```ignore
48    /// assert_eq!(Ecosystem::Cargo.as_str(), "cargo");
49    /// ```
50    pub fn as_str(&self) -> &'static str {
51        match self {
52            Self::Npm => "npm",
53            Self::Pypi => "pypi",
54            Self::Cargo => "cargo",
55            Self::Docker => "docker",
56            Self::Go => "go",
57            Self::Maven => "maven",
58            Self::Nuget => "nuget",
59        }
60    }
61
62    /// Parse an ecosystem string, accepting canonical names and common aliases.
63    ///
64    /// Args:
65    /// * `s`: The ecosystem string to parse (case-insensitive).
66    ///
67    /// Usage:
68    /// ```ignore
69    /// assert_eq!(Ecosystem::parse("crates.io")?, Ecosystem::Cargo);
70    /// assert_eq!(Ecosystem::parse("NPM")?, Ecosystem::Npm);
71    /// ```
72    pub fn parse(s: &str) -> Result<Self, NamespaceVerifyError> {
73        match s.to_ascii_lowercase().as_str() {
74            "npm" | "npmjs" | "npmjs.com" => Ok(Self::Npm),
75            "pypi" | "pypi.org" => Ok(Self::Pypi),
76            "cargo" | "crates.io" | "crates" => Ok(Self::Cargo),
77            "docker" | "dockerhub" | "docker.io" => Ok(Self::Docker),
78            "go" | "golang" | "go.dev" | "pkg.go.dev" => Ok(Self::Go),
79            "maven" | "maven-central" | "mvn" => Ok(Self::Maven),
80            "nuget" | "nuget.org" => Ok(Self::Nuget),
81            _ => Err(NamespaceVerifyError::UnsupportedEcosystem {
82                ecosystem: s.to_string(),
83            }),
84        }
85    }
86}
87
88impl fmt::Display for Ecosystem {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.write_str(self.as_str())
91    }
92}
93
94/// Validated package name within an ecosystem.
95///
96/// Rejects empty strings, control characters, and path traversal patterns.
97///
98/// Usage:
99/// ```ignore
100/// let name = PackageName::parse("my-package")?;
101/// assert_eq!(name.as_str(), "my-package");
102/// ```
103#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[serde(transparent)]
105pub struct PackageName(String);
106
107impl PackageName {
108    /// Parse and validate a package name string.
109    ///
110    /// Args:
111    /// * `s`: The package name to validate.
112    ///
113    /// Usage:
114    /// ```ignore
115    /// let name = PackageName::parse("left-pad")?;
116    /// ```
117    pub fn parse(s: &str) -> Result<Self, NamespaceVerifyError> {
118        if s.is_empty() {
119            return Err(NamespaceVerifyError::InvalidPackageName {
120                name: s.to_string(),
121                reason: "package name cannot be empty".to_string(),
122            });
123        }
124
125        if s.chars().any(|c| c.is_control()) {
126            return Err(NamespaceVerifyError::InvalidPackageName {
127                name: s.to_string(),
128                reason: "package name contains control characters".to_string(),
129            });
130        }
131
132        if s.contains("..") || s.starts_with('/') || s.starts_with('\\') {
133            return Err(NamespaceVerifyError::InvalidPackageName {
134                name: s.to_string(),
135                reason: "package name contains path traversal".to_string(),
136            });
137        }
138
139        Ok(Self(s.to_string()))
140    }
141
142    /// Returns the package name as a string slice.
143    pub fn as_str(&self) -> &str {
144        &self.0
145    }
146}
147
148impl fmt::Display for PackageName {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        f.write_str(&self.0)
151    }
152}
153
154/// Verification token for namespace ownership challenges.
155///
156/// Tokens must have the `auths-verify-` prefix followed by a hex-encoded suffix.
157/// Token generation is an infrastructure concern — this type only validates and holds.
158///
159/// Usage:
160/// ```ignore
161/// let token = VerificationToken::parse("auths-verify-abc123def456")?;
162/// assert_eq!(token.as_str(), "auths-verify-abc123def456");
163/// ```
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(transparent)]
166pub struct VerificationToken(String);
167
168const TOKEN_PREFIX: &str = "auths-verify-";
169
170impl VerificationToken {
171    /// Parse and validate a verification token string.
172    ///
173    /// Args:
174    /// * `s`: The token string to validate. Must have `auths-verify-` prefix and hex suffix.
175    ///
176    /// Usage:
177    /// ```ignore
178    /// let token = VerificationToken::parse("auths-verify-deadbeef")?;
179    /// ```
180    pub fn parse(s: &str) -> Result<Self, NamespaceVerifyError> {
181        let suffix =
182            s.strip_prefix(TOKEN_PREFIX)
183                .ok_or_else(|| NamespaceVerifyError::InvalidToken {
184                    reason: format!("token must start with '{TOKEN_PREFIX}'"),
185                })?;
186
187        if suffix.is_empty() {
188            return Err(NamespaceVerifyError::InvalidToken {
189                reason: "token suffix cannot be empty".to_string(),
190            });
191        }
192
193        if !suffix.chars().all(|c| c.is_ascii_hexdigit()) {
194            return Err(NamespaceVerifyError::InvalidToken {
195                reason: "token suffix must be hex-encoded".to_string(),
196            });
197        }
198
199        Ok(Self(s.to_string()))
200    }
201
202    /// Returns the token as a string slice.
203    pub fn as_str(&self) -> &str {
204        &self.0
205    }
206}
207
208impl fmt::Display for VerificationToken {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        f.write_str(&self.0)
211    }
212}
213
214/// Method used to verify namespace ownership.
215///
216/// Usage:
217/// ```ignore
218/// let method = VerificationMethod::ApiOwnership;
219/// ```
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221pub enum VerificationMethod {
222    /// Verify by publishing a token in a release (e.g., PyPI project_urls).
223    PublishToken,
224    /// Verify via registry API ownership/collaborator endpoint.
225    ApiOwnership,
226    /// Verify via DNS TXT record (e.g., Go modules).
227    DnsTxt,
228}
229
230/// Proof of namespace ownership returned after successful verification.
231///
232/// Usage:
233/// ```ignore
234/// let proof = NamespaceOwnershipProof {
235///     ecosystem: Ecosystem::Npm,
236///     package_name: PackageName::parse("my-package")?,
237///     proof_url: "https://registry.npmjs.org/my-package".parse()?,
238///     method: VerificationMethod::ApiOwnership,
239///     verified_at: now,
240/// };
241/// ```
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct NamespaceOwnershipProof {
244    /// The ecosystem where ownership was verified.
245    pub ecosystem: Ecosystem,
246    /// The package name that was verified.
247    pub package_name: PackageName,
248    /// URL where the proof can be independently verified.
249    pub proof_url: Url,
250    /// The method used to verify ownership.
251    pub method: VerificationMethod,
252    /// When the verification was performed.
253    pub verified_at: DateTime<Utc>,
254}
255
256/// Challenge issued to a user to prove namespace ownership.
257///
258/// Usage:
259/// ```ignore
260/// let challenge = VerificationChallenge {
261///     ecosystem: Ecosystem::Cargo,
262///     package_name: PackageName::parse("my-crate")?,
263///     did: CanonicalDid::parse("did:keri:abc123")?,
264///     token: VerificationToken::parse("auths-verify-deadbeef")?,
265///     instructions: "Add this token to your crate owners".to_string(),
266///     expires_at: now + Duration::hours(1),
267/// };
268/// ```
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct VerificationChallenge {
271    /// The ecosystem for this challenge.
272    pub ecosystem: Ecosystem,
273    /// The package being verified.
274    pub package_name: PackageName,
275    /// The DID claiming ownership.
276    pub did: CanonicalDid,
277    /// The verification token to place in the registry.
278    pub token: VerificationToken,
279    /// Human-readable instructions for completing the challenge.
280    pub instructions: String,
281    /// When this challenge expires.
282    pub expires_at: DateTime<Utc>,
283}
284
285/// Verified platform identity context for cross-referencing during namespace verification.
286///
287/// SECURITY: This struct must ONLY be populated from server-verified platform claims
288/// (i.e., claims with `verified_at IS NOT NULL` in the registry). Never accept
289/// self-asserted usernames from CLI arguments — the CLI must fetch verified claims
290/// from the registry before building this context.
291///
292/// The verification chain is:
293/// 1. User runs `auths id claim github` → OAuth proves they control the GitHub account
294/// 2. Registry stores the verified claim with `verified_at`
295/// 3. User runs `auths namespace claim` → CLI fetches verified claims from registry
296/// 4. This context is built from those verified claims only
297/// 5. The namespace verifier cross-references against the ecosystem API (e.g., crates.io)
298///
299/// Usage:
300/// ```ignore
301/// // CORRECT: populated from registry-verified claims
302/// let ctx = fetch_verified_platform_context(&registry_url, &did).await?;
303///
304/// // WRONG: self-asserted from CLI args (vulnerable to spoofing)
305/// // let ctx = PlatformContext { github_username: Some(cli_arg), .. };
306/// ```
307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct PlatformContext {
309    /// GitHub username from a verified platform claim.
310    pub github_username: Option<String>,
311    /// npm username from a verified platform claim.
312    pub npm_username: Option<String>,
313    /// PyPI username from a verified platform claim.
314    pub pypi_username: Option<String>,
315}
316
317/// Errors from namespace verification operations.
318///
319/// Usage:
320/// ```ignore
321/// match result {
322///     Err(NamespaceVerifyError::UnsupportedEcosystem { .. }) => { /* unknown ecosystem */ }
323///     Err(NamespaceVerifyError::OwnershipNotConfirmed { .. }) => { /* user is not owner */ }
324///     Err(e) => return Err(e.into()),
325///     Ok(proof) => { /* proceed with proof */ }
326/// }
327/// ```
328#[derive(Debug, Error)]
329#[non_exhaustive]
330pub enum NamespaceVerifyError {
331    /// The requested ecosystem is not supported.
332    #[error("unsupported ecosystem: {ecosystem}")]
333    UnsupportedEcosystem {
334        /// The ecosystem string that was not recognized.
335        ecosystem: String,
336    },
337
338    /// The package was not found in the upstream registry.
339    #[error("package '{package_name}' not found in {ecosystem}")]
340    PackageNotFound {
341        /// The ecosystem where the lookup failed.
342        ecosystem: Ecosystem,
343        /// The package name that was not found.
344        package_name: String,
345    },
346
347    /// Ownership could not be confirmed via the upstream registry.
348    #[error("ownership of '{package_name}' on {ecosystem} not confirmed for the given identity")]
349    OwnershipNotConfirmed {
350        /// The ecosystem checked.
351        ecosystem: Ecosystem,
352        /// The package name checked.
353        package_name: String,
354    },
355
356    /// The verification challenge has expired.
357    #[error("verification challenge expired")]
358    ChallengeExpired,
359
360    /// The verification token is invalid.
361    #[error("invalid verification token: {reason}")]
362    InvalidToken {
363        /// Why the token is invalid.
364        reason: String,
365    },
366
367    /// The package name is invalid.
368    #[error("invalid package name '{name}': {reason}")]
369    InvalidPackageName {
370        /// The rejected package name.
371        name: String,
372        /// Why the name is invalid.
373        reason: String,
374    },
375
376    /// A network error occurred during verification.
377    #[error("verification network error: {message}")]
378    NetworkError {
379        /// Human-readable error detail.
380        message: String,
381    },
382
383    /// The upstream registry returned a rate limit response.
384    #[error("rate limited by {ecosystem} registry")]
385    RateLimited {
386        /// The ecosystem that rate-limited us.
387        ecosystem: Ecosystem,
388    },
389}
390
391impl auths_crypto::AuthsErrorInfo for NamespaceVerifyError {
392    fn error_code(&self) -> &'static str {
393        match self {
394            Self::UnsupportedEcosystem { .. } => "AUTHS-E4401",
395            Self::PackageNotFound { .. } => "AUTHS-E4402",
396            Self::OwnershipNotConfirmed { .. } => "AUTHS-E4403",
397            Self::ChallengeExpired => "AUTHS-E4404",
398            Self::InvalidToken { .. } => "AUTHS-E4405",
399            Self::InvalidPackageName { .. } => "AUTHS-E4406",
400            Self::NetworkError { .. } => "AUTHS-E4407",
401            Self::RateLimited { .. } => "AUTHS-E4408",
402        }
403    }
404
405    fn suggestion(&self) -> Option<&'static str> {
406        match self {
407            Self::UnsupportedEcosystem { .. } => {
408                Some("Supported ecosystems: npm, pypi, cargo, docker, go, maven, nuget")
409            }
410            Self::PackageNotFound { .. } => {
411                Some("Check the package name and ensure it exists on the registry")
412            }
413            Self::OwnershipNotConfirmed { .. } => {
414                Some("Ensure you are listed as an owner/collaborator on the upstream registry")
415            }
416            Self::ChallengeExpired => Some("Start a new verification challenge"),
417            Self::InvalidToken { .. } => {
418                Some("Tokens must start with 'auths-verify-' followed by a hex string")
419            }
420            Self::InvalidPackageName { .. } => Some(
421                "Package names cannot be empty, contain control characters, or use path traversal",
422            ),
423            Self::NetworkError { .. } => Some("Check your internet connection and try again"),
424            Self::RateLimited { .. } => Some("Wait a moment and retry the verification"),
425        }
426    }
427}
428
429/// Verifies ownership of a namespace (package) on an upstream registry.
430///
431/// Each ecosystem adapter implements this trait. The SDK stores adapters
432/// as `Arc<dyn NamespaceVerifier>` in a registry map keyed by [`Ecosystem`].
433///
434/// Usage:
435/// ```ignore
436/// let verifier: Arc<dyn NamespaceVerifier> = registry.get(&Ecosystem::Npm)?;
437/// let challenge = verifier.initiate(&package_name, &did, &platform_ctx).await?;
438/// // ... user completes challenge ...
439/// let proof = verifier.verify(&package_name, &did, &platform_ctx, &challenge).await?;
440/// ```
441#[async_trait]
442pub trait NamespaceVerifier: Send + Sync {
443    /// Returns the ecosystem this verifier handles.
444    fn ecosystem(&self) -> Ecosystem;
445
446    /// Initiate a verification challenge for the given package.
447    ///
448    /// Args:
449    /// * `now`: Current time (injected, never call `Utc::now()` directly).
450    /// * `package_name`: The package to verify ownership of.
451    /// * `did`: The caller's canonical DID.
452    /// * `platform`: Verified platform identity context for cross-referencing.
453    async fn initiate(
454        &self,
455        now: DateTime<Utc>,
456        package_name: &PackageName,
457        did: &CanonicalDid,
458        platform: &PlatformContext,
459    ) -> Result<VerificationChallenge, NamespaceVerifyError>;
460
461    /// Verify the challenge was completed and return ownership proof.
462    ///
463    /// Args:
464    /// * `now`: Current time (injected, never call `Utc::now()` directly).
465    /// * `package_name`: The package to verify ownership of.
466    /// * `did`: The caller's canonical DID.
467    /// * `platform`: Verified platform identity context for cross-referencing.
468    /// * `challenge`: The challenge previously returned by [`initiate`](Self::initiate).
469    async fn verify(
470        &self,
471        now: DateTime<Utc>,
472        package_name: &PackageName,
473        did: &CanonicalDid,
474        platform: &PlatformContext,
475        challenge: &VerificationChallenge,
476    ) -> Result<NamespaceOwnershipProof, NamespaceVerifyError>;
477}