diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..c016487 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,44 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use base64::Engine; +use rand::Rng; + +/// Generate a prefixed token: `{id}.{random_secret}`. +/// The id allows O(1) lookup; the secret is verified via argon2. +/// The `id` parameter is the entity UUID this token belongs to. +pub fn generate_token(id: &str) -> String { + let bytes: [u8; 32] = rand::thread_rng().gen(); + let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + format!("{id}.{secret}") +} + +/// Generate a non-prefixed secret (for registration secrets that don't need lookup). +pub fn generate_secret() -> String { + let bytes: [u8; 32] = rand::thread_rng().gen(); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// Hash a token (or its secret part) with argon2. +pub fn hash_token(token: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + Ok(argon2.hash_password(token.as_bytes(), &salt)?.to_string()) +} + +/// Verify a token against a stored argon2 hash. +pub fn verify_token(token: &str, hash: &str) -> bool { + let Ok(parsed_hash) = PasswordHash::new(hash) else { + return false; + }; + Argon2::default() + .verify_password(token.as_bytes(), &parsed_hash) + .is_ok() +} + +/// Split a prefixed token into (id, secret). +/// Returns None if the token is not in `id.secret` format. +pub fn split_token(token: &str) -> Option<(&str, &str)> { + token.split_once('.') +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..219000b --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,167 @@ +use dashmap::DashMap; +use sea_orm::*; +use std::sync::Arc; + +use crate::entity::{links, resources}; + +#[derive(Debug, Clone)] +pub struct CachedLink { + pub rel: String, + pub href: Option, + pub link_type: Option, + pub titles: Option, + pub properties: Option, + pub template: Option, +} + +#[derive(Debug, Clone)] +pub struct CachedResource { + pub subject: String, + pub aliases: Option>, + pub properties: Option, + pub links: Vec, +} + +#[derive(Debug, Clone)] +pub struct Cache { + inner: Arc>, +} + +impl Cache { + pub fn new() -> Self { + Self { + inner: Arc::new(DashMap::new()), + } + } + + pub fn get(&self, resource_uri: &str) -> Option { + self.inner.get(resource_uri).map(|r| r.value().clone()) + } + + pub fn set(&self, resource_uri: String, resource: CachedResource) { + self.inner.insert(resource_uri, resource); + } + + pub fn remove(&self, resource_uri: &str) { + self.inner.remove(resource_uri); + } + + /// Remove all cache entries for the given resource URIs. + /// Callers should query the DB for all resource URIs belonging to a domain + /// before deleting, then pass them here. This handles non-acct: URI schemes. + pub fn remove_many(&self, resource_uris: &[String]) { + for uri in resource_uris { + self.inner.remove(uri.as_str()); + } + } + + /// Load all non-expired resources and links from DB into cache. + pub async fn hydrate(&self, db: &DatabaseConnection) -> Result<(), DbErr> { + let now = chrono::Utc::now().naive_utc(); + + let all_resources = resources::Entity::find().all(db).await?; + + for resource in all_resources { + let resource_links = links::Entity::find() + .filter(links::Column::ResourceId.eq(&resource.id)) + .filter( + Condition::any() + .add(links::Column::ExpiresAt.is_null()) + .add(links::Column::ExpiresAt.gt(now)), + ) + .all(db) + .await?; + + if resource_links.is_empty() { + continue; + } + + let cached = CachedResource { + subject: resource.resource_uri.clone(), + aliases: resource + .aliases + .as_deref() + .and_then(|a| serde_json::from_str(a).ok()), + properties: resource + .properties + .as_deref() + .and_then(|p| serde_json::from_str(p).ok()), + links: resource_links + .into_iter() + .map(|l| CachedLink { + rel: l.rel, + href: l.href, + link_type: l.link_type, + titles: l.titles, + properties: l.properties, + template: l.template, + }) + .collect(), + }; + + self.set(resource.resource_uri, cached); + } + + Ok(()) + } + + /// Rebuild cache entry for a single resource from DB. + pub async fn refresh_resource( + &self, + db: &DatabaseConnection, + resource_uri: &str, + ) -> Result<(), DbErr> { + let now = chrono::Utc::now().naive_utc(); + + let resource = resources::Entity::find() + .filter(resources::Column::ResourceUri.eq(resource_uri)) + .one(db) + .await?; + + let Some(resource) = resource else { + self.remove(resource_uri); + return Ok(()); + }; + + let resource_links = links::Entity::find() + .filter(links::Column::ResourceId.eq(&resource.id)) + .filter( + Condition::any() + .add(links::Column::ExpiresAt.is_null()) + .add(links::Column::ExpiresAt.gt(now)), + ) + .all(db) + .await?; + + if resource_links.is_empty() { + self.remove(resource_uri); + return Ok(()); + } + + let cached = CachedResource { + subject: resource.resource_uri.clone(), + aliases: resource + .aliases + .as_deref() + .and_then(|a| serde_json::from_str(a).ok()), + properties: resource + .properties + .as_deref() + .and_then(|p| serde_json::from_str(p).ok()), + links: resource_links + .into_iter() + .map(|l| CachedLink { + rel: l.rel, + href: l.href, + link_type: l.link_type, + titles: l.titles, + properties: l.properties, + template: l.template, + }) + .collect(), + }; + + self.set(resource.resource_uri, cached); + Ok(()) + } +} diff --git a/src/challenge.rs b/src/challenge.rs new file mode 100644 index 0000000..7322f5b --- /dev/null +++ b/src/challenge.rs @@ -0,0 +1,25 @@ +use async_trait::async_trait; + +/// Trait for verifying domain ownership challenges (DNS-01 and HTTP-01). +#[async_trait] +pub trait ChallengeVerifier: Send + Sync + std::fmt::Debug { + async fn verify_dns01(&self, domain: &str, expected_token: &str) -> Result; + async fn verify_http01(&self, domain: &str, expected_token: &str) -> Result; +} + +/// Production implementation using real DNS and HTTP lookups. +#[derive(Debug)] +pub struct RealChallengeVerifier; + +#[async_trait] +impl ChallengeVerifier for RealChallengeVerifier { + async fn verify_dns01(&self, _domain: &str, _expected_token: &str) -> Result { + // TODO: implement with hickory-resolver in a later task + Ok(false) + } + + async fn verify_http01(&self, _domain: &str, _expected_token: &str) -> Result { + // TODO: implement with reqwest in a later task + Ok(false) + } +} diff --git a/src/entity/domains.rs b/src/entity/domains.rs new file mode 100644 index 0000000..2741e2e --- /dev/null +++ b/src/entity/domains.rs @@ -0,0 +1,47 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "domains")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + #[sea_orm(unique)] + pub domain: String, + pub owner_token_hash: String, + pub registration_secret: String, + pub challenge_type: String, + pub challenge_token: Option, + pub verified: bool, + pub created_at: chrono::NaiveDateTime, + pub verified_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::resources::Entity")] + Resources, + #[sea_orm(has_many = "super::service_tokens::Entity")] + ServiceTokens, + #[sea_orm(has_many = "super::links::Entity")] + Links, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Resources.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ServiceTokens.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Links.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/links.rs b/src/entity/links.rs new file mode 100644 index 0000000..0789c67 --- /dev/null +++ b/src/entity/links.rs @@ -0,0 +1,63 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "links")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + pub resource_id: String, + pub service_token_id: String, + pub domain_id: String, + pub rel: String, + pub href: Option, + #[sea_orm(column_name = "type")] + pub link_type: Option, + pub titles: Option, + pub properties: Option, + pub template: Option, + pub ttl_seconds: Option, + pub created_at: chrono::NaiveDateTime, + pub expires_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::resources::Entity", + from = "Column::ResourceId", + to = "super::resources::Column::Id" + )] + Resource, + #[sea_orm( + belongs_to = "super::service_tokens::Entity", + from = "Column::ServiceTokenId", + to = "super::service_tokens::Column::Id" + )] + ServiceToken, + #[sea_orm( + belongs_to = "super::domains::Entity", + from = "Column::DomainId", + to = "super::domains::Column::Id" + )] + Domain, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Resource.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ServiceToken.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Domain.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/mod.rs b/src/entity/mod.rs new file mode 100644 index 0000000..1a08c31 --- /dev/null +++ b/src/entity/mod.rs @@ -0,0 +1,4 @@ +pub mod domains; +pub mod links; +pub mod resources; +pub mod service_tokens; diff --git a/src/entity/resources.rs b/src/entity/resources.rs new file mode 100644 index 0000000..0975437 --- /dev/null +++ b/src/entity/resources.rs @@ -0,0 +1,41 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "resources")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + pub domain_id: String, + #[sea_orm(unique)] + pub resource_uri: String, + pub aliases: Option, + pub properties: Option, + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::domains::Entity", + from = "Column::DomainId", + to = "super::domains::Column::Id" + )] + Domain, + #[sea_orm(has_many = "super::links::Entity")] + Links, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Domain.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Links.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/service_tokens.rs b/src/entity/service_tokens.rs new file mode 100644 index 0000000..7340fed --- /dev/null +++ b/src/entity/service_tokens.rs @@ -0,0 +1,41 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "service_tokens")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + pub domain_id: String, + pub name: String, + pub token_hash: String, + pub allowed_rels: String, + pub resource_pattern: String, + pub created_at: chrono::NaiveDateTime, + pub revoked_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::domains::Entity", + from = "Column::DomainId", + to = "super::domains::Column::Id" + )] + Domain, + #[sea_orm(has_many = "super::links::Entity")] + Links, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Domain.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Links.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/lib.rs b/src/lib.rs index 7404805..2f1e909 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,7 @@ +pub mod auth; +pub mod cache; +pub mod challenge; pub mod config; +pub mod entity; pub mod error; +pub mod state; diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..da8de5f --- /dev/null +++ b/src/state.rs @@ -0,0 +1,14 @@ +use sea_orm::DatabaseConnection; +use std::sync::Arc; + +use crate::cache::Cache; +use crate::challenge::ChallengeVerifier; +use crate::config::Settings; + +#[derive(Clone)] +pub struct AppState { + pub db: DatabaseConnection, + pub cache: Cache, + pub settings: Arc, + pub challenge_verifier: Arc, +}