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(®istry_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}