diff --git a/Cargo.lock b/Cargo.lock index 6ee77c2..d77d4b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -282,6 +282,7 @@ dependencies = [ "serde_jcs", "serde_json", "serde_with", + "thiserror", "url", ] diff --git a/blah-types/Cargo.toml b/blah-types/Cargo.toml index 7b8925c..b60b582 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" +thiserror = "1.0.63" url = { version = "2", features = ["serde"] } [dev-dependencies] diff --git a/blah-types/src/identity.rs b/blah-types/src/identity.rs new file mode 100644 index 0000000..cb0a1fc --- /dev/null +++ b/blah-types/src/identity.rs @@ -0,0 +1,184 @@ +use core::fmt; +use std::ops; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::{Host, Position, Url}; + +use crate::{PubKey, Signed}; + +/// 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: PubKey, + /// 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: PubKey, + 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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(try_from = "Url")] +pub struct IdUrl(Url); + +impl fmt::Display for IdUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Serialize for IdUrl { + fn serialize(&self, ser: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(ser) + } +} + +impl IdUrl { + /// 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: + pub const MAX_LEN: usize = 80; +} + +impl ops::Deref for IdUrl { + type Target = Url; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum IdUrlError { + #[error(transparent)] + ParseUrl(#[from] url::ParseError), + #[error("id-URL too long")] + TooLong, + #[error("id-URL scheme must be https or http")] + InvalidScheme, + #[error("id-URL must not have username or password")] + HasAuth, + #[error("id-URL host must not be an IP")] + InvalidHost, + #[error("id-URL must has root path `/` without query or fragment")] + InvalidPath, +} + +impl TryFrom for IdUrl { + type Error = IdUrlError; + + /// Validate identity URL. + /// + /// 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. + fn try_from(url: Url) -> Result { + // ```text + // url = + // scheme ":" + // [ "//" [ username [ ":" password ]? "@" ]? host [ ":" port ]? ]? + // path [ "?" query ]? [ "#" fragment ]? + // ``` + if url.as_str().len() > Self::MAX_LEN { + return Err(IdUrlError::TooLong); + } + if !["https", "http"].contains(&url.scheme()) { + return Err(IdUrlError::InvalidScheme); + } + if &url[Position::AfterScheme..Position::BeforeHost] != "://" { + return Err(IdUrlError::HasAuth); + } + if !url + .host() + .is_some_and(|host| matches!(host, Host::Domain(_))) + { + return Err(IdUrlError::InvalidHost); + } + if &url[Position::BeforePath..] != "/" { + return Err(IdUrlError::InvalidPath); + } + Ok(Self(url)) + } +} + +impl FromStr for IdUrl { + type Err = IdUrlError; + + fn from_str(s: &str) -> Result { + Url::parse(s)?.try_into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_id_url() { + let parse = ::parse::; + + assert!(matches!( + parse("not-a-url").unwrap_err(), + IdUrlError::ParseUrl(_) + )); + + macro_rules! check_err { + ($($s:expr, $err:expr;)*) => { + $( + assert_eq!(parse(&$s), Err($err)); + )* + }; + } + + check_err! { + format!("https://{}.com/", "a".repeat(IdUrl::MAX_LEN)), IdUrlError::TooLong; + "file:///etc/passwd", IdUrlError::InvalidScheme; + + "https://user@example.com/", IdUrlError::HasAuth; + "https://user:passwd@example.com/", IdUrlError::HasAuth; + "https://:passwd@example.com/", IdUrlError::HasAuth; + + "https://[::1]/", IdUrlError::InvalidHost; + "https://127.0.0.1/", IdUrlError::InvalidHost; + + "https://example.com/path", IdUrlError::InvalidPath; + "https://example.com//", IdUrlError::InvalidPath; + "https://example.com/?query", IdUrlError::InvalidPath; + "https://example.com/#hash", IdUrlError::InvalidPath; + "https://example.com?query", IdUrlError::InvalidPath; + "https://example.com#hash", IdUrlError::InvalidPath; + } + + // Auto normalized. + let expect = parse("https://example.com/").unwrap(); + assert_eq!(parse("https://example.com").unwrap(), expect); + assert_eq!(parse("https://:@example.com").unwrap(), expect); + } +} diff --git a/blah-types/src/lib.rs b/blah-types/src/lib.rs index b33bf65..297a180 100644 --- a/blah-types/src/lib.rs +++ b/blah-types/src/lib.rs @@ -7,6 +7,7 @@ use ed25519_dalek::{ Signature, SignatureError, Signer, SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH, }; +use identity::IdUrl; use rand_core::RngCore; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde_with::{serde_as, DisplayFromStr}; @@ -15,42 +16,13 @@ use url::Url; // Re-export of public dependencies. pub use bitflags; pub use ed25519_dalek; +pub use url; + +pub mod identity; 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: PubKey, - /// 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: PubKey, - 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] @@ -177,7 +149,7 @@ impl Signed { #[serde(tag = "typ", rename = "user_register")] pub struct UserRegisterPayload { pub server_url: Url, - pub id_url: Url, + pub id_url: IdUrl, pub id_key: PubKey, pub challenge_nonce: u32, } diff --git a/blahctl/src/main.rs b/blahctl/src/main.rs index 396caaa..8683fc5 100644 --- a/blahctl/src/main.rs +++ b/blahctl/src/main.rs @@ -3,9 +3,10 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; use anyhow::{ensure, Context, Result}; +use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile}; use blah_types::{ bitflags, get_timestamp, ChatPayload, CreateGroup, CreateRoomPayload, Id, PubKey, RichText, - RoomAttrs, ServerPermission, Signed, UserActKeyDesc, UserIdentityDesc, UserProfile, + RoomAttrs, ServerPermission, Signed, }; use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey}; @@ -74,7 +75,7 @@ enum IdCommand { /// The identity description file should be available at /// `/.well-known/blah/identity.json`. #[arg(long)] - id_url: Url, + id_url: IdUrl, }, /// Add an action subkey to an existing identity description. AddActKey { diff --git a/blahd/src/register.rs b/blahd/src/register.rs index b213443..05dfd44 100644 --- a/blahd/src/register.rs +++ b/blahd/src/register.rs @@ -3,9 +3,9 @@ use std::time::{Duration, Instant}; use anyhow::{anyhow, ensure, Context}; use axum::http::{HeaderMap, HeaderName, StatusCode}; +use blah_types::identity::{IdUrl, UserIdentityDesc}; use blah_types::{ - get_timestamp, PubKey, Signed, UserIdentityDesc, UserRegisterPayload, X_BLAH_DIFFICULTY, - X_BLAH_NONCE, + get_timestamp, PubKey, Signed, UserRegisterPayload, X_BLAH_DIFFICULTY, X_BLAH_NONCE, }; use http_body_util::BodyExt; use parking_lot::Mutex; @@ -14,17 +14,11 @@ 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 { @@ -38,6 +32,7 @@ pub struct Config { pub unsafe_allow_id_url_http: bool, pub unsafe_allow_id_url_custom_port: bool, + pub unsafe_allow_id_url_single_label: bool, } impl Default for Config { @@ -53,10 +48,32 @@ impl Default for Config { unsafe_allow_id_url_http: false, unsafe_allow_id_url_custom_port: false, + unsafe_allow_id_url_single_label: false, } } } +impl Config { + /// Check if the Identity URL is valid under the config. + /// This only does additional checking besides rules of [`IdUrl`]. + fn validate_id_url(&self, url: &IdUrl) -> Result<(), &'static str> { + if !self.unsafe_allow_id_url_http && url.scheme() == "http" { + return Err("http id_url is not permitted for this server"); + } + if !self.unsafe_allow_id_url_custom_port && url.port().is_some() { + return Err("id_url with custom port is not permitted for this server"); + } + let host = url.host_str().expect("checked by IdUrl"); + if host.starts_with('.') || host.ends_with('.') { + return Err("unpermitted id_url with starting or trailing dot"); + } + if !self.unsafe_allow_id_url_single_label && !host.contains('.') { + return Err("single-label id_url is not permitted for this server"); + } + Ok(()) + } +} + #[derive(Debug)] pub struct State { nonces: Mutex, @@ -131,31 +148,6 @@ impl State { } } -/// 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, @@ -178,11 +170,11 @@ pub async fn user_register( "unexpected server url in payload", )); } - if !is_id_url_valid(&st.config.register, ®.id_url) { + if let Err(err) = st.config.register.validate_id_url(®.id_url) { return Err(error_response!( StatusCode::BAD_REQUEST, "invalid_id_url", - "invalid identity URL", + "{err}", )); } if !st.register.nonce().contains(®.challenge_nonce) { @@ -247,7 +239,7 @@ pub async fn user_register( return Err(error_response!( StatusCode::UNAUTHORIZED, "fetch_id_description", - "failed to fetch identity description from domain {}: {}", + "failed to fetch identity description from {}: {}", reg.id_url, err, )) @@ -323,7 +315,7 @@ pub async fn user_register( } fn validate_id_desc( - id_url: &Url, + id_url: &IdUrl, id_key: &PubKey, id_desc: &UserIdentityDesc, now: u64, @@ -368,3 +360,30 @@ fn validate_id_desc( ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reject_unpermitted_id_url() { + let mut conf = Config::default(); + let http_url = "http://example.com".parse().unwrap(); + conf.validate_id_url(&http_url).unwrap_err(); + conf.unsafe_allow_id_url_http = true; + conf.validate_id_url(&http_url).unwrap(); + + let custom_port = "https://example.com:8080".parse().unwrap(); + conf.validate_id_url(&custom_port).unwrap_err(); + conf.unsafe_allow_id_url_custom_port = true; + conf.validate_id_url(&custom_port).unwrap(); + + let single_label = "https://localhost".parse().unwrap(); + conf.validate_id_url(&single_label).unwrap_err(); + conf.unsafe_allow_id_url_single_label = true; + conf.validate_id_url(&single_label).unwrap(); + + conf.validate_id_url(&"https://.".parse().unwrap()) + .unwrap_err(); + } +} diff --git a/blahd/tests/webapi.rs b/blahd/tests/webapi.rs index 3c322c7..37c7b02 100644 --- a/blahd/tests/webapi.rs +++ b/blahd/tests/webapi.rs @@ -9,11 +9,12 @@ use std::time::{Duration, Instant}; use anyhow::Result; use axum::http::HeaderMap; +use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile}; use blah_types::{ get_timestamp, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, PubKey, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, - ServerPermission, Signed, SignedChatMsg, UserActKeyDesc, UserIdentityDesc, UserKey, - UserProfile, UserRegisterPayload, WithMsgId, X_BLAH_DIFFICULTY, X_BLAH_NONCE, + ServerPermission, Signed, SignedChatMsg, UserKey, UserRegisterPayload, WithMsgId, + X_BLAH_DIFFICULTY, X_BLAH_NONCE, }; use blahd::{ApiError, AppState, Database, RoomList, RoomMsgs}; use ed25519_dalek::SigningKey; @@ -29,7 +30,6 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tokio::net::TcpListener; -use url::Url; // Register API requires a non-IP hostname. const LOCALHOST: &str = "localhost"; @@ -48,6 +48,7 @@ difficulty = {REGISTER_DIFFICULTY} request_timeout_secs = 1 unsafe_allow_id_url_http = true unsafe_allow_id_url_custom_port = true +unsafe_allow_id_url_single_label = true "# ) }; @@ -856,9 +857,9 @@ async fn register(server: Server) { let mut req = UserRegisterPayload { id_key: CAROL.pubkeys.id_key.clone(), - // Fake values. - server_url: "http://invalid.example.com".parse().unwrap(), - id_url: "file:///etc/passwd".parse().unwrap(), + // Invalid values. + server_url: "http://localhost".parse().unwrap(), + id_url: "http://.".parse().unwrap(), challenge_nonce: challenge_nonce - 1, }; let register = |req: Signed| { @@ -897,7 +898,9 @@ async fn register(server: Server) { 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(); + req.id_url = format!("http://{LOCALHOST}:{port}") + .parse::() + .unwrap(); let router = axum::Router::new() .route( @@ -962,7 +965,7 @@ async fn register(server: Server) { (StatusCode::OK, desc.clone()) }} }; - let sign_profile = |url: Url| { + let sign_profile = |url: IdUrl| { server.sign( &CAROL, UserProfile { @@ -985,7 +988,7 @@ async fn register(server: Server) { }, ) .unwrap(); - let profile = sign_profile(req.id_url.join("/mismatch").unwrap()); + let profile = sign_profile("https://localhost".parse().unwrap()); UserIdentityDesc { id_key: CAROL.pubkeys.id_key.clone(), act_keys: vec![act_key],