From 7a694623e5fb70108225867007378c491cff00d5 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 17 Jan 2024 15:51:16 +0100 Subject: [PATCH] add request signatures and GET/POST requests --- Cargo.lock | 35 ++++++ config.identity.toml | 2 +- foxchat/Cargo.toml | 7 ++ foxchat/src/error/mod.rs | 8 +- foxchat/src/fed/mod.rs | 4 + foxchat/src/fed/request.rs | 100 ++++++++++++++++++ foxchat/src/fed/signature.rs | 78 ++++++++++++++ foxchat/src/http/response.rs | 21 +++- foxchat/src/lib.rs | 4 +- ...999becab8ab1c5f6da712db7f759f30f2bca8.json | 32 ++++++ ...e9303a3068b728142dc90bb66389069608e28.json | 62 +++++++++++ ...3092e5c603089672bceff3a4fa17dd624d933.json | 31 ++++++ ...1edcd04e1570c96869993b6b20d16ad71da3c.json | 62 +++++++++++ ...08d7c7ce717e42f970ea30fd7e828c8c9ef06.json | 20 ++++ ...6580047c45469fc9a867a4cc93a49343a5333.json | 65 ++++++++++++ ...a8a35d5dc84b0860ba22a8c105d457cb8b4e6.json | 15 +++ ...121074794c172310c88bdcdd8bef083df9431.json | 62 +++++++++++ ...8e91cbd34ef31391418828f4a0bb4e9e2a4ce.json | 15 +++ identity/Cargo.toml | 1 + identity/src/config.rs | 11 +- identity/src/db/account.rs | 10 +- identity/src/http/mod.rs | 1 + identity/src/http/node.rs | 19 +++- identity/src/model/chat_instance.rs | 55 +++++++++- 24 files changed, 690 insertions(+), 30 deletions(-) create mode 100644 foxchat/src/fed/mod.rs create mode 100644 foxchat/src/fed/request.rs create mode 100644 foxchat/src/fed/signature.rs create mode 100644 identity/.sqlx/query-29b5c01c2511f63be1ae213d8b1999becab8ab1c5f6da712db7f759f30f2bca8.json create mode 100644 identity/.sqlx/query-3402be9906900efd5c0e9f7ec10e9303a3068b728142dc90bb66389069608e28.json create mode 100644 identity/.sqlx/query-665e75d8304aab441acb50e78793092e5c603089672bceff3a4fa17dd624d933.json create mode 100644 identity/.sqlx/query-acb98db00095d510a139a8a4fd61edcd04e1570c96869993b6b20d16ad71da3c.json create mode 100644 identity/.sqlx/query-ad1116f3443ba2c3093c6c7605508d7c7ce717e42f970ea30fd7e828c8c9ef06.json create mode 100644 identity/.sqlx/query-bdf6cfaad2176b76f5092898e796580047c45469fc9a867a4cc93a49343a5333.json create mode 100644 identity/.sqlx/query-d125d3c97a59ac5c4ba04eb02d9a8a35d5dc84b0860ba22a8c105d457cb8b4e6.json create mode 100644 identity/.sqlx/query-dab570575b63e52a4bee944f139121074794c172310c88bdcdd8bef083df9431.json create mode 100644 identity/.sqlx/query-e8680ec00d42254b8fc8240636d8e91cbd34ef31391418828f4a0bb4e9e2a4ce.json diff --git a/Cargo.lock b/Cargo.lock index 8380dd5..867f7ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93b8a41dbe230ad5087cc721f8d41611de654542180586b315d9f4cf6b72bef" +dependencies = [ + "psl", + "psl-types", +] + [[package]] name = "addr2line" version = "0.21.0" @@ -399,7 +409,9 @@ checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-targets 0.48.5", ] @@ -729,8 +741,15 @@ dependencies = [ name = "foxchat" version = "0.1.0" dependencies = [ + "addr", "axum", + "base64", + "chrono", "eyre", + "once_cell", + "rand", + "reqwest", + "rsa", "serde", "serde_json", "sqlx", @@ -1115,6 +1134,7 @@ dependencies = [ "axum", "base64", "bcrypt", + "chrono", "clap", "color-eyre", "eyre", @@ -1614,6 +1634,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl" +version = "2.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383703acfc34f7a00724846c14dc5ea4407c59e5aedcbbb18a1c0c1a23fe5013" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "quote" version = "1.0.35" diff --git a/config.identity.toml b/config.identity.toml index d46cc32..f619e0f 100644 --- a/config.identity.toml +++ b/config.identity.toml @@ -1,4 +1,4 @@ database_url = "postgresql://foxchat:password@localhost/foxchat_ident_dev" port = 3000 log_level = "DEBUG" -insecure_requests = true +domain = "chat.foxchat.localhost" diff --git a/foxchat/Cargo.toml b/foxchat/Cargo.toml index 23943b5..ff4a8cb 100644 --- a/foxchat/Cargo.toml +++ b/foxchat/Cargo.toml @@ -6,8 +6,15 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +addr = "0.15.6" axum = "0.7.4" +base64 = "0.21.7" +chrono = "0.4.31" eyre = "0.6.11" +once_cell = "1.19.0" +rand = "0.8.5" +reqwest = "0.11.23" +rsa = { version = "0.9.6", features = ["sha2"] } serde = { version = "1.0.195", features = ["derive"] } serde_json = "1.0.111" sqlx = "0.7.3" diff --git a/foxchat/src/error/mod.rs b/foxchat/src/error/mod.rs index 3240f94..7a4482d 100644 --- a/foxchat/src/error/mod.rs +++ b/foxchat/src/error/mod.rs @@ -1,7 +1,13 @@ use thiserror::Error; #[derive(Error, Debug, Copy, Clone)] -pub enum QueryError { +pub enum FoxError { #[error("object not found")] NotFound, + #[error("date for signature out of range")] + SignatureDateOutOfRange(&'static str), + #[error("non-200 response to federation request")] + ResponseNotOk, + #[error("server is invalid")] + InvalidServer, } diff --git a/foxchat/src/fed/mod.rs b/foxchat/src/fed/mod.rs new file mode 100644 index 0000000..a2bba7b --- /dev/null +++ b/foxchat/src/fed/mod.rs @@ -0,0 +1,4 @@ +pub mod request; +pub mod signature; + +pub use request::{get, post}; diff --git a/foxchat/src/fed/request.rs b/foxchat/src/fed/request.rs new file mode 100644 index 0000000..d65e825 --- /dev/null +++ b/foxchat/src/fed/request.rs @@ -0,0 +1,100 @@ +use eyre::Result; +use once_cell::sync::Lazy; +use reqwest::{Response, StatusCode}; +use rsa::RsaPrivateKey; +use serde::{de::DeserializeOwned, Serialize}; + +use crate::{ + signature::{build_signature, format_date}, + FoxError, +}; + +static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); + +static CLIENT: Lazy = Lazy::new(|| { + reqwest::Client::builder() + .user_agent(APP_USER_AGENT) + .build() + .unwrap() +}); + +pub fn is_valid_domain(domain: &str) -> bool { + addr::parse_domain_name(domain).is_ok() +} + +pub async fn get( + private_key: &RsaPrivateKey, + self_domain: String, + host: String, + path: String, + user_id: Option, +) -> Result { + let (signature, date) = build_signature( + private_key, + host.clone(), + path.clone(), + None, + user_id.clone(), + ); + + let mut req = CLIENT + .get(format!("https://{host}{path}")) + .header("Date", format_date(date)) + .header("X-Foxchat-Signature", signature) + .header("X-Foxchat-Server", self_domain); + + req = if let Some(id) = user_id { + req.header("X-Foxchat-User", id) + } else { + req + }; + + let resp = req.send().await?; + handle_response(resp).await +} + +pub async fn post( + private_key: &RsaPrivateKey, + self_domain: String, + host: String, + path: String, + user_id: Option, + body: &T, +) -> Result { + let body = serde_json::to_string(body)?; + + let (signature, date) = build_signature( + private_key, + host.clone(), + path.clone(), + Some(body.len()), + user_id.clone(), + ); + + let mut req = CLIENT + .post(format!("https://{host}{path}")) + .header("Date", format_date(date)) + .header("X-Foxchat-Signature", signature) + .header("X-Foxchat-Server", self_domain) + .header("Content-Type", "application/json; charset=utf-8") + .header("Content-Length", body.len().to_string()) + .body(body); + + req = if let Some(id) = user_id { + req.header("X-Foxchat-User", id) + } else { + req + }; + + let resp = req.send().await?; + handle_response(resp).await +} + +async fn handle_response(resp: Response) -> Result { + if resp.status() != StatusCode::OK { + return Err(FoxError::ResponseNotOk.into()); + } + + let parsed = resp.json::().await?; + Ok(parsed) +} diff --git a/foxchat/src/fed/signature.rs b/foxchat/src/fed/signature.rs new file mode 100644 index 0000000..ebb6fab --- /dev/null +++ b/foxchat/src/fed/signature.rs @@ -0,0 +1,78 @@ +use base64::prelude::{Engine, BASE64_URL_SAFE}; +use chrono::{DateTime, Utc, Duration}; +use eyre::Result; +use rsa::pkcs1v15::{SigningKey, VerifyingKey, Signature}; +use rsa::sha2::Sha256; +use rsa::signature::{RandomizedSigner, SignatureEncoding, Verifier}; +use rsa::{RsaPrivateKey, RsaPublicKey}; + +use crate::FoxError; + +pub fn build_signature( + private_key: &RsaPrivateKey, + host: String, + request_path: String, + content_length: Option, + user_id: Option, +) -> (String, DateTime) { + let mut rng = rand::thread_rng(); + let signing_key = SigningKey::::new(private_key.clone()); + let time = Utc::now(); + + let plaintext = plaintext_string(time, host, request_path, content_length, user_id); + let signature = signing_key.sign_with_rng(&mut rng, plaintext.as_bytes()); + + let str = BASE64_URL_SAFE.encode(signature.to_bytes()); + + (str, time) +} + +fn plaintext_string( + time: DateTime, + host: String, + request_path: String, + content_length: Option, + user_id: Option, +) -> String { + let raw_time = format_date(time); + let raw_content_length = content_length + .map(|i| i.to_string()) + .unwrap_or("".to_owned()); + let raw_user_id = user_id.unwrap_or("".to_owned()); + + format!( + "{}:{}:{}:{}:{}", + raw_time, host, request_path, raw_content_length, raw_user_id + ) +} + +pub fn format_date(time: DateTime) -> String { + time.format("%a, %d %b %Y %T GMT").to_string() +} + +pub fn verify_signature( + public_key: &RsaPublicKey, + encoded_signature: String, + time: DateTime, // from Date header + host: String, // from Host header, verify that it's actually your host + request_path: String, // from router + content_length: Option, // from Content-Length header + user_id: Option, // from X-Foxchat-User header +) -> Result { + let verifying_key = VerifyingKey::::new(public_key.clone()); + + let now = Utc::now(); + if (now - Duration::minutes(1)) < time { + return Err(FoxError::SignatureDateOutOfRange("request was made too long ago").into()); + } else if (now + Duration::minutes(1)) > time { + return Err(FoxError::SignatureDateOutOfRange("request was made in the future").into()); + } + + let plaintext = plaintext_string(time, host, request_path, content_length, user_id); + + let hash = &BASE64_URL_SAFE.decode(encoded_signature)?; + let signature = Signature::try_from(hash.as_ref())?; + + verifying_key.verify(plaintext.as_bytes(), &signature)?; + Ok(true) +} diff --git a/foxchat/src/http/response.rs b/foxchat/src/http/response.rs index 3abd596..e5f9449 100644 --- a/foxchat/src/http/response.rs +++ b/foxchat/src/http/response.rs @@ -8,7 +8,7 @@ use serde::Serialize; use serde_json::json; use tracing::error; -use crate::QueryError; +use crate::FoxError; pub struct ApiError { status: StatusCode, @@ -35,6 +35,7 @@ impl IntoResponse for ApiError { pub enum ErrorCode { InternalServerError, ObjectNotFound, + InvalidServer, } impl From for ApiError { @@ -53,7 +54,7 @@ impl From for ApiError { impl From for ApiError { fn from(err: Report) -> Self { - match err.downcast_ref::() { + match err.downcast_ref::() { Some(e) => return (*e).into(), None => {} }; @@ -71,14 +72,24 @@ impl From for ApiError { } } -impl From for ApiError { - fn from(err: QueryError) -> Self { +impl From for ApiError { + fn from(err: FoxError) -> Self { match err { - QueryError::NotFound => ApiError { + FoxError::NotFound => ApiError { status: StatusCode::NOT_FOUND, code: ErrorCode::ObjectNotFound, message: "Object not found".into(), }, + FoxError::InvalidServer => ApiError { + status: StatusCode::BAD_REQUEST, + code: ErrorCode::InvalidServer, + message: "Invalid domain or server".into(), + }, + _ => ApiError { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: ErrorCode::InternalServerError, + message: "Internal server error".into(), + }, } } } diff --git a/foxchat/src/lib.rs b/foxchat/src/lib.rs index bfab54c..4d4a419 100644 --- a/foxchat/src/lib.rs +++ b/foxchat/src/lib.rs @@ -1,5 +1,7 @@ pub mod error; pub mod http; pub mod s2s; +pub mod fed; -pub use error::QueryError; +pub use error::FoxError; +pub use fed::signature; diff --git a/identity/.sqlx/query-29b5c01c2511f63be1ae213d8b1999becab8ab1c5f6da712db7f759f30f2bca8.json b/identity/.sqlx/query-29b5c01c2511f63be1ae213d8b1999becab8ab1c5f6da712db7f759f30f2bca8.json new file mode 100644 index 0000000..ce72f0b --- /dev/null +++ b/identity/.sqlx/query-29b5c01c2511f63be1ae213d8b1999becab8ab1c5f6da712db7f759f30f2bca8.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM instance WHERE id = 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "public_key", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "private_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "29b5c01c2511f63be1ae213d8b1999becab8ab1c5f6da712db7f759f30f2bca8" +} diff --git a/identity/.sqlx/query-3402be9906900efd5c0e9f7ec10e9303a3068b728142dc90bb66389069608e28.json b/identity/.sqlx/query-3402be9906900efd5c0e9f7ec10e9303a3068b728142dc90bb66389069608e28.json new file mode 100644 index 0000000..e9fe67d --- /dev/null +++ b/identity/.sqlx/query-3402be9906900efd5c0e9f7ec10e9303a3068b728142dc90bb66389069608e28.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n a.id, a.username, a.email, a.password, a.role as \"role: Role\", a.avatar\n from accounts a\n join tokens t on t.account_id = a.id\n where t.account_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "role: Role", + "type_info": { + "Custom": { + "name": "account_role", + "kind": { + "Enum": [ + "user", + "admin" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "avatar", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "3402be9906900efd5c0e9f7ec10e9303a3068b728142dc90bb66389069608e28" +} diff --git a/identity/.sqlx/query-665e75d8304aab441acb50e78793092e5c603089672bceff3a4fa17dd624d933.json b/identity/.sqlx/query-665e75d8304aab441acb50e78793092e5c603089672bceff3a4fa17dd624d933.json new file mode 100644 index 0000000..fdad3e5 --- /dev/null +++ b/identity/.sqlx/query-665e75d8304aab441acb50e78793092e5c603089672bceff3a4fa17dd624d933.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into accounts (id, username, email, password) values ($1, $2, $3, $4) returning id, username", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "665e75d8304aab441acb50e78793092e5c603089672bceff3a4fa17dd624d933" +} diff --git a/identity/.sqlx/query-acb98db00095d510a139a8a4fd61edcd04e1570c96869993b6b20d16ad71da3c.json b/identity/.sqlx/query-acb98db00095d510a139a8a4fd61edcd04e1570c96869993b6b20d16ad71da3c.json new file mode 100644 index 0000000..2b000ec --- /dev/null +++ b/identity/.sqlx/query-acb98db00095d510a139a8a4fd61edcd04e1570c96869993b6b20d16ad71da3c.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "select\n id, username, email, password, role as \"role: Role\", avatar\n from accounts where username = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "role: Role", + "type_info": { + "Custom": { + "name": "account_role", + "kind": { + "Enum": [ + "user", + "admin" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "avatar", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "acb98db00095d510a139a8a4fd61edcd04e1570c96869993b6b20d16ad71da3c" +} diff --git a/identity/.sqlx/query-ad1116f3443ba2c3093c6c7605508d7c7ce717e42f970ea30fd7e828c8c9ef06.json b/identity/.sqlx/query-ad1116f3443ba2c3093c6c7605508d7c7ce717e42f970ea30fd7e828c8c9ef06.json new file mode 100644 index 0000000..63bc809 --- /dev/null +++ b/identity/.sqlx/query-ad1116f3443ba2c3093c6c7605508d7c7ce717e42f970ea30fd7e828c8c9ef06.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "select exists(select * from instance)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "ad1116f3443ba2c3093c6c7605508d7c7ce717e42f970ea30fd7e828c8c9ef06" +} diff --git a/identity/.sqlx/query-bdf6cfaad2176b76f5092898e796580047c45469fc9a867a4cc93a49343a5333.json b/identity/.sqlx/query-bdf6cfaad2176b76f5092898e796580047c45469fc9a867a4cc93a49343a5333.json new file mode 100644 index 0000000..6d9fa09 --- /dev/null +++ b/identity/.sqlx/query-bdf6cfaad2176b76f5092898e796580047c45469fc9a867a4cc93a49343a5333.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into chat_instances\n (id, domain, base_url, public_key) values ($1, $2, $3, $4)\n returning id, domain, base_url, public_key,\n status as \"status: InstanceStatus\", reason", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "domain", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "base_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "public_key", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status: InstanceStatus", + "type_info": { + "Custom": { + "name": "instance_status", + "kind": { + "Enum": [ + "active", + "suspended" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "reason", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "bdf6cfaad2176b76f5092898e796580047c45469fc9a867a4cc93a49343a5333" +} diff --git a/identity/.sqlx/query-d125d3c97a59ac5c4ba04eb02d9a8a35d5dc84b0860ba22a8c105d457cb8b4e6.json b/identity/.sqlx/query-d125d3c97a59ac5c4ba04eb02d9a8a35d5dc84b0860ba22a8c105d457cb8b4e6.json new file mode 100644 index 0000000..3c55d17 --- /dev/null +++ b/identity/.sqlx/query-d125d3c97a59ac5c4ba04eb02d9a8a35d5dc84b0860ba22a8c105d457cb8b4e6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into tokens (token, account_id) values ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d125d3c97a59ac5c4ba04eb02d9a8a35d5dc84b0860ba22a8c105d457cb8b4e6" +} diff --git a/identity/.sqlx/query-dab570575b63e52a4bee944f139121074794c172310c88bdcdd8bef083df9431.json b/identity/.sqlx/query-dab570575b63e52a4bee944f139121074794c172310c88bdcdd8bef083df9431.json new file mode 100644 index 0000000..2d51436 --- /dev/null +++ b/identity/.sqlx/query-dab570575b63e52a4bee944f139121074794c172310c88bdcdd8bef083df9431.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "select id, domain, base_url, public_key,\n status as \"status: InstanceStatus\", reason\n from chat_instances where domain = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "domain", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "base_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "public_key", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status: InstanceStatus", + "type_info": { + "Custom": { + "name": "instance_status", + "kind": { + "Enum": [ + "active", + "suspended" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "reason", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "dab570575b63e52a4bee944f139121074794c172310c88bdcdd8bef083df9431" +} diff --git a/identity/.sqlx/query-e8680ec00d42254b8fc8240636d8e91cbd34ef31391418828f4a0bb4e9e2a4ce.json b/identity/.sqlx/query-e8680ec00d42254b8fc8240636d8e91cbd34ef31391418828f4a0bb4e9e2a4ce.json new file mode 100644 index 0000000..08e2865 --- /dev/null +++ b/identity/.sqlx/query-e8680ec00d42254b8fc8240636d8e91cbd34ef31391418828f4a0bb4e9e2a4ce.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into instance (public_key, private_key) values ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e8680ec00d42254b8fc8240636d8e91cbd34ef31391418828f4a0bb4e9e2a4ce" +} diff --git a/identity/Cargo.toml b/identity/Cargo.toml index 5c97009..9de417f 100644 --- a/identity/Cargo.toml +++ b/identity/Cargo.toml @@ -26,3 +26,4 @@ bcrypt = "0.15.0" base64 = "0.21.7" sha256 = "1.5.0" reqwest = { version = "0.11.23", features = ["json", "gzip", "brotli", "multipart"] } +chrono = "0.4.31" diff --git a/identity/src/config.rs b/identity/src/config.rs index 8f7a7bd..d946bb3 100644 --- a/identity/src/config.rs +++ b/identity/src/config.rs @@ -10,11 +10,9 @@ pub const CONFIG_FILE: &str = "config.identity.toml"; pub struct Config { pub database_url: String, pub port: u16, + pub domain: String, pub auto_migrate: Option, pub log_level: Option, - /// Whether to try HTTP if a server is not served over HTTPS. - /// This should only be set to `true` in development. - pub insecure_requests: Option, } impl Config { @@ -43,21 +41,16 @@ impl Config { _ => None } } - - /// If true, the server will try to identify remote servers over HTTP if they are not available over HTTPS - pub fn should_try_insecure(&self) -> bool { - self.insecure_requests.unwrap_or(false) - } } 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, - insecure_requests: None, } } } diff --git a/identity/src/db/account.rs b/identity/src/db/account.rs index 5c0a428..c9f81e4 100644 --- a/identity/src/db/account.rs +++ b/identity/src/db/account.rs @@ -1,6 +1,6 @@ use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD}; use eyre::{Context, Report, Result}; -use foxchat::QueryError; +use foxchat::FoxError; use rand::RngCore; use sqlx::{PgExecutor, Pool, Postgres}; @@ -22,16 +22,16 @@ pub async fn get_user_by_username_and_password( .await .map_err(|e| -> Report { match e { - sqlx::Error::RowNotFound => QueryError::NotFound.into(), + sqlx::Error::RowNotFound => FoxError::NotFound.into(), _ => e.into(), } })?; - if bcrypt::verify(password, &account.password).map_err(|_| QueryError::NotFound)? { + if bcrypt::verify(password, &account.password).map_err(|_| FoxError::NotFound)? { return Ok(account); } - Err(QueryError::NotFound.into()) + Err(FoxError::NotFound.into()) } pub async fn check_token(pool: &Pool, token: String) -> Result { @@ -51,7 +51,7 @@ pub async fn check_token(pool: &Pool, token: String) -> Result Ok(a), - None => Err(QueryError::NotFound.into()), + None => Err(FoxError::NotFound.into()), } } diff --git a/identity/src/http/mod.rs b/identity/src/http/mod.rs index d3b8459..1d04da4 100644 --- a/identity/src/http/mod.rs +++ b/identity/src/http/mod.rs @@ -15,6 +15,7 @@ pub fn new(pool: Pool, config: Config) -> Router { let app = Router::new() .merge(account::router()) .route("/_fox/ident/node", get(node::get_node)) + .route("/_fox/ident/node/:domain", get(node::get_chat_node)) .layer(TraceLayer::new_for_http()) .layer(Extension(app_state)); diff --git a/identity/src/http/node.rs b/identity/src/http/node.rs index 2536d3a..6b880b4 100644 --- a/identity/src/http/node.rs +++ b/identity/src/http/node.rs @@ -1,14 +1,16 @@ use std::sync::Arc; -use axum::{Extension, Json}; -use eyre::{Result, Context}; +use axum::{Extension, Json, extract::Path}; +use eyre::{Context, Result}; use foxchat::http::ApiError; use rsa::pkcs1::EncodeRsaPublicKey; use serde::Serialize; -use crate::{app_state::AppState, model::instance::Instance}; +use crate::{app_state::AppState, model::{instance::Instance, chat_instance::ChatInstance}}; -pub async fn get_node(Extension(state): Extension>) -> Result, ApiError> { +pub async fn get_node( + Extension(state): Extension>, +) -> Result, ApiError> { let instance = Instance::get(&state.pool).await?; let public_key = instance @@ -29,3 +31,12 @@ pub struct NodeResponse { pub software: &'static str, pub public_key: String, } + +pub async fn get_chat_node( + Extension(state): Extension>, + Path(domain): Path, +) -> Result, ApiError> { + let instance = ChatInstance::get(state, domain).await?; + + Ok(Json(instance)) +} diff --git a/identity/src/model/chat_instance.rs b/identity/src/model/chat_instance.rs index 1692ee7..360adda 100644 --- a/identity/src/model/chat_instance.rs +++ b/identity/src/model/chat_instance.rs @@ -1,10 +1,13 @@ use std::sync::Arc; use eyre::Result; +use foxchat::{fed::{self, request::is_valid_domain}, FoxError}; use serde::{Deserialize, Serialize}; +use ulid::Ulid; -use crate::app_state::AppState; +use crate::{app_state::AppState, model::instance::Instance}; +#[derive(Serialize)] pub struct ChatInstance { pub id: String, pub domain: String, @@ -23,6 +26,10 @@ pub enum InstanceStatus { impl ChatInstance { pub async fn get(state: Arc, domain: String) -> Result { + 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, @@ -36,9 +43,49 @@ impl ChatInstance { return Ok(instance); } - // TODO: identify server process - // only try HTTP if `state.config.should_try_insecure()` + let current_instance = Instance::get(&state.pool).await?; - todo!() + let resp: HelloResponse = fed::post( + ¤t_instance.private_key, + state.config.domain.clone(), + domain.clone(), + "/_fox/chat/hello".into(), + None, + &HelloRequest { + host: state.config.domain.clone(), + }, + ) + .await?; + + if resp.host != domain.clone() { + return Err(FoxError::InvalidServer.into()); + } + + let instance = sqlx::query_as!( + Self, + r#"insert into chat_instances + (id, domain, base_url, public_key) values ($1, $2, $3, $4) + returning id, domain, base_url, public_key, + status as "status: InstanceStatus", reason"#, + Ulid::new().to_string(), + domain.clone(), + format!("https://{domain}"), + resp.public_key + ) + .fetch_one(&state.pool) + .await?; + + Ok(instance) } } + +#[derive(Serialize, Debug)] +struct HelloRequest { + pub host: String, +} + +#[derive(Deserialize, Debug)] +struct HelloResponse { + pub public_key: String, + pub host: String, +}