mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-04-30 16:21:10 +00:00
refactor(webapi,types)!: make challenge type extensive
We may allow more challenge types other than PoW in the future, eg. captcha. So make the relevent types more generic. Now the challenge is returned in JSON response as a individual top-level field `register_challenge` instead of in HTTP headers.
This commit is contained in:
parent
364e517b7d
commit
bc6e6c2056
11 changed files with 206 additions and 130 deletions
|
@ -2,7 +2,7 @@
|
||||||
use std::hint::black_box;
|
use std::hint::black_box;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use blah_types::msg::{ChatPayload, UserRegisterPayload};
|
use blah_types::msg::{ChatPayload, UserRegisterChallengeResponse, UserRegisterPayload};
|
||||||
use blah_types::{get_timestamp, Id, PubKey, SignExt, Signee, UserKey};
|
use blah_types::{get_timestamp, Id, PubKey, SignExt, Signee, UserKey};
|
||||||
use criterion::{criterion_group, criterion_main, Criterion};
|
use criterion::{criterion_group, criterion_main, Criterion};
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
|
@ -24,7 +24,7 @@ fn bench_register_pow(c: &mut Criterion) {
|
||||||
id_key: id_key.clone(),
|
id_key: id_key.clone(),
|
||||||
server_url: "http://some.example.com".parse().unwrap(),
|
server_url: "http://some.example.com".parse().unwrap(),
|
||||||
id_url: "http://another.example.com".parse().unwrap(),
|
id_url: "http://another.example.com".parse().unwrap(),
|
||||||
challenge_nonce: rng.gen(),
|
challenge: Some(UserRegisterChallengeResponse::Pow { nonce: rng.gen() }),
|
||||||
};
|
};
|
||||||
let mut signee = Signee {
|
let mut signee = Signee {
|
||||||
nonce: 0,
|
nonce: 0,
|
||||||
|
|
|
@ -58,7 +58,17 @@ pub struct UserRegisterPayload {
|
||||||
pub server_url: Url,
|
pub server_url: Url,
|
||||||
pub id_url: IdUrl,
|
pub id_url: IdUrl,
|
||||||
pub id_key: PubKey,
|
pub id_key: PubKey,
|
||||||
pub challenge_nonce: u32,
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub challenge: Option<UserRegisterChallengeResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server-specific challenge data for registration.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum UserRegisterChallengeResponse {
|
||||||
|
/// Proof of work challenge containing the same nonce from server challenge request.
|
||||||
|
/// The whole msg signee hash should have enough prefix zero bits.
|
||||||
|
Pow { nonce: u32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: `deny_unknown_fields` breaks this.
|
// FIXME: `deny_unknown_fields` breaks this.
|
||||||
|
|
|
@ -6,9 +6,6 @@ use url::Url;
|
||||||
use crate::msg::{Id, MemberPermission, RoomAttrs, SignedChatMsgWithId};
|
use crate::msg::{Id, MemberPermission, RoomAttrs, SignedChatMsgWithId};
|
||||||
use crate::PubKey;
|
use crate::PubKey;
|
||||||
|
|
||||||
pub const X_BLAH_NONCE: &str = "x-blah-nonce";
|
|
||||||
pub const X_BLAH_DIFFICULTY: &str = "x-blah-difficulty";
|
|
||||||
|
|
||||||
/// Metadata about the version and capabilities of a Chat Server.
|
/// Metadata about the version and capabilities of a Chat Server.
|
||||||
///
|
///
|
||||||
/// It should be relatively stable and do not change very often.
|
/// It should be relatively stable and do not change very often.
|
||||||
|
@ -37,6 +34,18 @@ pub struct ServerCapabilities {
|
||||||
pub allow_public_register: bool,
|
pub allow_public_register: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registration challenge information.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum UserRegisterChallenge {
|
||||||
|
/// Proof-of-work (PoW) challenge.
|
||||||
|
Pow { nonce: u32, difficulty: u8 },
|
||||||
|
|
||||||
|
/// A catch-all unknown challenge type.
|
||||||
|
#[serde(other, skip_serializing)]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RoomMetadata {
|
pub struct RoomMetadata {
|
||||||
/// Room id.
|
/// Room id.
|
||||||
|
|
|
@ -68,14 +68,6 @@ event_queue_len = 1024
|
||||||
# Allow public registration.
|
# Allow public registration.
|
||||||
enable_public = false
|
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`.
|
# The timeout in seconds for fetching user `id_url`.
|
||||||
request_timeout_secs = 5
|
request_timeout_secs = 5
|
||||||
|
|
||||||
|
@ -89,3 +81,15 @@ unsafe_allow_id_url_http = false
|
||||||
# [UNSAFE] Also accept `id_url` with custom port.
|
# [UNSAFE] Also accept `id_url` with custom port.
|
||||||
# This should only be used for testing.
|
# This should only be used for testing.
|
||||||
unsafe_allow_id_url_custom_port = false
|
unsafe_allow_id_url_custom_port = false
|
||||||
|
|
||||||
|
# The difficulty of Proof of Work (PoW) challenge to avoid API abuse.
|
||||||
|
# It demands at least `difficulty` number of leading zeros in SHA256.
|
||||||
|
# Currently only PoW challenge is supported.
|
||||||
|
# A zero difficulty effectively disables the challenge.
|
||||||
|
[server.register.challenge.pow]
|
||||||
|
# The difficulty.
|
||||||
|
# On average `2^difficulty` SHA256 ops are required to complete the challenge.
|
||||||
|
difficulty = 16
|
||||||
|
|
||||||
|
# The challenge nonce rotation period in seconds.
|
||||||
|
nonce_rotate_secs = 60
|
||||||
|
|
|
@ -12,7 +12,6 @@ use blah_types::{Id, PubKey, Signee, UserKey};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rusqlite::{named_params, params, prepare_cached_and_bind, Connection, OpenFlags, Row};
|
use rusqlite::{named_params, params, prepare_cached_and_bind, Connection, OpenFlags, Row};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_inline_default::serde_inline_default;
|
|
||||||
|
|
||||||
use crate::middleware::ApiError;
|
use crate::middleware::ApiError;
|
||||||
|
|
||||||
|
@ -31,7 +30,6 @@ type Result<T, E = ApiError> = std::result::Result<T, E>;
|
||||||
// `echo -n 'blahd-database-0' | sha256sum | head -c5` || version
|
// `echo -n 'blahd-database-0' | sha256sum | head -c5` || version
|
||||||
const APPLICATION_ID: i32 = 0xd9e_8405;
|
const APPLICATION_ID: i32 = 0xd9e_8405;
|
||||||
|
|
||||||
#[serde_inline_default]
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
|
|
@ -6,7 +6,7 @@ use anyhow::Result;
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use axum::extract::{ws, OriginalUri};
|
use axum::extract::{ws, OriginalUri};
|
||||||
use axum::extract::{Path, Query, State, WebSocketUpgrade};
|
use axum::extract::{Path, Query, State, WebSocketUpgrade};
|
||||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{header, HeaderName, HeaderValue, 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};
|
||||||
|
@ -16,15 +16,13 @@ use blah_types::msg::{
|
||||||
MemberPermission, RoomAdminOp, RoomAdminPayload, RoomAttrs, ServerPermission,
|
MemberPermission, RoomAdminOp, RoomAdminPayload, RoomAttrs, ServerPermission,
|
||||||
SignedChatMsgWithId, UserRegisterPayload,
|
SignedChatMsgWithId, UserRegisterPayload,
|
||||||
};
|
};
|
||||||
use blah_types::server::{
|
use blah_types::server::{RoomMetadata, ServerCapabilities, ServerMetadata, UserRegisterChallenge};
|
||||||
RoomMetadata, ServerCapabilities, ServerMetadata, X_BLAH_DIFFICULTY, X_BLAH_NONCE,
|
|
||||||
};
|
|
||||||
use blah_types::{get_timestamp, Id, Signed, UserKey};
|
use blah_types::{get_timestamp, Id, Signed, UserKey};
|
||||||
use data_encoding::BASE64_NOPAD;
|
use data_encoding::BASE64_NOPAD;
|
||||||
use database::{Transaction, TransactionOps};
|
use database::{Transaction, TransactionOps};
|
||||||
use feed::FeedData;
|
use feed::FeedData;
|
||||||
use id::IdExt;
|
use id::IdExt;
|
||||||
use middleware::{Auth, ETag, MaybeAuth, ResultExt as _, SignedJson};
|
use middleware::{Auth, ETag, MaybeAuth, RawApiError, ResultExt as _, SignedJson};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use serde_inline_default::serde_inline_default;
|
use serde_inline_default::serde_inline_default;
|
||||||
|
@ -174,11 +172,7 @@ pub fn router(st: Arc<AppState>) -> Router {
|
||||||
// correct CORS headers. Also `Authorization` must be explicitly included besides `*`.
|
// correct CORS headers. Also `Authorization` must be explicitly included besides `*`.
|
||||||
.layer(
|
.layer(
|
||||||
tower_http::cors::CorsLayer::permissive()
|
tower_http::cors::CorsLayer::permissive()
|
||||||
.allow_headers([HeaderName::from_static("*"), header::AUTHORIZATION])
|
.allow_headers([HeaderName::from_static("*"), header::AUTHORIZATION]),
|
||||||
.expose_headers([
|
|
||||||
HeaderName::from_static(X_BLAH_NONCE),
|
|
||||||
HeaderName::from_static(X_BLAH_DIFFICULTY),
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
.with_state(st);
|
.with_state(st);
|
||||||
Router::new().nest("/_blah", router)
|
Router::new().nest("/_blah", router)
|
||||||
|
@ -220,10 +214,7 @@ async fn handle_ws(State(st): ArcState, ws: WebSocketUpgrade) -> Response {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn user_get(
|
async fn user_get(State(st): ArcState, auth: MaybeAuth) -> Response {
|
||||||
State(st): ArcState,
|
|
||||||
auth: MaybeAuth,
|
|
||||||
) -> Result<StatusCode, (HeaderMap, ApiError)> {
|
|
||||||
let ret = (|| {
|
let ret = (|| {
|
||||||
match auth.into_optional()? {
|
match auth.into_optional()? {
|
||||||
None => None,
|
None => None,
|
||||||
|
@ -232,9 +223,27 @@ async fn user_get(
|
||||||
.ok_or(ApiError::UserNotFound)
|
.ok_or(ApiError::UserNotFound)
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// TODO: Hoist this into types crate.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ErrResp<'a> {
|
||||||
|
error: RawApiError<'a>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
register_challenge: Option<UserRegisterChallenge>,
|
||||||
|
}
|
||||||
|
|
||||||
match ret {
|
match ret {
|
||||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(err) => Err((st.register.challenge_headers(), err)),
|
Err(err) => {
|
||||||
|
let (status, raw_err) = err.to_raw();
|
||||||
|
if status != StatusCode::NOT_FOUND {
|
||||||
|
return err.into_response();
|
||||||
|
}
|
||||||
|
let resp = Json(ErrResp {
|
||||||
|
error: raw_err,
|
||||||
|
register_challenge: st.register.challenge(&st.config.register),
|
||||||
|
});
|
||||||
|
(status, resp).into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,8 +34,8 @@ macro_rules! define_api_error {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl $name {
|
impl $name {
|
||||||
fn to_response_tuple(&self) -> (StatusCode, &'static str, &str) {
|
pub fn to_raw(&self) -> (StatusCode, RawApiError<'_>) {
|
||||||
paste::paste! {
|
let (status, code, message): (StatusCode, &str, &str) = paste::paste! {
|
||||||
match self {
|
match self {
|
||||||
$(
|
$(
|
||||||
Self::$variant
|
Self::$variant
|
||||||
|
@ -44,7 +44,8 @@ macro_rules! define_api_error {
|
||||||
,
|
,
|
||||||
)*
|
)*
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
(status, RawApiError { code, message })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,6 +76,12 @@ pub enum ApiError {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RawApiError<'a> {
|
||||||
|
pub code: &'a str,
|
||||||
|
pub message: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
macro_rules! api_ensure {
|
macro_rules! api_ensure {
|
||||||
($assertion:expr, $msg:literal $(,)?) => {
|
($assertion:expr, $msg:literal $(,)?) => {
|
||||||
if !$assertion {
|
if !$assertion {
|
||||||
|
@ -95,19 +102,11 @@ impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct Resp<'a> {
|
struct Resp<'a> {
|
||||||
error: Error<'a>,
|
error: RawApiError<'a>,
|
||||||
}
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Error<'a> {
|
|
||||||
code: &'a str,
|
|
||||||
message: &'a str,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let (status, code, message) = self.to_response_tuple();
|
let (status, error) = self.to_raw();
|
||||||
let mut resp = Json(Resp {
|
let mut resp = Json(Resp { error }).into_response();
|
||||||
error: Error { code, message },
|
|
||||||
})
|
|
||||||
.into_response();
|
|
||||||
*resp.status_mut() = status;
|
*resp.status_mut() = status;
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
@ -115,7 +114,7 @@ impl IntoResponse for ApiError {
|
||||||
|
|
||||||
impl fmt::Display for ApiError {
|
impl fmt::Display for ApiError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
let (_, code, message) = self.to_response_tuple();
|
let RawApiError { code, message } = self.to_raw().1;
|
||||||
write!(f, "({code}) {message}")
|
write!(f, "({code}) {message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,10 @@ use std::num::NonZero;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, ensure};
|
use anyhow::{anyhow, ensure};
|
||||||
use axum::http::{HeaderMap, HeaderName, StatusCode};
|
use axum::http::StatusCode;
|
||||||
use blah_types::identity::{IdUrl, UserIdentityDesc};
|
use blah_types::identity::{IdUrl, UserIdentityDesc};
|
||||||
use blah_types::msg::UserRegisterPayload;
|
use blah_types::msg::{UserRegisterChallengeResponse, UserRegisterPayload};
|
||||||
use blah_types::server::{X_BLAH_DIFFICULTY, X_BLAH_NONCE};
|
use blah_types::server::UserRegisterChallenge;
|
||||||
use blah_types::{get_timestamp, Signed};
|
use blah_types::{get_timestamp, Signed};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
@ -23,8 +23,6 @@ use crate::{ApiError, AppState, SERVER_AND_VERSION};
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub enable_public: bool,
|
pub enable_public: bool,
|
||||||
|
|
||||||
pub difficulty: u8,
|
|
||||||
pub nonce_rotate_secs: NonZero<u64>,
|
|
||||||
pub request_timeout_secs: u64,
|
pub request_timeout_secs: u64,
|
||||||
|
|
||||||
pub max_identity_description_bytes: usize,
|
pub max_identity_description_bytes: usize,
|
||||||
|
@ -32,6 +30,8 @@ pub struct Config {
|
||||||
pub unsafe_allow_id_url_http: bool,
|
pub unsafe_allow_id_url_http: bool,
|
||||||
pub unsafe_allow_id_url_custom_port: bool,
|
pub unsafe_allow_id_url_custom_port: bool,
|
||||||
pub unsafe_allow_id_url_single_label: bool,
|
pub unsafe_allow_id_url_single_label: bool,
|
||||||
|
|
||||||
|
pub challenge: ChallengeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
@ -39,8 +39,7 @@ impl Default for Config {
|
||||||
Self {
|
Self {
|
||||||
enable_public: false,
|
enable_public: false,
|
||||||
|
|
||||||
difficulty: 16,
|
challenge: ChallengeConfig::default(),
|
||||||
nonce_rotate_secs: 60.try_into().expect("not zero"),
|
|
||||||
request_timeout_secs: 5,
|
request_timeout_secs: 5,
|
||||||
|
|
||||||
max_identity_description_bytes: 64 << 10, // 64KiB
|
max_identity_description_bytes: 64 << 10, // 64KiB
|
||||||
|
@ -52,6 +51,24 @@ impl Default for Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||||
|
pub enum ChallengeConfig {
|
||||||
|
Pow {
|
||||||
|
difficulty: u8,
|
||||||
|
nonce_rotate_secs: NonZero<u64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ChallengeConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Pow {
|
||||||
|
difficulty: 16,
|
||||||
|
nonce_rotate_secs: 60.try_into().expect("not zero"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/// Check if the Identity URL is valid under the config.
|
/// Check if the Identity URL is valid under the config.
|
||||||
/// This only does additional checking besides rules of [`IdUrl`].
|
/// This only does additional checking besides rules of [`IdUrl`].
|
||||||
|
@ -79,7 +96,7 @@ pub struct State {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
|
||||||
epoch: Instant,
|
epoch: Instant,
|
||||||
config: Config,
|
nonce_rotate_secs: NonZero<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
@ -98,6 +115,9 @@ impl State {
|
||||||
.timeout(Duration::from_secs(config.request_timeout_secs))
|
.timeout(Duration::from_secs(config.request_timeout_secs))
|
||||||
.build()
|
.build()
|
||||||
.expect("initialize TLS");
|
.expect("initialize TLS");
|
||||||
|
let ChallengeConfig::Pow {
|
||||||
|
nonce_rotate_secs, ..
|
||||||
|
} = config.challenge;
|
||||||
Self {
|
Self {
|
||||||
nonces: Nonces {
|
nonces: Nonces {
|
||||||
nonce: OsRng.next_u32(),
|
nonce: OsRng.next_u32(),
|
||||||
|
@ -106,14 +126,15 @@ impl State {
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
client,
|
client,
|
||||||
|
|
||||||
epoch: Instant::now(),
|
epoch: Instant::now(),
|
||||||
config,
|
nonce_rotate_secs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nonce(&self) -> [u32; 2] {
|
fn nonce(&self) -> [u32; 2] {
|
||||||
let cur_period =
|
let cur_period =
|
||||||
Instant::now().duration_since(self.epoch).as_secs() / self.config.nonce_rotate_secs;
|
Instant::now().duration_since(self.epoch).as_secs() / self.nonce_rotate_secs;
|
||||||
let mut n = self.nonces.lock();
|
let mut n = self.nonces.lock();
|
||||||
if n.update_period == cur_period {
|
if n.update_period == cur_period {
|
||||||
[n.nonce, n.prev_nonce]
|
[n.nonce, n.prev_nonce]
|
||||||
|
@ -129,21 +150,12 @@ impl State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn challenge_headers(&self) -> HeaderMap {
|
pub fn challenge(&self, config: &Config) -> Option<UserRegisterChallenge> {
|
||||||
if !self.config.enable_public {
|
let ChallengeConfig::Pow { difficulty, .. } = config.challenge;
|
||||||
return HeaderMap::new();
|
config.enable_public.then(|| UserRegisterChallenge::Pow {
|
||||||
}
|
nonce: self.nonce()[0],
|
||||||
|
difficulty,
|
||||||
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(),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -162,14 +174,16 @@ pub async fn user_register(
|
||||||
if let Err(err) = st.config.register.validate_id_url(®.id_url) {
|
if let Err(err) = st.config.register.validate_id_url(®.id_url) {
|
||||||
return Err(ApiError::Disabled(err));
|
return Err(ApiError::Disabled(err));
|
||||||
}
|
}
|
||||||
api_ensure!(
|
|
||||||
st.register.nonce().contains(®.challenge_nonce),
|
|
||||||
"invalid challenge nonce",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Challenge verification.
|
// Challenge verification.
|
||||||
let expect_bits = st.register.config.difficulty;
|
let ChallengeConfig::Pow { difficulty, .. } = st.config.register.challenge;
|
||||||
if expect_bits > 0 {
|
if difficulty > 0 {
|
||||||
|
let nonce_valid = matches!(reg.challenge,
|
||||||
|
Some(UserRegisterChallengeResponse::Pow { nonce })
|
||||||
|
if st.register.nonce().contains(&nonce)
|
||||||
|
);
|
||||||
|
api_ensure!(nonce_valid, "invalid challenge nonce");
|
||||||
|
|
||||||
let hash = {
|
let hash = {
|
||||||
let signee = msg.canonical_signee();
|
let signee = msg.canonical_signee();
|
||||||
let mut h = Sha256::new();
|
let mut h = Sha256::new();
|
||||||
|
@ -178,7 +192,7 @@ pub async fn user_register(
|
||||||
};
|
};
|
||||||
let hash = &hash[..];
|
let hash = &hash[..];
|
||||||
// `difficulty` is u8 so it must be < 256
|
// `difficulty` is u8 so it must be < 256
|
||||||
let (bytes, bits) = (expect_bits as usize / 8, expect_bits as usize % 8);
|
let (bytes, bits) = (difficulty as usize / 8, difficulty as usize % 8);
|
||||||
// NB. Shift by 8 would overflow and wrap around for u8. Convert it to u32 first.
|
// NB. Shift by 8 would overflow and wrap around for u8. Convert it to u32 first.
|
||||||
let ok = hash[..bytes].iter().all(|&b| b == 0) && (hash[bytes] as u32) >> (8 - bits) == 0;
|
let ok = hash[..bytes].iter().all(|&b| b == 0) && (hash[bytes] as u32) >> (8 - bits) == 0;
|
||||||
api_ensure!(ok, "hash challenge failed");
|
api_ensure!(ok, "hash challenge failed");
|
||||||
|
|
|
@ -8,14 +8,14 @@ use std::sync::{Arc, LazyLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::http::HeaderMap;
|
|
||||||
use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile};
|
use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile};
|
||||||
use blah_types::msg::{
|
use blah_types::msg::{
|
||||||
AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload,
|
AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload,
|
||||||
MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, ServerPermission,
|
MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, ServerPermission,
|
||||||
SignedChatMsg, SignedChatMsgWithId, UserRegisterPayload, WithMsgId,
|
SignedChatMsg, SignedChatMsgWithId, UserRegisterChallengeResponse, UserRegisterPayload,
|
||||||
|
WithMsgId,
|
||||||
};
|
};
|
||||||
use blah_types::server::{RoomMetadata, ServerMetadata, X_BLAH_DIFFICULTY, X_BLAH_NONCE};
|
use blah_types::server::{RoomMetadata, ServerMetadata, UserRegisterChallenge};
|
||||||
use blah_types::{Id, SignExt, Signed, UserKey};
|
use blah_types::{Id, SignExt, Signed, UserKey};
|
||||||
use blahd::{AppState, Database, RoomList, RoomMsgs};
|
use blahd::{AppState, Database, RoomList, RoomMsgs};
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
|
@ -52,11 +52,14 @@ max_page_len = 2
|
||||||
|
|
||||||
[register]
|
[register]
|
||||||
enable_public = true
|
enable_public = true
|
||||||
difficulty = {REGISTER_DIFFICULTY}
|
|
||||||
request_timeout_secs = 1
|
request_timeout_secs = 1
|
||||||
unsafe_allow_id_url_http = true
|
unsafe_allow_id_url_http = true
|
||||||
unsafe_allow_id_url_custom_port = true
|
unsafe_allow_id_url_custom_port = true
|
||||||
unsafe_allow_id_url_single_label = true
|
unsafe_allow_id_url_single_label = true
|
||||||
|
|
||||||
|
[register.challenge.pow]
|
||||||
|
difficulty = {REGISTER_DIFFICULTY}
|
||||||
|
nonce_rotate_secs = 60
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
@ -98,7 +101,7 @@ 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::<ApiErrorWithHeaders>().unwrap();
|
let err = self.unwrap_err().downcast::<ApiError>().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(err.status, &*err.code),
|
(err.status, &*err.code),
|
||||||
(status, code),
|
(status, code),
|
||||||
|
@ -108,7 +111,7 @@ impl<T: fmt::Debug> ResultExt for Result<T> {
|
||||||
|
|
||||||
#[track_caller]
|
#[track_caller]
|
||||||
fn expect_invalid_request(self, message: &str) {
|
fn expect_invalid_request(self, message: &str) {
|
||||||
let err = self.unwrap_err().downcast::<ApiErrorWithHeaders>().unwrap();
|
let err = self.unwrap_err().downcast::<ApiError>().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(err.status, &*err.code, &*err.message),
|
(err.status, &*err.code, &*err.message),
|
||||||
(StatusCode::BAD_REQUEST, "invalid_request", message),
|
(StatusCode::BAD_REQUEST, "invalid_request", message),
|
||||||
|
@ -118,14 +121,14 @@ impl<T: fmt::Debug> ResultExt for Result<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ApiErrorWithHeaders {
|
pub struct ApiError {
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
code: String,
|
code: String,
|
||||||
message: String,
|
message: String,
|
||||||
headers: HeaderMap,
|
register_challenge: Option<UserRegisterChallenge>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ApiErrorWithHeaders {
|
impl fmt::Display for ApiError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
|
@ -135,7 +138,7 @@ impl fmt::Display for ApiErrorWithHeaders {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for ApiErrorWithHeaders {}
|
impl std::error::Error for ApiError {}
|
||||||
|
|
||||||
// TODO: Hoist this into types crate.
|
// TODO: Hoist this into types crate.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
@ -201,13 +204,14 @@ 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() {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Resp {
|
struct Resp {
|
||||||
error: RespErr,
|
error: RespErr,
|
||||||
|
#[serde(default)]
|
||||||
|
register_challenge: Option<UserRegisterChallenge>,
|
||||||
}
|
}
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct RespErr {
|
struct RespErr {
|
||||||
|
@ -217,11 +221,11 @@ impl Server {
|
||||||
|
|
||||||
let resp = serde_json::from_str::<Resp>(&resp_str)
|
let resp = serde_json::from_str::<Resp>(&resp_str)
|
||||||
.with_context(|| format!("failed to parse response {resp_str:?}"))?;
|
.with_context(|| format!("failed to parse response {resp_str:?}"))?;
|
||||||
Err(ApiErrorWithHeaders {
|
Err(ApiError {
|
||||||
status,
|
status,
|
||||||
code: resp.error.code,
|
code: resp.error.code,
|
||||||
message: resp.error.message,
|
message: resp.error.message,
|
||||||
headers,
|
register_challenge: resp.register_challenge,
|
||||||
}
|
}
|
||||||
.into())
|
.into())
|
||||||
} else if resp_str.is_empty() {
|
} else if resp_str.is_empty() {
|
||||||
|
@ -364,22 +368,15 @@ impl Server {
|
||||||
{
|
{
|
||||||
Ok(None) => Ok(()),
|
Ok(None) => Ok(()),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let err = err.downcast::<ApiErrorWithHeaders>().unwrap();
|
let err = err.downcast::<ApiError>().unwrap();
|
||||||
assert_eq!(err.status, StatusCode::NOT_FOUND);
|
assert_eq!(err.status, StatusCode::NOT_FOUND);
|
||||||
if !err.headers.contains_key(X_BLAH_NONCE) {
|
Err(match err.register_challenge {
|
||||||
return Err(None);
|
Some(UserRegisterChallenge::Pow { nonce, difficulty }) => {
|
||||||
}
|
Some((nonce, difficulty))
|
||||||
let challenge_nonce = err.headers[X_BLAH_NONCE]
|
}
|
||||||
.to_str()
|
Some(UserRegisterChallenge::Unknown) => unreachable!(),
|
||||||
.unwrap()
|
None => None,
|
||||||
.parse::<u32>()
|
})
|
||||||
.unwrap();
|
|
||||||
let difficulty = err.headers[X_BLAH_DIFFICULTY]
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
.parse::<u8>()
|
|
||||||
.unwrap();
|
|
||||||
Err(Some((challenge_nonce, difficulty)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1197,7 +1194,9 @@ async fn register_flow(server: Server) {
|
||||||
// Invalid values.
|
// Invalid values.
|
||||||
server_url: "http://localhost".parse().unwrap(),
|
server_url: "http://localhost".parse().unwrap(),
|
||||||
id_url: "http://com.".parse().unwrap(),
|
id_url: "http://com.".parse().unwrap(),
|
||||||
challenge_nonce: challenge_nonce - 1,
|
challenge: Some(UserRegisterChallengeResponse::Pow {
|
||||||
|
nonce: challenge_nonce - 1,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
let register = |req: Signed<UserRegisterPayload>| {
|
let register = |req: Signed<UserRegisterPayload>| {
|
||||||
server
|
server
|
||||||
|
@ -1262,7 +1261,9 @@ async fn register_flow(server: Server) {
|
||||||
register_fast(&req)
|
register_fast(&req)
|
||||||
.await
|
.await
|
||||||
.expect_invalid_request("invalid challenge nonce");
|
.expect_invalid_request("invalid challenge nonce");
|
||||||
req.challenge_nonce += 1;
|
req.challenge = Some(UserRegisterChallengeResponse::Pow {
|
||||||
|
nonce: challenge_nonce,
|
||||||
|
});
|
||||||
|
|
||||||
register(sign_with_difficulty(&req, false))
|
register(sign_with_difficulty(&req, false))
|
||||||
.await
|
.await
|
||||||
|
@ -1408,7 +1409,7 @@ unsafe_allow_id_url_single_label = {allow_single_label}
|
||||||
// Unused values.
|
// Unused values.
|
||||||
id_url: server_url.parse().unwrap(),
|
id_url: server_url.parse().unwrap(),
|
||||||
server_url: server_url.parse().unwrap(),
|
server_url: server_url.parse().unwrap(),
|
||||||
challenge_nonce: 0,
|
challenge: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let ret = server
|
let ret = server
|
||||||
|
@ -1433,9 +1434,10 @@ async fn register_nonce() {
|
||||||
base_url="{BASE_URL}"
|
base_url="{BASE_URL}"
|
||||||
[register]
|
[register]
|
||||||
enable_public = true
|
enable_public = true
|
||||||
|
unsafe_allow_id_url_http = true
|
||||||
|
[register.challenge.pow]
|
||||||
difficulty = 64 # Should fail the challenge if nonce matches.
|
difficulty = 64 # Should fail the challenge if nonce matches.
|
||||||
nonce_rotate_secs = 10
|
nonce_rotate_secs = 10
|
||||||
unsafe_allow_id_url_http = true
|
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
@ -1455,7 +1457,7 @@ unsafe_allow_id_url_http = true
|
||||||
id_key: CAROL.pubkeys.id_key.clone(),
|
id_key: CAROL.pubkeys.id_key.clone(),
|
||||||
server_url: BASE_URL.parse().unwrap(),
|
server_url: BASE_URL.parse().unwrap(),
|
||||||
id_url: BASE_URL.parse().unwrap(),
|
id_url: BASE_URL.parse().unwrap(),
|
||||||
challenge_nonce: nonce,
|
challenge: Some(UserRegisterChallengeResponse::Pow { nonce }),
|
||||||
}
|
}
|
||||||
.sign_msg(&CAROL.pubkeys.id_key, &CAROL.act_priv)
|
.sign_msg(&CAROL.pubkeys.id_key, &CAROL.act_priv)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
@ -85,17 +85,10 @@ paths:
|
||||||
404:
|
404:
|
||||||
description: |
|
description: |
|
||||||
The user is not registered, or no token is not provided.
|
The user is not registered, or no token is not provided.
|
||||||
headers:
|
content:
|
||||||
x-blah-nonce:
|
application/json:
|
||||||
description: The challenge nonce for registration.
|
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
$ref: '#/components/schemas/ApiErrorWithRegisterChallenge'
|
||||||
format: uint32
|
|
||||||
x-blah-difficulty:
|
|
||||||
description: The challenge difficulty for registration.
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
format: uint32
|
|
||||||
|
|
||||||
post:
|
post:
|
||||||
summary: Register or update user identity
|
summary: Register or update user identity
|
||||||
|
@ -554,6 +547,24 @@ components:
|
||||||
description: A human-readable error message.
|
description: A human-readable error message.
|
||||||
example: signature verification failed
|
example: signature verification failed
|
||||||
|
|
||||||
|
ApiErrorWithRegisterChallenge:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ApiError'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
register_challenge:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pow:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
nonce:
|
||||||
|
type: integer
|
||||||
|
format: uint32
|
||||||
|
difficulty:
|
||||||
|
type: integer
|
||||||
|
format: uint32
|
||||||
|
|
||||||
RoomList:
|
RoomList:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
@ -869,10 +880,16 @@ components:
|
||||||
id_key:
|
id_key:
|
||||||
type: string
|
type: string
|
||||||
description: Hex encoded user primary key `id_key`.
|
description: Hex encoded user primary key `id_key`.
|
||||||
challenge_nonce:
|
challenge:
|
||||||
type: integer
|
type: object
|
||||||
format: uint32
|
properties:
|
||||||
description: The challenge nonce retrieved from a recent GET response of `/user/me`.
|
pow:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
nonce:
|
||||||
|
type: integer
|
||||||
|
format: uint32
|
||||||
|
description: The challenge nonce retrieved from a recent GET response of `/user/me`.
|
||||||
|
|
||||||
UserIdentityDescription:
|
UserIdentityDescription:
|
||||||
type: object
|
type: object
|
||||||
|
|
|
@ -132,14 +132,28 @@ async function register() {
|
||||||
const getResp = await fetch(`${apiUrl}/user/me`, {
|
const getResp = await fetch(`${apiUrl}/user/me`, {
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
})
|
})
|
||||||
const challenge = parseInt(getResp.headers.get('x-blah-nonce'));
|
if (getResp.status === 204) {
|
||||||
const difficulty = parseInt(getResp.headers.get('x-blah-difficulty'));
|
log('already registered');
|
||||||
if (challenge === null) throw new Error('cannot get challenge nonce');
|
return;
|
||||||
|
}
|
||||||
|
const getRespJson = await getResp.json();
|
||||||
|
if (getResp.status !== 404) {
|
||||||
|
throw new Error(`failed to get user info, status ${getResp.status}: ${getRespJson.error.message}`);
|
||||||
|
}
|
||||||
|
const challenge_nonce = getRespJson?.register_challenge?.pow?.nonce;
|
||||||
|
if (!challenge_nonce) {
|
||||||
|
throw new Error(`cannot get challenge nonce: ${getRespJson.error.message}`);
|
||||||
|
}
|
||||||
|
const difficulty = getRespJson.register_challenge.pow.difficulty;
|
||||||
|
|
||||||
log('solving challenge')
|
log('solving challenge')
|
||||||
const postResp = await signAndPost(`${apiUrl}/user/me`, {
|
const postResp = await signAndPost(`${apiUrl}/user/me`, {
|
||||||
// sorted fields.
|
// sorted fields.
|
||||||
challenge_nonce: challenge,
|
challenge: {
|
||||||
|
pow: {
|
||||||
|
nonce: challenge_nonce,
|
||||||
|
},
|
||||||
|
},
|
||||||
id_key: getIdPubkey(),
|
id_key: getIdPubkey(),
|
||||||
id_url: norm(idUrl),
|
id_url: norm(idUrl),
|
||||||
server_url: norm(apiUrl.replace(/\/_blah\/?$/, '')),
|
server_url: norm(apiUrl.replace(/\/_blah\/?$/, '')),
|
||||||
|
|
Loading…
Add table
Reference in a new issue