diff --git a/.gitignore b/.gitignore index 7cd509b..060148d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ Cargo.lock # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ /config.toml +/key.pem diff --git a/Cargo.toml b/Cargo.toml index 30b5827..7ac8754 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ thiserror = "2.0.12" actix-multipart = "0.7.2" lettre = { version = "0.11.16", features = ["tokio1", "tokio1-native-tls"] } chrono = { version = "0.4.41", features = ["serde"] } +ed25519-dalek = { version = "2.1.1", features = ["pem", "pkcs8", "rand_core"] } [dependencies.tokio] version = "1.45" diff --git a/entrypoint.sh b/entrypoint.sh index 38ba890..98729db 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -65,4 +65,4 @@ rotate_log "/gorb/logs/backend.log" # Give the DB time to start up before connecting sleep 5 -/usr/bin/gorb-backend --config /gorb/config/config.toml 2>&1 | tee /gorb/logs/backend.log +/usr/bin/gorb-backend --config /gorb/config/config.toml --private-key /gorb/config/federation-privkey.pem 2>&1 | tee /gorb/logs/backend.log diff --git a/migrations/2025-06-03-123458_federated_instances/down.sql b/migrations/2025-06-03-123458_federated_instances/down.sql new file mode 100644 index 0000000..b9ca2c0 --- /dev/null +++ b/migrations/2025-06-03-123458_federated_instances/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +DROP TABLE federated_users; +DROP TABLE instances; diff --git a/migrations/2025-06-03-123458_federated_instances/up.sql b/migrations/2025-06-03-123458_federated_instances/up.sql new file mode 100644 index 0000000..4113798 --- /dev/null +++ b/migrations/2025-06-03-123458_federated_instances/up.sql @@ -0,0 +1,10 @@ +-- Your SQL goes here +CREATE TABLE instances ( + instance_url VARCHAR(8000) PRIMARY KEY NOT NULL, + public_key VARCHAR(500) UNIQUE NOT NULL +); + +CREATE TABLE federated_users ( + uuid UUID PRIMARY KEY NOT NULL, + instance_url VARCHAR(8000) NOT NULL REFERENCES instances(instance_url) +); diff --git a/src/api/v1/federation/mod.rs b/src/api/v1/federation/mod.rs new file mode 100644 index 0000000..16901d4 --- /dev/null +++ b/src/api/v1/federation/mod.rs @@ -0,0 +1,44 @@ +use actix_web::{post, web, HttpRequest, Scope}; + +use crate::{objects::Signature, Data}; + +mod pubkey; + +pub fn web() -> Scope { + web::scope("/federation") + .service(pubkey::get) + .service(post) +} + +#[post("")] +pub async fn post( + req: HttpRequest, + channel_info: web::Json<>, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let signature = Signature::from_signature_header(headers)?; + + let guild_uuid = path.into_inner().0; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; + + Member::check_membership(&mut conn, uuid, guild_uuid).await?; + + // FIXME: Logic to check permissions, should probably be done in utils.rs + + let channel = Channel::new( + data.clone(), + guild_uuid, + channel_info.name.clone(), + channel_info.description.clone(), + ) + .await?; + + Ok(HttpResponse::Ok().json(channel)) +} diff --git a/src/api/v1/federation/pubkey.rs b/src/api/v1/federation/pubkey.rs new file mode 100644 index 0000000..3ce9f8d --- /dev/null +++ b/src/api/v1/federation/pubkey.rs @@ -0,0 +1,29 @@ +//! `/api/v1/users/{uuid}` Specific user endpoints + +use actix_web::{HttpResponse, get, web}; +use ed25519_dalek::pkcs8::{spki::der::pem::LineEnding, EncodePublicKey}; + +use crate::{ + Data, + error::Error, +}; + +/// `GET /api/v1/users/{uuid}` Returns user with the given UUID +/// +/// requires auth: yes +/// +/// requires relation: yes +/// +/// ### Response Example +/// ``` +/// "" +/// ``` +/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps +#[get("/pubkey")] +pub async fn get( + data: web::Data, +) -> Result { + let pubkey = data.signing_key.verifying_key().to_public_key_pem(LineEnding::LF)?; + + Ok(HttpResponse::Ok().body(pubkey)) +} diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index 6c2df0b..9422fe4 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -9,6 +9,7 @@ mod invites; mod me; mod stats; mod users; +mod federation; pub fn web() -> Scope { web::scope("/v1") @@ -19,4 +20,5 @@ pub fn web() -> Scope { .service(guilds::web()) .service(invites::web()) .service(me::web()) + .service(federation::web()) } diff --git a/src/error.rs b/src/error.rs index 1b1bfba..860400c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,6 +12,7 @@ use bunny_api_tokio::error::Error as BunnyError; use deadpool::managed::{BuildError, PoolError}; use diesel::{ConnectionError, result::Error as DieselError}; use diesel_async::pooled_connection::PoolError as DieselPoolError; +use ed25519_dalek::pkcs8::{self, spki}; use lettre::{ address::AddressError, error::Error as EmailError, transport::smtp::Error as SmtpError, }; @@ -63,6 +64,10 @@ pub enum Error { SmtpError(#[from] SmtpError), #[error(transparent)] SmtpAddressError(#[from] AddressError), + #[error(transparent)] + Pkcs8Error(#[from] pkcs8::Error), + #[error(transparent)] + SpkiError(#[from] spki::Error), #[error("{0}")] PasswordHashError(String), #[error("{0}")] diff --git a/src/main.rs b/src/main.rs index 540a237..71fa846 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,14 @@ use actix_cors::Cors; use actix_web::{App, HttpServer, web}; -use argon2::Argon2; +use argon2::{password_hash::rand_core::OsRng, Argon2}; use clap::Parser; use diesel_async::pooled_connection::AsyncDieselConnectionManager; use diesel_async::pooled_connection::deadpool::Pool; +use ed25519_dalek::{pkcs8::{spki::der::pem::LineEnding, DecodePrivateKey, EncodePrivateKey}, SigningKey}; use error::Error; use objects::MailClient; use simple_logger::SimpleLogger; +use tokio::{fs::{read_to_string, File}, io::AsyncWriteExt}; use std::time::SystemTime; mod config; use config::{Config, ConfigBuilder}; @@ -28,6 +30,8 @@ pub mod utils; struct Args { #[arg(short, long, default_value_t = String::from("/etc/gorb/config.toml"))] config: String, + #[arg(short, long, default_value_t = String::from("/etc/gorb/privkey.pem"))] + private_key: String, } #[derive(Clone)] @@ -42,6 +46,7 @@ pub struct Data { pub start_time: SystemTime, pub bunny_cdn: bunny_api_tokio::Client, pub mail_client: MailClient, + pub signing_key: SigningKey, } #[tokio::main] @@ -98,6 +103,18 @@ async fn main() -> Result<(), Error> { .await? .unwrap(); + let signing_key; + + if let Ok(content) = read_to_string(&args.private_key).await { + signing_key = SigningKey::from_pkcs8_pem(&content)?; + } else { + let mut csprng = OsRng; + signing_key = tokio::task::spawn_blocking(move || SigningKey::generate(&mut csprng)).await?; + + let mut file = File::create(args.private_key).await?; + file.write_all(signing_key.to_pkcs8_pem(LineEnding::LF)?.as_bytes()).await?; + } + /* **Stored for later possible use** @@ -124,6 +141,7 @@ async fn main() -> Result<(), Error> { start_time: SystemTime::now(), bunny_cdn, mail_client, + signing_key, }; HttpServer::new(move || { diff --git a/src/objects/mod.rs b/src/objects/mod.rs index 7b45957..8731ed5 100644 --- a/src/objects/mod.rs +++ b/src/objects/mod.rs @@ -17,6 +17,7 @@ mod message; mod password_reset_token; mod role; mod user; +mod signature; pub use channel::Channel; pub use email_token::EmailToken; @@ -28,6 +29,7 @@ pub use message::Message; pub use password_reset_token::PasswordResetToken; pub use role::Role; pub use user::User; +pub use signature::Signature; use crate::error::Error; diff --git a/src/objects/signature.rs b/src/objects/signature.rs new file mode 100644 index 0000000..a333906 --- /dev/null +++ b/src/objects/signature.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; + +use actix_web::http::header::HeaderMap; +use url::Url; + +use crate::error::Error; + +pub struct Signature { + pub url: Url, + pub signature: String, +} + +impl Signature { + pub fn from_signature_header(headers: &HeaderMap) -> Result { + let signature_header = headers.get(actix_web::http::header::HeaderName::from_static("signature")); + + if signature_header.is_none() { + return Err(Error::Unauthorized( + "No signature header provided".to_string(), + )); + } + + let signature_raw = signature_header.unwrap().to_str()?; + + let key_values = signature_raw.split_whitespace(); + + let mut hash_map = HashMap::new(); + + let results: Result, Error> = key_values.map(|kv| { + let mut kv_split = kv.split('='); + let key = kv_split.next().unwrap().to_string(); + let value = kv_split.next().ok_or(Error::BadRequest(format!(r#"Expected key="value", found {}"#, key)))?.trim_matches('"').to_string(); + + hash_map.insert(key, value); + + Ok::<(), Error>(()) + }).collect(); + + results?; + + let key_id = hash_map.get("keyId"); + let algorithm = hash_map.get("algorithm"); + let signature = hash_map.get("signature"); + + if key_id.is_none() { + return Err(Error::BadRequest("No keyId was provided".to_string())) + } + + + if algorithm.is_none() { + return Err(Error::BadRequest("No key algorithm was provided".to_string())) + } + + if signature.is_none() { + return Err(Error::BadRequest("No signature was provided".to_string())) + } + + let key_id = key_id.unwrap(); + let algorithm = algorithm.unwrap(); + let signature = signature.unwrap(); + + if algorithm != "ed25519" { + return Err(Error::BadRequest(format!("Unsupported signature {}, please use ed25519", algorithm))) + } + + Ok(Signature { url: key_id.parse()?, signature: signature.clone() }) + } +} diff --git a/src/schema.rs b/src/schema.rs index aaef9c1..7422f48 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -31,6 +31,15 @@ diesel::table! { } } +diesel::table! { + federated_users (uuid) { + uuid -> Uuid, + email_verified -> Bool, + #[max_length = 8000] + instance_url -> Varchar, + } +} + diesel::table! { guild_members (uuid) { uuid -> Uuid, @@ -61,6 +70,15 @@ diesel::table! { } } +diesel::table! { + instances (instance_url) { + #[max_length = 8000] + instance_url -> Varchar, + #[max_length = 500] + public_key -> Varchar, + } +} + diesel::table! { invites (id) { #[max_length = 32] @@ -137,6 +155,7 @@ diesel::joinable!(access_tokens -> refresh_tokens (refresh_token)); diesel::joinable!(access_tokens -> users (uuid)); diesel::joinable!(channel_permissions -> channels (channel_uuid)); diesel::joinable!(channels -> guilds (guild_uuid)); +diesel::joinable!(federated_users -> instances (instance_url)); diesel::joinable!(guild_members -> guilds (guild_uuid)); diesel::joinable!(guild_members -> users (user_uuid)); diesel::joinable!(guilds -> users (owner_uuid)); @@ -153,9 +172,11 @@ diesel::allow_tables_to_appear_in_same_query!( access_tokens, channel_permissions, channels, + federated_users, guild_members, guilds, instance_permissions, + instances, invites, messages, refresh_tokens, diff --git a/src/utils.rs b/src/utils.rs index 3172cec..b3de7ad 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,4 @@ -use std::sync::LazyLock; +use std::{collections::HashMap, sync::LazyLock}; use actix_web::{ cookie::{Cookie, SameSite, time::Duration},