add request verification extractor

This commit is contained in:
sam 2024-01-18 16:34:40 +01:00
parent 7a694623e5
commit 1e53661b0a
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
18 changed files with 482 additions and 32 deletions

View file

@ -14,7 +14,7 @@ create table users (
instance_id text not null references identity_instances (id) on delete cascade,
remote_user_id text not null,
username text not null,
avatar text -- URL, not hash, as this is a remote file
);
@ -46,6 +46,14 @@ create table messages (
channel_id text not null references channels (id) on delete cascade,
author_id text not null,
updated_at timestamptz not null default now(),
content text not null
);
create table instance (
id integer not null primary key default 1,
public_key text not null,
private_key text not null,
constraint singleton check (id = 1)
);

8
chat/src/app_state.rs Normal file
View file

@ -0,0 +1,8 @@
use sqlx::{Pool, Postgres};
use crate::config::Config;
pub struct AppState {
pub pool: Pool<Postgres>,
pub config: Config,
}

56
chat/src/config.rs Normal file
View file

@ -0,0 +1,56 @@
use eyre::Result;
use serde::Deserialize;
use std::path::Path;
use std::{env, fs};
use tracing::Level;
pub const CONFIG_FILE: &str = "config.chat.toml";
#[derive(Deserialize)]
pub struct Config {
pub database_url: String,
pub port: u16,
pub domain: String,
pub auto_migrate: Option<bool>,
pub log_level: Option<String>,
}
impl Config {
pub fn load() -> Result<Self> {
let cwd = env::current_dir()?;
let config_file = Path::join(cwd.as_path(), Path::new(CONFIG_FILE));
println!("config file: {}", config_file.display());
let s = fs::read_to_string(config_file)?;
let config = toml::from_str(s.as_str())?;
Ok(config)
}
pub fn tracing_level(&self) -> Option<Level> {
match self
.log_level
.as_deref()
.unwrap_or("INFO")
{
"TRACE" => Some(Level::TRACE),
"DEBUG" => Some(Level::DEBUG),
"INFO" => Some(Level::INFO),
"WARN" => Some(Level::WARN),
"ERROR" => Some(Level::ERROR),
_ => None
}
}
}
impl Default for Config {
fn default() -> Self {
Config {
database_url: env::var("DATABASE_URL").unwrap_or("".into()),
domain: "".into(),
port: 3000,
auto_migrate: None,
log_level: None,
}
}
}

50
chat/src/db/mod.rs Normal file
View file

@ -0,0 +1,50 @@
use eyre::{OptionExt, Result};
use rsa::pkcs1::{EncodeRsaPrivateKey, EncodeRsaPublicKey, LineEnding};
use rsa::{RsaPrivateKey, RsaPublicKey};
use sqlx::postgres::PgPoolOptions;
use sqlx::{Pool, Postgres};
use std::time::Duration;
pub async fn init(dsn: &str) -> Result<Pool<Postgres>> {
let pool = PgPoolOptions::new()
.acquire_timeout(Duration::from_secs(2)) // Fail fast and don't hang
.max_connections(100)
.connect(dsn)
.await?;
Ok(pool)
}
const PRIVATE_KEY_BITS: usize = 2048;
pub async fn init_instance(pool: &Pool<Postgres>) -> Result<()> {
let mut tx = pool.begin().await?;
// Check if we already have an instance configuration
let row = sqlx::query!("select exists(select * from instance)")
.fetch_one(&mut *tx)
.await?;
if row.exists.ok_or_eyre("exists was null")? {
return Ok(());
}
// Generate public/private key
let mut rng = rand::thread_rng();
let priv_key = RsaPrivateKey::new(&mut rng, PRIVATE_KEY_BITS)?;
let pub_key = RsaPublicKey::from(&priv_key);
let priv_key_string = priv_key.to_pkcs1_pem(LineEnding::LF)?;
let pub_key_string = pub_key.to_pkcs1_pem(LineEnding::LF)?;
sqlx::query!(
"insert into instance (public_key, private_key) values ($1, $2)",
pub_key_string,
priv_key_string.to_string(),
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}

102
chat/src/fed/mod.rs Normal file
View file

@ -0,0 +1,102 @@
use std::sync::Arc;
use axum::{
async_trait,
extract::FromRequestParts,
http::{
header::{CONTENT_LENGTH, DATE, HOST},
request::Parts,
},
Extension,
};
use foxchat::{
fed::{SERVER_HEADER, SIGNATURE_HEADER, USER_HEADER},
http::ApiError,
signature::{parse_date, verify_signature},
FoxError,
};
use tracing::error;
use crate::{app_state::AppState, model::identity_instance::IdentityInstance};
pub struct FoxRequestData {
pub instance: IdentityInstance,
pub user_id: Option<String>,
}
#[async_trait]
impl<S> FromRequestParts<S> for FoxRequestData
where
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let state: Extension<Arc<AppState>> = Extension::from_request_parts(parts, state)
.await
.expect("AppState was not added as an extension");
let domain = parts
.headers
.get(SERVER_HEADER)
.ok_or(FoxError::InvalidHeader)?
.to_str()?;
let instance = IdentityInstance::get(state.0, domain).await?;
let public_key = instance.parse_public_key()?;
let date = parse_date(
parts
.headers
.get(DATE)
.ok_or(FoxError::InvalidHeader)?
.to_str()?,
)?;
let signature = parts
.headers
.get(SIGNATURE_HEADER)
.ok_or(FoxError::MissingSignature)?
.to_str()?
.to_string();
let host = parts
.headers
.get(HOST)
.ok_or(FoxError::InvalidHeader)?
.to_str()?;
let content_length = if let Some(raw_length) = parts.headers.get(CONTENT_LENGTH) {
Some(raw_length.to_str()?.parse::<usize>()?)
} else {
None
};
let user_id = if let Some(raw_id) = parts.headers.get(USER_HEADER) {
Some(raw_id.to_str()?)
} else {
None
};
if let Err(e) = verify_signature(
&public_key,
signature,
date,
host,
parts.uri.path(),
content_length,
user_id,
) {
error!(
"Verifying signature from request for {} from {}: {}",
parts.uri.path(),
domain,
e
);
return Err(FoxError::InvalidSignature.into());
}
Ok(FoxRequestData {
instance,
user_id: user_id.map(|v| v.to_string()),
})
}
}

