mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 00:31:09 +00:00
feat(webapi): impl user registration and identity description format
This commit is contained in:
parent
7f74d73c8c
commit
fb76756482
11 changed files with 972 additions and 20 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
@ -276,6 +276,7 @@ dependencies = [
|
||||||
"serde_jcs",
|
"serde_jcs",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -305,6 +306,7 @@ dependencies = [
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"http-body-util",
|
||||||
"humantime",
|
"humantime",
|
||||||
"nix",
|
"nix",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
@ -317,8 +319,10 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde-constant",
|
"serde-constant",
|
||||||
"serde-inline-default",
|
"serde-inline-default",
|
||||||
|
"serde_jcs",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"toml",
|
"toml",
|
||||||
|
|
|
@ -16,6 +16,7 @@ serde = { version = "1", features = ["derive"] }
|
||||||
serde_jcs = "0.1"
|
serde_jcs = "0.1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_with = "3.9.0"
|
serde_with = "3.9.0"
|
||||||
|
url = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
expect-test = "1.5.0"
|
expect-test = "1.5.0"
|
||||||
|
|
|
@ -9,11 +9,47 @@ use ed25519_dalek::{
|
||||||
use rand_core::RngCore;
|
use rand_core::RngCore;
|
||||||
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
|
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use serde_with::{serde_as, DisplayFromStr};
|
use serde_with::{serde_as, DisplayFromStr};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
// Re-export of public dependencies.
|
// Re-export of public dependencies.
|
||||||
pub use bitflags;
|
pub use bitflags;
|
||||||
pub use ed25519_dalek;
|
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<Signed<UserActKeyDesc>>,
|
||||||
|
/// User profile, signed by any valid action key.
|
||||||
|
pub profile: Signed<UserProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Url>,
|
||||||
|
pub id_urls: Vec<Url>,
|
||||||
|
}
|
||||||
|
|
||||||
/// An opaque server-specific ID for rooms, messages, and etc.
|
/// An opaque server-specific ID for rooms, messages, and etc.
|
||||||
/// It's currently serialized as a string for JavaScript's convenience.
|
/// It's currently serialized as a string for JavaScript's convenience.
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
|
@ -83,6 +119,11 @@ pub fn get_timestamp() -> u64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Serialize> Signed<T> {
|
impl<T: Serialize> Signed<T> {
|
||||||
|
/// Get the canonically serialized signee bytes.
|
||||||
|
pub fn canonical_signee(&self) -> Vec<u8> {
|
||||||
|
serde_jcs::to_vec(&self.signee).expect("serialization cannot fail")
|
||||||
|
}
|
||||||
|
|
||||||
/// Sign the payload with the given `key`.
|
/// Sign the payload with the given `key`.
|
||||||
pub fn sign(
|
pub fn sign(
|
||||||
key: &SigningKey,
|
key: &SigningKey,
|
||||||
|
@ -105,13 +146,22 @@ impl<T: Serialize> Signed<T> {
|
||||||
///
|
///
|
||||||
/// Note that this does not check validity of timestamp and other data.
|
/// Note that this does not check validity of timestamp and other data.
|
||||||
pub fn verify(&self) -> Result<(), SignatureError> {
|
pub fn verify(&self) -> Result<(), SignatureError> {
|
||||||
let canonical_signee = serde_jcs::to_vec(&self.signee).expect("serialization cannot fail");
|
VerifyingKey::from_bytes(&self.signee.user.0)?
|
||||||
let sig = Signature::from_bytes(&self.sig);
|
.verify_strict(&self.canonical_signee(), &Signature::from_bytes(&self.sig))?;
|
||||||
VerifyingKey::from_bytes(&self.signee.user.0)?.verify_strict(&canonical_signee, &sig)?;
|
|
||||||
Ok(())
|
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.
|
// FIXME: `deny_unknown_fields` breaks this.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "typ", rename = "chat")]
|
#[serde(tag = "typ", rename = "chat")]
|
||||||
|
|
|
@ -11,15 +11,20 @@ clap = { version = "4", features = ["derive"] }
|
||||||
ed25519-dalek = "2"
|
ed25519-dalek = "2"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
hex = { version = "0.4", features = ["serde"] }
|
hex = { version = "0.4", features = ["serde"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
humantime = "2"
|
humantime = "2"
|
||||||
parking_lot = "0.12" # Maybe no better performance, just that we hate poisoning. ¯\_(ツ)_/¯
|
parking_lot = "0.12" # Maybe no better performance, just that we hate poisoning. ¯\_(ツ)_/¯
|
||||||
|
rand = "0.8"
|
||||||
|
reqwest = "0.12"
|
||||||
rusqlite = "0.32"
|
rusqlite = "0.32"
|
||||||
sd-notify = "0.4"
|
sd-notify = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde-constant = "0.1"
|
serde-constant = "0.1"
|
||||||
serde-inline-default = "0.2.0"
|
serde-inline-default = "0.2.0"
|
||||||
|
serde_jcs = "0.1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7.1"
|
serde_urlencoded = "0.7.1"
|
||||||
|
sha2 = "0.10"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] }
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
@ -32,7 +37,6 @@ blah-types = { path = "../blah-types", features = ["rusqlite"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
nix = { version = "0.29.0", features = ["fs", "process", "signal"] }
|
nix = { version = "0.29.0", features = ["fs", "process", "signal"] }
|
||||||
rand = "0.8.5"
|
|
||||||
reqwest = { version = "0.12.7", features = ["json"] }
|
reqwest = { version = "0.12.7", features = ["json"] }
|
||||||
rstest = { version = "0.22.0", default-features = false }
|
rstest = { version = "0.22.0", default-features = false }
|
||||||
scopeguard = "1.2.0"
|
scopeguard = "1.2.0"
|
||||||
|
|
|
@ -54,3 +54,29 @@ send_timeout_sec = 15
|
||||||
# If events overflow the pending buffer, older events will be dropped and
|
# If events overflow the pending buffer, older events will be dropped and
|
||||||
# client will be notified.
|
# client will be notified.
|
||||||
event_queue_len = 1024
|
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
|
||||||
|
|
|
@ -2,9 +2,19 @@
|
||||||
-- implemented and layout can change at any time.
|
-- implemented and layout can change at any time.
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `user` (
|
CREATE TABLE IF NOT EXISTS `user` (
|
||||||
`uid` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
`uid` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
`userkey` BLOB NOT NULL UNIQUE,
|
`userkey` BLOB NOT NULL UNIQUE,
|
||||||
`permission` INTEGER NOT NULL DEFAULT 0
|
`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;
|
) STRICT;
|
||||||
|
|
||||||
-- The highest bit of `rid` will be set for peer chat room.
|
-- The highest bit of `rid` will be set for peer chat room.
|
||||||
|
|
|
@ -13,7 +13,7 @@ static INIT_SQL: &str = include_str!("../schema.sql");
|
||||||
|
|
||||||
// Simple and stupid version check for now.
|
// Simple and stupid version check for now.
|
||||||
// `echo -n 'blahd-database-0' | sha256sum | head -c5` || version
|
// `echo -n 'blahd-database-0' | sha256sum | head -c5` || version
|
||||||
const APPLICATION_ID: i32 = 0xd9e_8404;
|
const APPLICATION_ID: i32 = 0xd9e_8405;
|
||||||
|
|
||||||
#[serde_inline_default]
|
#[serde_inline_default]
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|
|
@ -5,7 +5,7 @@ use std::time::{Duration, SystemTime};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::extract::ws;
|
use axum::extract::ws;
|
||||||
use axum::extract::{Path, Query, State, WebSocketUpgrade};
|
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::response::{IntoResponse, Response};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
|
@ -13,7 +13,7 @@ use axum_extra::extract::WithRejection as R;
|
||||||
use blah_types::{
|
use blah_types::{
|
||||||
ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, RoomAdminOp,
|
ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission, RoomAdminOp,
|
||||||
RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, Signed, SignedChatMsg, Signee,
|
RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, Signed, SignedChatMsg, Signee,
|
||||||
UserKey, WithMsgId,
|
UserKey, UserRegisterPayload, WithMsgId,
|
||||||
};
|
};
|
||||||
use ed25519_dalek::SIGNATURE_LENGTH;
|
use ed25519_dalek::SIGNATURE_LENGTH;
|
||||||
use id::IdExt;
|
use id::IdExt;
|
||||||
|
@ -27,10 +27,12 @@ use utils::ExpiringSet;
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
mod middleware;
|
mod middleware;
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
mod database;
|
mod database;
|
||||||
mod event;
|
mod event;
|
||||||
mod id;
|
mod id;
|
||||||
|
mod register;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use database::Database;
|
pub use database::Database;
|
||||||
|
@ -53,6 +55,8 @@ pub struct ServerConfig {
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ws: event::Config,
|
pub ws: event::Config,
|
||||||
|
#[serde(default)]
|
||||||
|
pub register: register::Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn de_base_url<'de, D: Deserializer<'de>>(de: D) -> Result<Url, D::Error> {
|
fn de_base_url<'de, D: Deserializer<'de>>(de: D) -> Result<Url, D::Error> {
|
||||||
|
@ -71,6 +75,7 @@ pub struct AppState {
|
||||||
db: Database,
|
db: Database,
|
||||||
used_nonces: Mutex<ExpiringSet<u32>>,
|
used_nonces: Mutex<ExpiringSet<u32>>,
|
||||||
event: event::State,
|
event: event::State,
|
||||||
|
register: register::State,
|
||||||
|
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
}
|
}
|
||||||
|
@ -83,6 +88,7 @@ impl AppState {
|
||||||
config.timestamp_tolerance_secs,
|
config.timestamp_tolerance_secs,
|
||||||
))),
|
))),
|
||||||
event: event::State::default(),
|
event: event::State::default(),
|
||||||
|
register: register::State::new(config.register.clone()),
|
||||||
|
|
||||||
config,
|
config,
|
||||||
}
|
}
|
||||||
|
@ -124,6 +130,7 @@ type ArcState = State<Arc<AppState>>;
|
||||||
pub fn router(st: Arc<AppState>) -> Router {
|
pub fn router(st: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/ws", get(handle_ws))
|
.route("/ws", get(handle_ws))
|
||||||
|
.route("/user/me", get(user_get).post(user_register))
|
||||||
.route("/room", get(room_list))
|
.route("/room", get(room_list))
|
||||||
.route("/room/create", post(room_create))
|
.route("/room/create", post(room_create))
|
||||||
.route("/room/:rid", get(room_get_metadata))
|
.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<StatusCode, (HeaderMap, ApiError)> {
|
||||||
|
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<UserRegisterPayload>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
register::user_register(&st, msg).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RoomList {
|
pub struct RoomList {
|
||||||
pub rooms: Vec<RoomMetadata>,
|
pub rooms: Vec<RoomMetadata>,
|
||||||
|
|
358
blahd/src/register.rs
Normal file
358
blahd/src/register.rs
Normal file
|
@ -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<u64>,
|
||||||
|
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<Nonces>,
|
||||||
|
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<UserRegisterPayload>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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::<UserIdentityDesc>(&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(())
|
||||||
|
}
|
|
@ -3,17 +3,23 @@
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::future::{Future, IntoFuture};
|
use std::future::{Future, IntoFuture};
|
||||||
|
use std::ops::DerefMut;
|
||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
use blah_types::{
|
use blah_types::{
|
||||||
get_timestamp, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id,
|
get_timestamp, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id,
|
||||||
MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata,
|
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 blahd::{ApiError, AppState, Database, RoomList, RoomMsgs};
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
|
use futures_util::future::BoxFuture;
|
||||||
use futures_util::TryFutureExt;
|
use futures_util::TryFutureExt;
|
||||||
|
use parking_lot::Mutex;
|
||||||
use rand::rngs::mock::StepRng;
|
use rand::rngs::mock::StepRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use reqwest::{header, Method, StatusCode};
|
use reqwest::{header, Method, StatusCode};
|
||||||
|
@ -21,15 +27,39 @@ use rstest::{fixture, rstest};
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
// Avoid name resolution.
|
// Register API requires a non-IP hostname.
|
||||||
const LOCALHOST: &str = "127.0.0.1";
|
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<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'A'; 32]));
|
static ALICE_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'A'; 32]));
|
||||||
static ALICE: LazyLock<UserKey> = LazyLock::new(|| UserKey(ALICE_PRIV.verifying_key().to_bytes()));
|
static ALICE: LazyLock<UserKey> = LazyLock::new(|| UserKey(ALICE_PRIV.verifying_key().to_bytes()));
|
||||||
static BOB_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'B'; 32]));
|
static BOB_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'B'; 32]));
|
||||||
static BOB: LazyLock<UserKey> = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes()));
|
static BOB: LazyLock<UserKey> = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes()));
|
||||||
|
static CAROL_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'C'; 32]));
|
||||||
|
static CAROL: LazyLock<UserKey> = LazyLock::new(|| UserKey(CAROL_PRIV.verifying_key().to_bytes()));
|
||||||
|
|
||||||
|
static CAROL_ACT_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'c'; 32]));
|
||||||
|
|
||||||
#[fixture]
|
#[fixture]
|
||||||
fn rng() -> impl RngCore {
|
fn rng() -> impl RngCore {
|
||||||
|
@ -46,12 +76,33 @@ trait ResultExt {
|
||||||
impl<T: fmt::Debug> ResultExt for Result<T> {
|
impl<T: fmt::Debug> ResultExt for Result<T> {
|
||||||
#[track_caller]
|
#[track_caller]
|
||||||
fn expect_api_err(self, status: StatusCode, code: &str) {
|
fn expect_api_err(self, status: StatusCode, code: &str) {
|
||||||
let err = self.unwrap_err().downcast::<ApiError>().unwrap();
|
let err = self
|
||||||
assert_eq!(err.status, status);
|
.unwrap_err()
|
||||||
assert_eq!(err.code, code);
|
.downcast::<ApiErrorWithHeaders>()
|
||||||
|
.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)]
|
#[derive(Debug)]
|
||||||
struct Server {
|
struct Server {
|
||||||
port: u16,
|
port: u16,
|
||||||
|
@ -64,6 +115,10 @@ impl Server {
|
||||||
format!("http://{}:{}{}", LOCALHOST, self.port, rhs)
|
format!("http://{}:{}{}", LOCALHOST, self.port, rhs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rng(&self) -> impl DerefMut<Target = impl RngCore> + use<'_> {
|
||||||
|
self.rng.borrow_mut()
|
||||||
|
}
|
||||||
|
|
||||||
fn request<Req: Serialize, Resp: DeserializeOwned>(
|
fn request<Req: Serialize, Resp: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
method: Method,
|
method: Method,
|
||||||
|
@ -82,6 +137,7 @@ impl Server {
|
||||||
async move {
|
async move {
|
||||||
let resp = b.send().await?;
|
let resp = b.send().await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
|
let headers = resp.headers().clone();
|
||||||
let resp_str = resp.text().await?;
|
let resp_str = resp.text().await?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
|
@ -91,7 +147,7 @@ impl Server {
|
||||||
}
|
}
|
||||||
let Resp { mut error } = serde_json::from_str(&resp_str)?;
|
let Resp { mut error } = serde_json::from_str(&resp_str)?;
|
||||||
error.status = status;
|
error.status = status;
|
||||||
Err(error.into())
|
Err(ApiErrorWithHeaders { error, headers }.into())
|
||||||
} else if resp_str.is_empty() {
|
} else if resp_str.is_empty() {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
} else {
|
} else {
|
||||||
|
@ -206,8 +262,16 @@ fn server() -> Server {
|
||||||
let mut add_user = conn
|
let mut add_user = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
r"
|
r"
|
||||||
INSERT INTO `user` (`userkey`, `permission`)
|
INSERT INTO `user` (`userkey`, `permission`, `last_fetch_time`, `id_desc`)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?, 0, '{}')
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut add_act_key = conn
|
||||||
|
.prepare(
|
||||||
|
r"
|
||||||
|
INSERT INTO `user_act_key` (`uid`, `act_key`, `expire_time`)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
",
|
",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
@ -216,6 +280,8 @@ fn server() -> Server {
|
||||||
(&BOB, ServerPermission::empty()),
|
(&BOB, ServerPermission::empty()),
|
||||||
] {
|
] {
|
||||||
add_user.execute(params![user, perm]).unwrap();
|
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();
|
let db = Database::from_raw(conn).unwrap();
|
||||||
|
@ -227,7 +293,7 @@ fn server() -> Server {
|
||||||
let listener = TcpListener::from_std(listener).unwrap();
|
let listener = TcpListener::from_std(listener).unwrap();
|
||||||
|
|
||||||
// TODO: Testing config is hard to build because it does have a `Default` impl.
|
// 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 st = AppState::new(db, config);
|
||||||
let router = blahd::router(Arc::new(st));
|
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::<ApiErrorWithHeaders>().unwrap();
|
||||||
|
assert_eq!(err.error.status, StatusCode::NOT_FOUND);
|
||||||
|
let challenge_nonce = err.headers[X_BLAH_NONCE]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.parse::<u32>()
|
||||||
|
.unwrap();
|
||||||
|
let difficulty = err.headers[X_BLAH_DIFFICULTY]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.parse::<u8>()
|
||||||
|
.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<UserRegisterPayload>| {
|
||||||
|
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<dyn FnMut() -> BoxFuture<'static, (StatusCode, String)> + Send>;
|
||||||
|
type State = Arc<Mutex<DynHandler>>;
|
||||||
|
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>| 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();
|
||||||
|
}
|
||||||
|
|
177
docs/webapi.yaml
177
docs/webapi.yaml
|
@ -46,6 +46,91 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/WSServerToClient'
|
$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:
|
/room:
|
||||||
get:
|
get:
|
||||||
summary: List rooms
|
summary: List rooms
|
||||||
|
@ -635,3 +720,95 @@ components:
|
||||||
permission: -1
|
permission: -1
|
||||||
timestamp: 1724966284
|
timestamp: 1724966284
|
||||||
user: 83ce46ced47ec0391c64846cbb6c507250ead4985b6a044d68751edc46015dd7
|
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://<domain>`.
|
||||||
|
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
|
||||||
|
|
Loading…
Add table
Reference in a new issue