From fb76756482f05318d14225801da08df9179782d1 Mon Sep 17 00:00:00 2001 From: oxalica Date: Mon, 16 Sep 2024 10:37:24 -0400 Subject: [PATCH] feat(webapi): impl user registration and identity description format --- Cargo.lock | 4 + blah-types/Cargo.toml | 1 + blah-types/src/lib.rs | 56 +++++- blahd/Cargo.toml | 6 +- blahd/config.example.toml | 26 +++ blahd/schema.sql | 16 +- blahd/src/database.rs | 2 +- blahd/src/lib.rs | 48 ++++- blahd/src/register.rs | 358 ++++++++++++++++++++++++++++++++++++++ blahd/tests/webapi.rs | 298 +++++++++++++++++++++++++++++-- docs/webapi.yaml | 177 +++++++++++++++++++ 11 files changed, 972 insertions(+), 20 deletions(-) create mode 100644 blahd/src/register.rs diff --git a/Cargo.lock b/Cargo.lock index 3138013..24d4180 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,6 +276,7 @@ dependencies = [ "serde_jcs", "serde_json", "serde_with", + "url", ] [[package]] @@ -305,6 +306,7 @@ dependencies = [ "ed25519-dalek", "futures-util", "hex", + "http-body-util", "humantime", "nix", "parking_lot", @@ -317,8 +319,10 @@ dependencies = [ "serde", "serde-constant", "serde-inline-default", + "serde_jcs", "serde_json", "serde_urlencoded", + "sha2", "tokio", "tokio-stream", "toml", diff --git a/blah-types/Cargo.toml b/blah-types/Cargo.toml index 8cdcdd4..a716980 100644 --- a/blah-types/Cargo.toml +++ b/blah-types/Cargo.toml @@ -16,6 +16,7 @@ serde = { version = "1", features = ["derive"] } serde_jcs = "0.1" serde_json = "1" serde_with = "3.9.0" +url = "2" [dev-dependencies] expect-test = "1.5.0" diff --git a/blah-types/src/lib.rs b/blah-types/src/lib.rs index 2f9f3c9..3fd9606 100644 --- a/blah-types/src/lib.rs +++ b/blah-types/src/lib.rs @@ -9,11 +9,47 @@ use ed25519_dalek::{ use rand_core::RngCore; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde_with::{serde_as, DisplayFromStr}; +use url::Url; // Re-export of public dependencies. pub use bitflags; pub use ed25519_dalek; +pub const X_BLAH_NONCE: &str = "x-blah-nonce"; +pub const X_BLAH_DIFFICULTY: &str = "x-blah-difficulty"; + +/// User identity description structure. +// TODO: Revise and shrink duplicates (pubkey fields). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserIdentityDesc { + /// User primary identity key, only for signing action keys. + pub id_key: UserKey, + /// User action subkeys, signed by the identity key. + pub act_keys: Vec>, + /// User profile, signed by any valid action key. + pub profile: Signed, +} + +impl UserIdentityDesc { + pub const WELL_KNOWN_PATH: &str = "/.well-known/blah/identity.json"; +} + +// TODO: JWS or alike? +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "typ", rename = "user_act_key")] +pub struct UserActKeyDesc { + pub act_key: UserKey, + pub expire_time: u64, + pub comment: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "typ", rename = "user_profile")] +pub struct UserProfile { + pub preferred_chat_server_urls: Vec, + pub id_urls: Vec, +} + /// An opaque server-specific ID for rooms, messages, and etc. /// It's currently serialized as a string for JavaScript's convenience. #[serde_as] @@ -83,6 +119,11 @@ pub fn get_timestamp() -> u64 { } impl Signed { + /// Get the canonically serialized signee bytes. + pub fn canonical_signee(&self) -> Vec { + serde_jcs::to_vec(&self.signee).expect("serialization cannot fail") + } + /// Sign the payload with the given `key`. pub fn sign( key: &SigningKey, @@ -105,13 +146,22 @@ impl Signed { /// /// Note that this does not check validity of timestamp and other data. pub fn verify(&self) -> Result<(), SignatureError> { - let canonical_signee = serde_jcs::to_vec(&self.signee).expect("serialization cannot fail"); - let sig = Signature::from_bytes(&self.sig); - VerifyingKey::from_bytes(&self.signee.user.0)?.verify_strict(&canonical_signee, &sig)?; + VerifyingKey::from_bytes(&self.signee.user.0)? + .verify_strict(&self.canonical_signee(), &Signature::from_bytes(&self.sig))?; Ok(()) } } +/// Register a user on a chat server. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "typ", rename = "user_register")] +pub struct UserRegisterPayload { + pub server_url: Url, + pub id_url: Url, + pub id_key: UserKey, + pub challenge_nonce: u32, +} + // FIXME: `deny_unknown_fields` breaks this. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "typ", rename = "chat")] diff --git a/blahd/Cargo.toml b/blahd/Cargo.toml index 6f03720..0504916 100644 --- a/blahd/Cargo.toml +++ b/blahd/Cargo.toml @@ -11,15 +11,20 @@ clap = { version = "4", features = ["derive"] } ed25519-dalek = "2" futures-util = "0.3" hex = { version = "0.4", features = ["serde"] } +http-body-util = "0.1" humantime = "2" parking_lot = "0.12" # Maybe no better performance, just that we hate poisoning. ¯\_(ツ)_/¯ +rand = "0.8" +reqwest = "0.12" rusqlite = "0.32" sd-notify = "0.4" serde = { version = "1", features = ["derive"] } serde-constant = "0.1" serde-inline-default = "0.2.0" +serde_jcs = "0.1" serde_json = "1" serde_urlencoded = "0.7.1" +sha2 = "0.10" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } tokio-stream = { version = "0.1", features = ["sync"] } toml = "0.8" @@ -32,7 +37,6 @@ blah-types = { path = "../blah-types", features = ["rusqlite"] } [dev-dependencies] nix = { version = "0.29.0", features = ["fs", "process", "signal"] } -rand = "0.8.5" reqwest = { version = "0.12.7", features = ["json"] } rstest = { version = "0.22.0", default-features = false } scopeguard = "1.2.0" diff --git a/blahd/config.example.toml b/blahd/config.example.toml index 359e599..0d07efa 100644 --- a/blahd/config.example.toml +++ b/blahd/config.example.toml @@ -54,3 +54,29 @@ send_timeout_sec = 15 # If events overflow the pending buffer, older events will be dropped and # client will be notified. event_queue_len = 1024 + +[server.register] +# Allow public registration. +enable_public = false + +# The registration challenge difficulty. +# It demands at least `difficulty` number of leading zeros in SHA256 for +# Proof of Work (PoW). +difficulty = 16 + +# The challenge nonce rotation period in seconds. +nonce_rotate_secs = 60 + +# The timeout in seconds for fetching user `id_url`. +request_timeout_secs = 5 + +# The maximum response length in bytes of user's identity description. +max_identity_description_bytes = 65536 + +# [UNSAFE] Also accept HTTP `id_url`. By default only HTTPS is allowed. +# This should only be used for testing. +unsafe_allow_id_url_http = false + +# [UNSAFE] Also accept `id_url` with custom port. +# This should only be used for testing. +unsafe_allow_id_url_custom_port = false diff --git a/blahd/schema.sql b/blahd/schema.sql index 710c998..40fa8e0 100644 --- a/blahd/schema.sql +++ b/blahd/schema.sql @@ -2,9 +2,19 @@ -- implemented and layout can change at any time. CREATE TABLE IF NOT EXISTS `user` ( - `uid` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - `userkey` BLOB NOT NULL UNIQUE, - `permission` INTEGER NOT NULL DEFAULT 0 + `uid` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `userkey` BLOB NOT NULL UNIQUE, + `permission` INTEGER NOT NULL DEFAULT 0, + `last_fetch_time` INTEGER NOT NULL, + `id_desc` TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS `user_act_key` ( + `uid` INTEGER NOT NULL REFERENCES `user` (`uid`), + `act_key` BLOB NOT NULL, + `expire_time` INTEGER NOT NULL, + + PRIMARY KEY (`uid`, `act_key`) ) STRICT; -- The highest bit of `rid` will be set for peer chat room. diff --git a/blahd/src/database.rs b/blahd/src/database.rs index 5c83a81..c4c5d07 100644 --- a/blahd/src/database.rs +++ b/blahd/src/database.rs @@ -13,7 +13,7 @@ static INIT_SQL: &str = include_str!("../schema.sql"); // Simple and stupid version check for now. // `echo -n 'blahd-database-0' | sha256sum | head -c5` || version -const APPLICATION_ID: i32 = 0xd9e_8404; +const APPLICATION_ID: i32 = 0xd9e_8405; #[serde_inline_default] #[derive(Debug, Clone, Deserialize)] diff --git a/blahd/src/lib.rs b/blahd/src/lib.rs index dba7f79..ef02738 100644 --- a/blahd/src/lib.rs +++ b/blahd/src/lib.rs @@ -5,7 +5,7 @@ use std::time::{Duration, SystemTime}; use anyhow::Result; use axum::extract::ws; use axum::extract::{Path, Query, State, WebSocketUpgrade}; -use axum::http::{header, StatusCode}; +use axum::http::{header, HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; @@ -13,7 +13,7 @@ use axum_extra::extract::WithRejection as R; use blah_types::{ ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, Signed, SignedChatMsg, Signee, - UserKey, WithMsgId, + UserKey, UserRegisterPayload, WithMsgId, }; use ed25519_dalek::SIGNATURE_LENGTH; use id::IdExt; @@ -27,10 +27,12 @@ use utils::ExpiringSet; #[macro_use] mod middleware; + pub mod config; mod database; mod event; mod id; +mod register; mod utils; pub use database::Database; @@ -53,6 +55,8 @@ pub struct ServerConfig { #[serde(default)] pub ws: event::Config, + #[serde(default)] + pub register: register::Config, } fn de_base_url<'de, D: Deserializer<'de>>(de: D) -> Result { @@ -71,6 +75,7 @@ pub struct AppState { db: Database, used_nonces: Mutex>, event: event::State, + register: register::State, config: ServerConfig, } @@ -83,6 +88,7 @@ impl AppState { config.timestamp_tolerance_secs, ))), event: event::State::default(), + register: register::State::new(config.register.clone()), config, } @@ -124,6 +130,7 @@ type ArcState = State>; pub fn router(st: Arc) -> Router { Router::new() .route("/ws", get(handle_ws)) + .route("/user/me", get(user_get).post(user_register)) .route("/room", get(room_list)) .route("/room/create", post(room_create)) .route("/room/:rid", get(room_get_metadata)) @@ -168,6 +175,43 @@ async fn handle_ws(State(st): ArcState, ws: WebSocketUpgrade) -> Response { }) } +async fn user_get( + State(st): ArcState, + auth: MaybeAuth, +) -> Result { + let ret = (|| { + match auth.into_optional()? { + None => None, + Some(user) => st + .db + .get() + .query_row( + " + SELECT 1 + FROM `user` + WHERE `userkey` = ? + ", + params![user], + |_| Ok(()), + ) + .optional()?, + } + .ok_or_else(|| error_response!(StatusCode::NOT_FOUND, "not_found", "user does not exist")) + })(); + + match ret { + Ok(()) => Ok(StatusCode::NO_CONTENT), + Err(err) => Err((st.register.challenge_headers(), err)), + } +} + +async fn user_register( + State(st): ArcState, + SignedJson(msg): SignedJson, +) -> impl IntoResponse { + register::user_register(&st, msg).await +} + #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoomList { pub rooms: Vec, diff --git a/blahd/src/register.rs b/blahd/src/register.rs new file mode 100644 index 0000000..bada8d4 --- /dev/null +++ b/blahd/src/register.rs @@ -0,0 +1,358 @@ +use std::num::NonZero; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, ensure, Context}; +use axum::http::{HeaderMap, HeaderName, StatusCode}; +use blah_types::{ + get_timestamp, Signed, UserIdentityDesc, UserKey, UserRegisterPayload, X_BLAH_DIFFICULTY, + X_BLAH_NONCE, +}; +use http_body_util::BodyExt; +use parking_lot::Mutex; +use rand::rngs::OsRng; +use rand::RngCore; +use rusqlite::{named_params, params, OptionalExtension}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use url::{Host, Url}; + +use crate::{ApiError, AppState}; + +const USER_AGENT: &str = concat!("blahd/", env!("CARGO_PKG_VERSION")); + +/// Max domain length is limited by TLS certificate CommonName `ub-common-name`, +/// which is 64. Adding the schema and port, it should still be below 80. +/// Ref: https://www.rfc-editor.org/rfc/rfc3280 +const MAX_ID_URL_LEN: usize = 80; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub enable_public: bool, + + pub difficulty: u8, + pub nonce_rotate_secs: NonZero, + pub request_timeout_secs: u64, + + pub max_identity_description_bytes: usize, + + pub unsafe_allow_id_url_http: bool, + pub unsafe_allow_id_url_custom_port: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + enable_public: false, + + difficulty: 16, + nonce_rotate_secs: 60.try_into().expect("not zero"), + request_timeout_secs: 5, + + max_identity_description_bytes: 64 << 10, // 64KiB + + unsafe_allow_id_url_http: false, + unsafe_allow_id_url_custom_port: false, + } + } +} + +#[derive(Debug)] +pub struct State { + nonces: Mutex, + client: reqwest::Client, + + epoch: Instant, + config: Config, +} + +#[derive(Debug, Clone, Copy)] +struct Nonces { + nonce: u32, + prev_nonce: u32, + update_period: u64, +} + +impl State { + pub fn new(config: Config) -> Self { + // TODO: Audit this. + let client = reqwest::ClientBuilder::new() + .user_agent(USER_AGENT) + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(config.request_timeout_secs)) + .build() + .expect("initialize TLS"); + Self { + nonces: Nonces { + nonce: OsRng.next_u32(), + prev_nonce: OsRng.next_u32(), + update_period: 0, + } + .into(), + client, + epoch: Instant::now(), + config, + } + } + + fn nonce(&self) -> [u32; 2] { + let cur_period = + Instant::now().duration_since(self.epoch).as_secs() / self.config.nonce_rotate_secs; + let mut n = self.nonces.lock(); + if n.update_period == cur_period { + [n.nonce, n.prev_nonce] + } else { + n.prev_nonce = if n.update_period + 1 == cur_period { + n.nonce + } else { + OsRng.next_u32() + }; + n.nonce = OsRng.next_u32(); + [n.nonce, n.prev_nonce] + } + } + + pub fn challenge_headers(&self) -> HeaderMap { + if !self.config.enable_public { + return HeaderMap::new(); + } + + HeaderMap::from_iter([ + ( + const { HeaderName::from_static(X_BLAH_NONCE) }, + self.nonce()[0].into(), + ), + ( + const { HeaderName::from_static(X_BLAH_DIFFICULTY) }, + u16::from(self.config.difficulty).into(), + ), + ]) + } +} + +/// Check if the Identity URL is valid under the config. +/// +/// We only accept simple HTTPS (and HTTP, if configured) domains. It must not be an IP host and +/// must not have other parts like username, query, and etc. +/// +/// Ref: https://docs.rs/url/2.5.2/url/enum.Position.html +/// ```text +/// url = +/// scheme ":" +/// [ "//" [ username [ ":" password ]? "@" ]? host [ ":" port ]? ]? +/// path [ "?" query ]? [ "#" fragment ]? +/// ``` +fn is_id_url_valid(config: &Config, url: &Url) -> bool { + use url::Position; + + url.as_str().len() <= MAX_ID_URL_LEN + && (url.scheme() == "https" || config.unsafe_allow_id_url_http && url.scheme() == "http") + && &url[Position::AfterScheme..Position::BeforeHost] == "://" + && url + .host() + .is_some_and(|host| matches!(host, Host::Domain(_))) + && (config.unsafe_allow_id_url_custom_port || url.port().is_none()) + && &url[Position::BeforePath..] == "/" +} + +pub async fn user_register( + st: &AppState, + msg: Signed, +) -> Result { + if !st.config.register.enable_public { + return Err(error_response!( + StatusCode::FORBIDDEN, + "disabled", + "public registration is disabled", + )); + } + + let reg = &msg.signee.payload; + + // Basic validity check. + if reg.server_url != st.config.base_url { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "invalid_server_url", + "unexpected server url in payload", + )); + } + if !is_id_url_valid(&st.config.register, ®.id_url) { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "invalid_id_url", + "invalid identity URL", + )); + } + if !st.register.nonce().contains(®.challenge_nonce) { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "invalid_challenge_nonce", + "invalid or outdated challenge nonce", + )); + } + + // Challenge verification. + let expect_bits = st.register.config.difficulty; + if expect_bits > 0 { + let hash = { + let signee = msg.canonical_signee(); + let mut h = Sha256::new(); + h.update(&signee); + h.finalize() + }; + let hash = &hash[..]; + // `difficulty` is u8 so it must be < 256 + let (bytes, bits) = (expect_bits as usize / 8, expect_bits as usize % 8); + let ok = hash[..bytes].iter().all(|&b| b == 0) && hash[bytes] >> (8 - bits) == 0; + if !ok { + return Err(error_response!( + StatusCode::BAD_REQUEST, + "invalid_challenge_hash", + "challenge failed", + )); + } + } + + // TODO: Limit concurrency for the same domain and/or id_key? + + let fetch_url = reg + .id_url + .join(UserIdentityDesc::WELL_KNOWN_PATH) + .expect("URL is validated"); + let fut = async { + let resp = st + .register + .client + .get(fetch_url) + .send() + .await? + .error_for_status()?; + let body = reqwest::Body::from(resp); + let body = + http_body_util::Limited::new(body, st.config.register.max_identity_description_bytes) + .collect() + .await + .map_err(|err| anyhow!("{err}"))? + .to_bytes(); + let id_desc = serde_json::from_slice::(&body)?; + anyhow::Ok(id_desc) + }; + let fetch_time = get_timestamp(); + + let id_desc = match fut.await { + Ok(id_desc) => id_desc, + Err(err) => { + return Err(error_response!( + StatusCode::UNAUTHORIZED, + "fetch_id_description", + "failed to fetch identity description from domain {}: {}", + reg.id_url, + err, + )) + } + }; + + if let Err(err) = validate_id_desc(®.id_url, ®.id_key, &id_desc, fetch_time) { + return Err(error_response!( + StatusCode::UNAUTHORIZED, + "invalid_id_description", + "{err}", + )); + } + + // Now the identity is verified. + + let id_desc_json = serde_jcs::to_string(&id_desc).expect("serialization cannot fail"); + + let mut conn = st.db.get(); + let txn = conn.transaction()?; + let uid = txn + .query_row( + r" + INSERT INTO `user` (`userkey`, `last_fetch_time`, `id_desc`) + VALUES (:id_key, :last_fetch_time, :id_desc) + ON CONFLICT (`userkey`) DO UPDATE SET + `last_fetch_time` = :last_fetch_time, + `id_desc` = :id_desc + WHERE `last_fetch_time` < :last_fetch_time + RETURNING `uid` + ", + named_params! { + ":id_key": reg.id_key, + ":id_desc": id_desc_json, + ":last_fetch_time": fetch_time, + }, + |row| row.get::<_, i64>(0), + ) + .optional()? + .ok_or_else(|| { + error_response!( + StatusCode::CONFLICT, + "conflict", + "racing register, please try again later", + ) + })?; + { + txn.execute( + r" + DELETE FROM `user_act_key` + WHERE `uid` = ? + ", + params![uid], + )?; + let mut stmt = txn.prepare( + r" + INSERT INTO `user_act_key` (`uid`, `act_key`, `expire_time`) + VALUES (:uid, :act_key, :expire_time) + ", + )?; + for kdesc in &id_desc.act_keys { + stmt.execute(named_params! { + ":uid": uid, + ":act_key": kdesc.signee.payload.act_key, + // FIXME: Other `u64` that will be stored in database should also be range checked. + ":expire_time": kdesc.signee.payload.expire_time.min(i64::MAX as _), + })?; + } + } + txn.commit()?; + + Ok(StatusCode::NO_CONTENT) +} + +fn validate_id_desc( + id_url: &Url, + id_key: &UserKey, + id_desc: &UserIdentityDesc, + now: u64, +) -> anyhow::Result<()> { + ensure!(*id_key == id_desc.id_key, "id_key mismatch"); + + let profile_signing_key = &id_desc.profile.signee.user; + let mut profile_signed = false; + + for (i, act_key) in id_desc.act_keys.iter().enumerate() { + let kdesc = &act_key.signee.payload; + (|| { + ensure!(act_key.signee.user == *id_key, "not signed by id_key"); + act_key.verify().context("signature verification failed")?; + if now < kdesc.expire_time && *profile_signing_key == kdesc.act_key { + profile_signed = true; + } + Ok(()) + })() + .with_context(|| format!("in act_key {} {}", i, kdesc.act_key))?; + } + + ensure!(profile_signed, "profile is not signed by valid act_keys"); + id_desc + .profile + .verify() + .context("profile signature verification failed")?; + ensure!( + id_desc.profile.signee.payload.id_urls == std::slice::from_ref(id_url), + "id_url list must consists of a single matching id_url", + ); + Ok(()) +} diff --git a/blahd/tests/webapi.rs b/blahd/tests/webapi.rs index 245bc8a..c79463a 100644 --- a/blahd/tests/webapi.rs +++ b/blahd/tests/webapi.rs @@ -3,17 +3,23 @@ use std::cell::RefCell; use std::fmt; use std::future::{Future, IntoFuture}; +use std::ops::DerefMut; use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; use anyhow::Result; +use axum::http::HeaderMap; use blah_types::{ get_timestamp, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, - ServerPermission, Signed, SignedChatMsg, UserKey, WithMsgId, + ServerPermission, Signed, SignedChatMsg, UserActKeyDesc, UserIdentityDesc, UserKey, + UserProfile, UserRegisterPayload, WithMsgId, X_BLAH_DIFFICULTY, X_BLAH_NONCE, }; use blahd::{ApiError, AppState, Database, RoomList, RoomMsgs}; use ed25519_dalek::SigningKey; +use futures_util::future::BoxFuture; use futures_util::TryFutureExt; +use parking_lot::Mutex; use rand::rngs::mock::StepRng; use rand::RngCore; use reqwest::{header, Method, StatusCode}; @@ -21,15 +27,39 @@ use rstest::{fixture, rstest}; use rusqlite::{params, Connection}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tokio::net::TcpListener; +use url::Url; -// Avoid name resolution. -const LOCALHOST: &str = "127.0.0.1"; +// Register API requires a non-IP hostname. +const LOCALHOST: &str = "localhost"; +const REGISTER_DIFFICULTY: u8 = 1; + +const TIME_TOLERANCE: Duration = Duration::from_millis(100); + +const CONFIG: fn(u16) -> String = |port| { + format!( + r#" +base_url="http://{LOCALHOST}:{port}" + +[register] +enable_public = true +difficulty = {REGISTER_DIFFICULTY} +request_timeout_secs = 1 +unsafe_allow_id_url_http = true +unsafe_allow_id_url_custom_port = true + "# + ) +}; static ALICE_PRIV: LazyLock = LazyLock::new(|| SigningKey::from_bytes(&[b'A'; 32])); static ALICE: LazyLock = LazyLock::new(|| UserKey(ALICE_PRIV.verifying_key().to_bytes())); static BOB_PRIV: LazyLock = LazyLock::new(|| SigningKey::from_bytes(&[b'B'; 32])); static BOB: LazyLock = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes())); +static CAROL_PRIV: LazyLock = LazyLock::new(|| SigningKey::from_bytes(&[b'C'; 32])); +static CAROL: LazyLock = LazyLock::new(|| UserKey(CAROL_PRIV.verifying_key().to_bytes())); + +static CAROL_ACT_PRIV: LazyLock = LazyLock::new(|| SigningKey::from_bytes(&[b'c'; 32])); #[fixture] fn rng() -> impl RngCore { @@ -46,12 +76,33 @@ trait ResultExt { impl ResultExt for Result { #[track_caller] fn expect_api_err(self, status: StatusCode, code: &str) { - let err = self.unwrap_err().downcast::().unwrap(); - assert_eq!(err.status, status); - assert_eq!(err.code, code); + let err = self + .unwrap_err() + .downcast::() + .unwrap() + .error; + assert_eq!( + (err.status, &*err.code), + (status, code), + "unexpecteed API error: {err:?}", + ); } } +#[derive(Debug)] +pub struct ApiErrorWithHeaders { + error: ApiError, + headers: HeaderMap, +} + +impl fmt::Display for ApiErrorWithHeaders { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.error.fmt(f) + } +} + +impl std::error::Error for ApiErrorWithHeaders {} + #[derive(Debug)] struct Server { port: u16, @@ -64,6 +115,10 @@ impl Server { format!("http://{}:{}{}", LOCALHOST, self.port, rhs) } + fn rng(&self) -> impl DerefMut + use<'_> { + self.rng.borrow_mut() + } + fn request( &self, method: Method, @@ -82,6 +137,7 @@ impl Server { async move { let resp = b.send().await?; let status = resp.status(); + let headers = resp.headers().clone(); let resp_str = resp.text().await?; if !status.is_success() { @@ -91,7 +147,7 @@ impl Server { } let Resp { mut error } = serde_json::from_str(&resp_str)?; error.status = status; - Err(error.into()) + Err(ApiErrorWithHeaders { error, headers }.into()) } else if resp_str.is_empty() { Ok(None) } else { @@ -206,8 +262,16 @@ fn server() -> Server { let mut add_user = conn .prepare( r" - INSERT INTO `user` (`userkey`, `permission`) - VALUES (?, ?) + INSERT INTO `user` (`userkey`, `permission`, `last_fetch_time`, `id_desc`) + VALUES (?, ?, 0, '{}') + ", + ) + .unwrap(); + let mut add_act_key = conn + .prepare( + r" + INSERT INTO `user_act_key` (`uid`, `act_key`, `expire_time`) + VALUES (?, ?, ?) ", ) .unwrap(); @@ -216,6 +280,8 @@ fn server() -> Server { (&BOB, ServerPermission::empty()), ] { add_user.execute(params![user, perm]).unwrap(); + let uid = conn.last_insert_rowid(); + add_act_key.execute(params![uid, user, i64::MAX]).unwrap(); } } let db = Database::from_raw(conn).unwrap(); @@ -227,7 +293,7 @@ fn server() -> Server { let listener = TcpListener::from_std(listener).unwrap(); // TODO: Testing config is hard to build because it does have a `Default` impl. - let config = toml::from_str(&format!(r#"base_url="http://{LOCALHOST}:{port}""#)).unwrap(); + let config = toml::from_str(&CONFIG(port)).unwrap(); let st = AppState::new(db, config); let router = blahd::router(Arc::new(st)); @@ -717,3 +783,215 @@ async fn peer_chat(server: Server, ref mut rng: impl RngCore) { ); } } + +#[rstest] +#[tokio::test] +async fn register(server: Server) { + let rid = server + .create_room( + &ALICE_PRIV, + RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE, + "public room", + ) + .await + .unwrap(); + + let get_me = |user: Option<&SigningKey>| { + let auth = user.map(|user| auth(user, &mut *server.rng())); + server + .request::<(), ()>(Method::GET, "/user/me", auth.as_deref(), None) + .map_ok(|_| ()) + .map_err(|err| { + let err = err.downcast::().unwrap(); + assert_eq!(err.error.status, StatusCode::NOT_FOUND); + let challenge_nonce = err.headers[X_BLAH_NONCE] + .to_str() + .unwrap() + .parse::() + .unwrap(); + let difficulty = err.headers[X_BLAH_DIFFICULTY] + .to_str() + .unwrap() + .parse::() + .unwrap(); + (challenge_nonce, difficulty) + }) + }; + + // Alice is registered. + get_me(Some(&ALICE_PRIV)).await.unwrap(); + + // Carol is not registered. + let (challenge_nonce, diff) = get_me(Some(&CAROL_PRIV)).await.unwrap_err(); + assert_eq!(diff, REGISTER_DIFFICULTY); + + // Without token. + let ret2 = get_me(None).await.unwrap_err(); + assert_eq!(ret2, (challenge_nonce, diff)); + + let mut req = UserRegisterPayload { + id_key: CAROL.clone(), + // Fake values. + server_url: "http://invalid.example.com".parse().unwrap(), + id_url: "file:///etc/passwd".parse().unwrap(), + challenge_nonce: challenge_nonce - 1, + }; + let register = |req: Signed| { + server + .request::<_, ()>(Method::POST, "/user/me", None, Some(req)) + .map_ok(|_| {}) + }; + let sign_with_difficulty = |req: &UserRegisterPayload, pass: bool| loop { + let signed = sign(&CAROL_PRIV, &mut *server.rng(), req.clone()); + let mut h = Sha256::new(); + h.update(signed.canonical_signee()); + let h = h.finalize(); + if (h[0] >> (8 - REGISTER_DIFFICULTY) == 0) == pass { + return signed; + } + }; + let register_fast = + |req: &UserRegisterPayload| register(sign(&CAROL_PRIV, &mut *server.rng(), req.clone())); + + register_fast(&req) + .await + .expect_api_err(StatusCode::BAD_REQUEST, "invalid_server_url"); + req.server_url = server.url("").parse().unwrap(); + + register_fast(&req) + .await + .expect_api_err(StatusCode::BAD_REQUEST, "invalid_id_url"); + + // Test identity server. + type DynHandler = Box BoxFuture<'static, (StatusCode, String)> + Send>; + type State = Arc>; + let id_server_handler = { + let handler = Box::new(|| { + Box::pin(async move { (StatusCode::NOT_FOUND, "".into()) }) as BoxFuture<_> + }) as DynHandler; + let st = Arc::new(Mutex::new(handler)) as State; + + let listener = TcpListener::bind(format!("{LOCALHOST}:0")).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + req.id_url = Url::parse(&format!("http://{LOCALHOST}:{port}")).unwrap(); + + let router = axum::Router::new() + .route( + UserIdentityDesc::WELL_KNOWN_PATH, + axum::routing::get(move |state: axum::extract::State| state.lock()()), + ) + .with_state(st.clone()); + tokio::spawn(axum::serve(listener, router).into_future()); + st + }; + macro_rules! set_handler { + ($([$before:stmt])? $h:block) => { + *id_server_handler.lock() = Box::new(move || { + $($before)? + Box::pin(async move $h) as BoxFuture<_> + }) as DynHandler; + }; + } + + register_fast(&req) + .await + .expect_api_err(StatusCode::BAD_REQUEST, "invalid_challenge_nonce"); + req.challenge_nonce += 1; + + register(sign_with_difficulty(&req, false)) + .await + .expect_api_err(StatusCode::BAD_REQUEST, "invalid_challenge_hash"); + + //// Starting here, early validation passed. //// + + // id_url 404 + register(sign_with_difficulty(&req, true)) + .await + .expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description"); + + // Timeout + set_handler! {{ + tokio::time::sleep(Duration::from_secs(2)).await; + (StatusCode::OK, "".into()) + }} + let inst = Instant::now(); + register(sign_with_difficulty(&req, true)) + .await + .expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description"); + let elapsed = inst.elapsed(); + assert!( + elapsed.abs_diff(Duration::from_secs(1)) < TIME_TOLERANCE, + "unexpected delay: {elapsed:?}", + ); + + // Body too long. + set_handler! {{ + (StatusCode::OK, " ".repeat(64 << 10)) // 64KiB + }} + register(sign_with_difficulty(&req, true)) + .await + .expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description"); + + let set_id_desc = |desc: &UserIdentityDesc| { + let desc = serde_json::to_string(&desc).unwrap(); + set_handler! { [let desc = desc.clone()] { + (StatusCode::OK, desc.clone()) + }} + }; + let mut id_desc = { + let act_key = sign( + &CAROL_PRIV, + &mut *server.rng(), + UserActKeyDesc { + act_key: UserKey(CAROL_ACT_PRIV.verifying_key().to_bytes()), + expire_time: u64::MAX, + comment: "comment".into(), + }, + ); + let profile = sign( + &CAROL_ACT_PRIV, + &mut *server.rng(), + UserProfile { + preferred_chat_server_urls: Vec::new(), + id_urls: vec![req.id_url.join("/mismatch").unwrap()], + }, + ); + UserIdentityDesc { + id_key: CAROL.clone(), + act_keys: vec![act_key], + profile, + } + }; + + // id_url mismatch + set_id_desc(&id_desc); + register(sign_with_difficulty(&req, true)) + .await + .expect_api_err(StatusCode::UNAUTHORIZED, "invalid_id_description"); + + // Still not registered. + get_me(Some(&CAROL_PRIV)).await.unwrap_err(); + server + .join_room(rid, &CAROL_PRIV, MemberPermission::MAX_SELF_ADD) + .await + .expect_api_err(StatusCode::NOT_FOUND, "not_found"); + + // Finally pass. + id_desc.profile = sign( + &CAROL_ACT_PRIV, + &mut *server.rng(), + UserProfile { + preferred_chat_server_urls: Vec::new(), + id_urls: vec![req.id_url.clone()], + }, + ); + set_id_desc(&id_desc); + register(sign_with_difficulty(&req, true)).await.unwrap(); + + // Registered now. + get_me(Some(&CAROL_PRIV)).await.unwrap(); + server + .join_room(rid, &CAROL_PRIV, MemberPermission::MAX_SELF_ADD) + .await + .unwrap(); +} diff --git a/docs/webapi.yaml b/docs/webapi.yaml index dd48554..b5852e3 100644 --- a/docs/webapi.yaml +++ b/docs/webapi.yaml @@ -46,6 +46,91 @@ paths: schema: $ref: '#/components/schemas/WSServerToClient' + /user/me: + get: + summary: Check registration status of the current user + parameters: + - name: Authorization + in: header + description: Optional user authentication token. + schema: + $ref: '#/components/schemas/Signed-Auth' + + responses: + 204: + description: The user is already registered on the server. + + 404: + description: | + The user is not registered, or no token is not provided. + headers: + x-blah-nonce: + description: The challenge nonce for registration. + schema: + type: integer + format: uint32 + x-blah-difficulty: + description: The challenge difficulty for registration. + schema: + type: integer + format: uint32 + + post: + summary: Register or update user identity + description: | + Register or update a user identity description. + + To prevent misuse and DOS of this endpoint, the request must pass the + server-specific Proof of Work (PoW) challenge as below: + + 1. The request payload must include `challenge_nonce` with the value + of `x-blah-nonce` header from a recent enough GET response of + `/user/me`. Server will rotate it and a nonce will expire after a + server-specific time period. + + 2. The SHA256 of the canonical serialization (JCS) of `signee` must + have at least `x-blah-difficulty` (from a recent response) number + of leading zero bits. + + The `id_url` should be a HTTPS domain name without path. A fixed + well-known path `/.well-known/blah.identity.json` will be fetched. + It should return status 200, with a JSON response of type + `UserIdentityDescription`. + + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Signed-UserRegister' + + responses: + 204: + description: User successfully registered. + + 400: + description: Invalid request format, or invalid challenge. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + + 401: + description: | + Unable to verify user identity. May caused by connection failure + when fetching id_url, malformed identity description, and etc. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + + 409: + description: | + User state changed during the operation. Could retry later. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + /room: get: summary: List rooms @@ -635,3 +720,95 @@ components: permission: -1 timestamp: 1724966284 user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7 + + Signed-UserRegister: + type: object + properties: + sig: + type: string + signee: + type: object + properties: + nonce: + type: integer + format: uint32 + payload: + type: object + properties: + typ: + type: string + const: 'user_register' + server_url: + type: string + description: The server URL to register on. Must matches chat server's base_url. + id_url: + type: string + description: The identity server URL. Must be in form `https://`. + id_key: + type: string + description: Hex encoded user primary key `id_key`. + challenge_nonce: + type: integer + format: uint32 + description: The challenge nonce retrieved from a recent GET response of `/user/me`. + + UserIdentityDescription: + type: object + properties: + id_key: + type: string + + act_keys: + type: array + items: + type: object + properties: + sig: + type: string + signee: + type: object + properties: + nonce: + type: integer + format: uint32 + payload: + type: object + properties: + typ: + type: string + const: 'user_act_key' + act_key: + type: string + expire_time: + type: integer + format: uint64 + comment: + type: string + + profile: + type: object + properties: + sig: + type: string + signee: + type: object + properties: + nonce: + type: integer + format: uint32 + payload: + type: object + properties: + typ: + type: string + const: 'user_profile' + preferred_chat_server_urls: + type: array + items: + type: string + format: url + id_urls: + type: array + items: + type: string + format: url