auths-core 0.1.2

Core cryptography and keychain integration for Auths
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
//! 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<Self, NamespaceVerifyError> {
        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<Self, NamespaceVerifyError> {
        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<Self, NamespaceVerifyError> {
        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<Utc>,
}

/// 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<Utc>,
}

/// Verified platform identity context for cross-referencing during namespace verification.
///
/// SECURITY: This struct must ONLY be populated from server-verified platform claims
/// (i.e., claims with `verified_at IS NOT NULL` in the registry). Never accept
/// self-asserted usernames from CLI arguments — the CLI must fetch verified claims
/// from the registry before building this context.
///
/// The verification chain is:
/// 1. User runs `auths id claim github` → OAuth proves they control the GitHub account
/// 2. Registry stores the verified claim with `verified_at`
/// 3. User runs `auths namespace claim` → CLI fetches verified claims from registry
/// 4. This context is built from those verified claims only
/// 5. The namespace verifier cross-references against the ecosystem API (e.g., crates.io)
///
/// Usage:
/// ```ignore
/// // CORRECT: populated from registry-verified claims
/// let ctx = fetch_verified_platform_context(&registry_url, &did).await?;
///
/// // WRONG: self-asserted from CLI args (vulnerable to spoofing)
/// // let ctx = PlatformContext { github_username: Some(cli_arg), .. };
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlatformContext {
    /// GitHub username from a verified platform claim.
    pub github_username: Option<String>,
    /// npm username from a verified platform claim.
    pub npm_username: Option<String>,
    /// PyPI username from a verified platform claim.
    pub pypi_username: Option<String>,
}

/// 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-E3961",
            Self::PackageNotFound { .. } => "AUTHS-E3962",
            Self::OwnershipNotConfirmed { .. } => "AUTHS-E3963",
            Self::ChallengeExpired => "AUTHS-E3964",
            Self::InvalidToken { .. } => "AUTHS-E3965",
            Self::InvalidPackageName { .. } => "AUTHS-E3966",
            Self::NetworkError { .. } => "AUTHS-E3967",
            Self::RateLimited { .. } => "AUTHS-E3968",
        }
    }

    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<dyn NamespaceVerifier>` in a registry map keyed by [`Ecosystem`].
///
/// Usage:
/// ```ignore
/// let verifier: Arc<dyn NamespaceVerifier> = 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<Utc>,
        package_name: &PackageName,
        did: &CanonicalDid,
        platform: &PlatformContext,
    ) -> Result<VerificationChallenge, NamespaceVerifyError>;

    /// 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<Utc>,
        package_name: &PackageName,
        did: &CanonicalDid,
        platform: &PlatformContext,
        challenge: &VerificationChallenge,
    ) -> Result<NamespaceOwnershipProof, NamespaceVerifyError>;
}