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;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Ecosystem {
Npm,
Pypi,
Cargo,
Docker,
Go,
Maven,
Nuget,
}
impl Ecosystem {
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",
}
}
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())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PackageName(String);
impl PackageName {
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()))
}
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)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct VerificationToken(String);
const TOKEN_PREFIX: &str = "auths-verify-";
impl VerificationToken {
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()))
}
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)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VerificationMethod {
PublishToken,
ApiOwnership,
DnsTxt,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NamespaceOwnershipProof {
pub ecosystem: Ecosystem,
pub package_name: PackageName,
pub proof_url: Url,
pub method: VerificationMethod,
pub verified_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationChallenge {
pub ecosystem: Ecosystem,
pub package_name: PackageName,
pub did: CanonicalDid,
pub token: VerificationToken,
pub instructions: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlatformContext {
pub github_username: Option<String>,
pub npm_username: Option<String>,
pub pypi_username: Option<String>,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum NamespaceVerifyError {
#[error("unsupported ecosystem: {ecosystem}")]
UnsupportedEcosystem {
ecosystem: String,
},
#[error("package '{package_name}' not found in {ecosystem}")]
PackageNotFound {
ecosystem: Ecosystem,
package_name: String,
},
#[error("ownership of '{package_name}' on {ecosystem} not confirmed for the given identity")]
OwnershipNotConfirmed {
ecosystem: Ecosystem,
package_name: String,
},
#[error("verification challenge expired")]
ChallengeExpired,
#[error("invalid verification token: {reason}")]
InvalidToken {
reason: String,
},
#[error("invalid package name '{name}': {reason}")]
InvalidPackageName {
name: String,
reason: String,
},
#[error("verification network error: {message}")]
NetworkError {
message: String,
},
#[error("rate limited by {ecosystem} registry")]
RateLimited {
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"),
}
}
}
#[async_trait]
pub trait NamespaceVerifier: Send + Sync {
fn ecosystem(&self) -> Ecosystem;
async fn initiate(
&self,
now: DateTime<Utc>,
package_name: &PackageName,
did: &CanonicalDid,
platform: &PlatformContext,
) -> Result<VerificationChallenge, NamespaceVerifyError>;
async fn verify(
&self,
now: DateTime<Utc>,
package_name: &PackageName,
did: &CanonicalDid,
platform: &PlatformContext,
challenge: &VerificationChallenge,
) -> Result<NamespaceOwnershipProof, NamespaceVerifyError>;
}