View file

@ -1,3 +1,65 @@
fn main() {
println!("Hello, world!");
mod config;
mod db;
mod fed;
mod app_state;
mod model;
use crate::config::Config;
use clap::{Parser, Subcommand};
use eyre::Result;
use tracing::info;
#[derive(Debug, Parser)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
Serve,
Migrate,
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let config = Config::load()?;
let args = Cli::parse();
tracing_subscriber::fmt()
.with_max_level(config.tracing_level().unwrap_or(tracing::Level::INFO))
.init();
match args.command.unwrap_or(Command::Serve) {
Command::Serve => main_web(config).await,
Command::Migrate => main_migrate(config).await,
}
}
async fn main_migrate(config: Config) -> Result<()> {
info!("Connecting to database");
let pool = db::init(&config.database_url).await?;
info!("Migrating database");
sqlx::migrate!().run(&pool).await?;
info!("Migrated database");
Ok(())
}
async fn main_web(config: Config) -> Result<()> {
info!("Connecting to database");
let pool = db::init(&config.database_url).await?;
if config.auto_migrate.unwrap_or(false) {
info!("Auto-migrate is enabled, migrating database");
sqlx::migrate!().run(&pool).await?;
info!("Migrated database");
}
info!("Initializing instance data");
db::init_instance(&pool).await?;
info!("Initialized instance data!");
Ok(())
}

View file

@ -0,0 +1,52 @@
use std::sync::Arc;
use eyre::{Result, Context};
use foxchat::{fed::request::is_valid_domain, FoxError};
use rsa::{RsaPublicKey, pkcs1::DecodeRsaPublicKey};
use serde::{Deserialize, Serialize};
use crate::app_state::AppState;
#[derive(Serialize)]
pub struct IdentityInstance {
pub id: String,
pub domain: String,
pub base_url: String,
pub public_key: String,
pub status: InstanceStatus,
pub reason: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, sqlx::Type)]
#[sqlx(type_name = "instance_status", rename_all = "lowercase")]
pub enum InstanceStatus {
Active,
Suspended,
}
impl IdentityInstance {
pub async fn get(state: Arc<AppState>, domain: &str) -> Result<Self> {
if !is_valid_domain(domain) {
return Err(FoxError::InvalidServer.into());
}
if let Some(instance) = sqlx::query_as!(
Self,
r#"select id, domain, base_url, public_key,
status as "status: InstanceStatus", reason
from identity_instances where domain = $1"#,
domain
)
.fetch_optional(&state.pool)
.await?
{
return Ok(instance);
}
return Err(FoxError::InvalidServer.into());
}
pub fn parse_public_key(&self) -> Result<RsaPublicKey> {
RsaPublicKey::from_pkcs1_pem(&self.public_key).wrap_err("parsing identity instance public key")
}
}

View file

@ -0,0 +1,27 @@
use eyre::Result;
use sqlx::{Pool, Postgres};
use rsa::{RsaPrivateKey, RsaPublicKey, pkcs1::{DecodeRsaPublicKey, DecodeRsaPrivateKey}};
#[derive(Debug, Clone)]
pub struct Instance {
pub public_key: RsaPublicKey,
pub private_key: RsaPrivateKey,
}
impl Instance {
/// Gets the instance's configuration.
/// This is a singleton row that is always present.
pub async fn get(pool: &Pool<Postgres>) -> Result<Self> {
let instance = sqlx::query!("SELECT * FROM instance WHERE id = 1")
.fetch_one(pool)
.await?;
let public_key = RsaPublicKey::from_pkcs1_pem(&instance.public_key)?;
let private_key = RsaPrivateKey::from_pkcs1_pem(&instance.private_key)?;
Ok(Self {
public_key,
private_key,
})
}
}

2
chat/src/model/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod instance;
pub mod identity_instance;