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:
oxalica 2024-10-01 05:26:00 -04:00
parent 364e517b7d
commit bc6e6c2056
11 changed files with 206 additions and 130 deletions

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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 {

View file

@ -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()
}
} }
} }

View file

@ -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}")
} }
} }

View file

@ -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(&reg.id_url) { if let Err(err) = st.config.register.validate_id_url(&reg.id_url) {
return Err(ApiError::Disabled(err)); return Err(ApiError::Disabled(err));
} }
api_ensure!(
st.register.nonce().contains(&reg.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");

View file

@ -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();

View file

@ -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

View file

@ -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\/?$/, '')),