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::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 criterion::{criterion_group, criterion_main, Criterion};
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
@ -24,7 +24,7 @@ fn bench_register_pow(c: &mut Criterion) {
|
|||
id_key: id_key.clone(),
|
||||
server_url: "http://some.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 {
|
||||
nonce: 0,
|
||||
|
|
|
@ -58,7 +58,17 @@ pub struct UserRegisterPayload {
|
|||
pub server_url: Url,
|
||||
pub id_url: IdUrl,
|
||||
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.
|
||||
|
|
|
@ -6,9 +6,6 @@ use url::Url;
|
|||
use crate::msg::{Id, MemberPermission, RoomAttrs, SignedChatMsgWithId};
|
||||
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.
|
||||
///
|
||||
/// It should be relatively stable and do not change very often.
|
||||
|
@ -37,6 +34,18 @@ pub struct ServerCapabilities {
|
|||
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)]
|
||||
pub struct RoomMetadata {
|
||||
/// Room id.
|
||||
|
|
|
@ -68,14 +68,6 @@ event_queue_len = 1024
|
|||
# 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
|
||||
|
||||
|
@ -89,3 +81,15 @@ 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
|
||||
|
||||
# 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 rusqlite::{named_params, params, prepare_cached_and_bind, Connection, OpenFlags, Row};
|
||||
use serde::Deserialize;
|
||||
use serde_inline_default::serde_inline_default;
|
||||
|
||||
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
|
||||
const APPLICATION_ID: i32 = 0xd9e_8405;
|
||||
|
||||
#[serde_inline_default]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
|
|
|
@ -6,7 +6,7 @@ use anyhow::Result;
|
|||
use axum::body::Bytes;
|
||||
use axum::extract::{ws, OriginalUri};
|
||||
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::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
|
@ -16,15 +16,13 @@ use blah_types::msg::{
|
|||
MemberPermission, RoomAdminOp, RoomAdminPayload, RoomAttrs, ServerPermission,
|
||||
SignedChatMsgWithId, UserRegisterPayload,
|
||||
};
|
||||
use blah_types::server::{
|
||||
RoomMetadata, ServerCapabilities, ServerMetadata, X_BLAH_DIFFICULTY, X_BLAH_NONCE,
|
||||
};
|
||||
use blah_types::server::{RoomMetadata, ServerCapabilities, ServerMetadata, UserRegisterChallenge};
|
||||
use blah_types::{get_timestamp, Id, Signed, UserKey};
|
||||
use data_encoding::BASE64_NOPAD;
|
||||
use database::{Transaction, TransactionOps};
|
||||
use feed::FeedData;
|
||||
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 serde::{Deserialize, Deserializer, Serialize};
|
||||
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 `*`.
|
||||
.layer(
|
||||
tower_http::cors::CorsLayer::permissive()
|
||||
.allow_headers([HeaderName::from_static("*"), header::AUTHORIZATION])
|
||||
.expose_headers([
|
||||
HeaderName::from_static(X_BLAH_NONCE),
|
||||
HeaderName::from_static(X_BLAH_DIFFICULTY),
|
||||
]),
|
||||
.allow_headers([HeaderName::from_static("*"), header::AUTHORIZATION]),
|
||||
)
|
||||
.with_state(st);
|
||||
Router::new().nest("/_blah", router)
|
||||
|
@ -220,10 +214,7 @@ async fn handle_ws(State(st): ArcState, ws: WebSocketUpgrade) -> Response {
|
|||
})
|
||||
}
|
||||
|
||||
async fn user_get(
|
||||
State(st): ArcState,
|
||||
auth: MaybeAuth,
|
||||
) -> Result<StatusCode, (HeaderMap, ApiError)> {
|
||||
async fn user_get(State(st): ArcState, auth: MaybeAuth) -> Response {
|
||||
let ret = (|| {
|
||||
match auth.into_optional()? {
|
||||
None => None,
|
||||
|
@ -232,9 +223,27 @@ async fn user_get(
|
|||
.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 {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(err) => Err((st.register.challenge_headers(), err)),
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
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 {
|
||||
fn to_response_tuple(&self) -> (StatusCode, &'static str, &str) {
|
||||
paste::paste! {
|
||||
pub fn to_raw(&self) -> (StatusCode, RawApiError<'_>) {
|
||||
let (status, code, message): (StatusCode, &str, &str) = paste::paste! {
|
||||
match self {
|
||||
$(
|
||||
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 {
|
||||
($assertion:expr, $msg:literal $(,)?) => {
|
||||
if !$assertion {
|
||||
|
@ -95,19 +102,11 @@ impl IntoResponse for ApiError {
|
|||
fn into_response(self) -> Response {
|
||||
#[derive(Serialize)]
|
||||
struct Resp<'a> {
|
||||
error: Error<'a>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
struct Error<'a> {
|
||||
code: &'a str,
|
||||
message: &'a str,
|
||||
error: RawApiError<'a>,
|
||||
}
|
||||
|
||||
let (status, code, message) = self.to_response_tuple();
|
||||
let mut resp = Json(Resp {
|
||||
error: Error { code, message },
|
||||
})
|
||||
.into_response();
|
||||
let (status, error) = self.to_raw();
|
||||
let mut resp = Json(Resp { error }).into_response();
|
||||
*resp.status_mut() = status;
|
||||
resp
|
||||
}
|
||||
|
@ -115,7 +114,7 @@ impl IntoResponse for ApiError {
|
|||
|
||||
impl fmt::Display for ApiError {
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,10 @@ use std::num::NonZero;
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, ensure};
|
||||
use axum::http::{HeaderMap, HeaderName, StatusCode};
|
||||
use axum::http::StatusCode;
|
||||
use blah_types::identity::{IdUrl, UserIdentityDesc};
|
||||
use blah_types::msg::UserRegisterPayload;
|
||||
use blah_types::server::{X_BLAH_DIFFICULTY, X_BLAH_NONCE};
|
||||
use blah_types::msg::{UserRegisterChallengeResponse, UserRegisterPayload};
|
||||
use blah_types::server::UserRegisterChallenge;
|
||||
use blah_types::{get_timestamp, Signed};
|
||||
use http_body_util::BodyExt;
|
||||
use parking_lot::Mutex;
|
||||
|
@ -23,8 +23,6 @@ use crate::{ApiError, AppState, SERVER_AND_VERSION};
|
|||
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,
|
||||
|
@ -32,6 +30,8 @@ pub struct Config {
|
|||
pub unsafe_allow_id_url_http: bool,
|
||||
pub unsafe_allow_id_url_custom_port: bool,
|
||||
pub unsafe_allow_id_url_single_label: bool,
|
||||
|
||||
pub challenge: ChallengeConfig,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
@ -39,8 +39,7 @@ impl Default for Config {
|
|||
Self {
|
||||
enable_public: false,
|
||||
|
||||
difficulty: 16,
|
||||
nonce_rotate_secs: 60.try_into().expect("not zero"),
|
||||
challenge: ChallengeConfig::default(),
|
||||
request_timeout_secs: 5,
|
||||
|
||||
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 {
|
||||
/// Check if the Identity URL is valid under the config.
|
||||
/// This only does additional checking besides rules of [`IdUrl`].
|
||||
|
@ -79,7 +96,7 @@ pub struct State {
|
|||
client: reqwest::Client,
|
||||
|
||||
epoch: Instant,
|
||||
config: Config,
|
||||
nonce_rotate_secs: NonZero<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
@ -98,6 +115,9 @@ impl State {
|
|||
.timeout(Duration::from_secs(config.request_timeout_secs))
|
||||
.build()
|
||||
.expect("initialize TLS");
|
||||
let ChallengeConfig::Pow {
|
||||
nonce_rotate_secs, ..
|
||||
} = config.challenge;
|
||||
Self {
|
||||
nonces: Nonces {
|
||||
nonce: OsRng.next_u32(),
|
||||
|
@ -106,14 +126,15 @@ impl State {
|
|||
}
|
||||
.into(),
|
||||
client,
|
||||
|
||||
epoch: Instant::now(),
|
||||
config,
|
||||
nonce_rotate_secs,
|
||||
}
|
||||
}
|
||||
|
||||
fn nonce(&self) -> [u32; 2] {
|
||||
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();
|
||||
if n.update_period == cur_period {
|
||||
[n.nonce, n.prev_nonce]
|
||||
|
@ -129,21 +150,12 @@ impl State {
|
|||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
),
|
||||
])
|
||||
pub fn challenge(&self, config: &Config) -> Option<UserRegisterChallenge> {
|
||||
let ChallengeConfig::Pow { difficulty, .. } = config.challenge;
|
||||
config.enable_public.then(|| UserRegisterChallenge::Pow {
|
||||
nonce: self.nonce()[0],
|
||||
difficulty,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -162,14 +174,16 @@ pub async fn user_register(
|
|||
if let Err(err) = st.config.register.validate_id_url(®.id_url) {
|
||||
return Err(ApiError::Disabled(err));
|
||||
}
|
||||
api_ensure!(
|
||||
st.register.nonce().contains(®.challenge_nonce),
|
||||
"invalid challenge nonce",
|
||||
);
|
||||
|
||||
// Challenge verification.
|
||||
let expect_bits = st.register.config.difficulty;
|
||||
if expect_bits > 0 {
|
||||
let ChallengeConfig::Pow { difficulty, .. } = st.config.register.challenge;
|
||||
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 signee = msg.canonical_signee();
|
||||
let mut h = Sha256::new();
|
||||
|
@ -178,7 +192,7 @@ pub async fn user_register(
|
|||
};
|
||||
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 (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.
|
||||
let ok = hash[..bytes].iter().all(|&b| b == 0) && (hash[bytes] as u32) >> (8 - bits) == 0;
|
||||
api_ensure!(ok, "hash challenge failed");
|
||||
|
|
|
@ -8,14 +8,14 @@ use std::sync::{Arc, LazyLock};
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::http::HeaderMap;
|
||||
use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile};
|
||||
use blah_types::msg::{
|
||||
AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, DeleteRoomPayload,
|
||||
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 blahd::{AppState, Database, RoomList, RoomMsgs};
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
@ -52,11 +52,14 @@ max_page_len = 2
|
|||
|
||||
[register]
|
||||
enable_public = true
|
||||
difficulty = {REGISTER_DIFFICULTY}
|
||||
request_timeout_secs = 1
|
||||
unsafe_allow_id_url_http = true
|
||||
unsafe_allow_id_url_custom_port = true
|
||||
unsafe_allow_id_url_single_label = true
|
||||
|
||||
[register.challenge.pow]
|
||||
difficulty = {REGISTER_DIFFICULTY}
|
||||
nonce_rotate_secs = 60
|
||||
"#
|
||||
)
|
||||
};
|
||||
|
@ -98,7 +101,7 @@ trait ResultExt {
|
|||
impl<T: fmt::Debug> ResultExt for Result<T> {
|
||||
#[track_caller]
|
||||
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!(
|
||||
(err.status, &*err.code),
|
||||
(status, code),
|
||||
|
@ -108,7 +111,7 @@ impl<T: fmt::Debug> ResultExt for Result<T> {
|
|||
|
||||
#[track_caller]
|
||||
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!(
|
||||
(err.status, &*err.code, &*err.message),
|
||||
(StatusCode::BAD_REQUEST, "invalid_request", message),
|
||||
|
@ -118,14 +121,14 @@ impl<T: fmt::Debug> ResultExt for Result<T> {
|
|||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiErrorWithHeaders {
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
code: 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 {
|
||||
write!(
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
|
@ -201,13 +204,14 @@ impl Server {
|
|||
async move {
|
||||
let resp = b.send().await?;
|
||||
let status = resp.status();
|
||||
let headers = resp.headers().clone();
|
||||
let resp_str = resp.text().await?;
|
||||
|
||||
if !status.is_success() {
|
||||
#[derive(Deserialize)]
|
||||
struct Resp {
|
||||
error: RespErr,
|
||||
#[serde(default)]
|
||||
register_challenge: Option<UserRegisterChallenge>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct RespErr {
|
||||
|
@ -217,11 +221,11 @@ impl Server {
|
|||
|
||||
let resp = serde_json::from_str::<Resp>(&resp_str)
|
||||
.with_context(|| format!("failed to parse response {resp_str:?}"))?;
|
||||
Err(ApiErrorWithHeaders {
|
||||
Err(ApiError {
|
||||
status,
|
||||
code: resp.error.code,
|
||||
message: resp.error.message,
|
||||
headers,
|
||||
register_challenge: resp.register_challenge,
|
||||
}
|
||||
.into())
|
||||
} else if resp_str.is_empty() {
|
||||
|
@ -364,22 +368,15 @@ impl Server {
|
|||
{
|
||||
Ok(None) => Ok(()),
|
||||
Err(err) => {
|
||||
let err = err.downcast::<ApiErrorWithHeaders>().unwrap();
|
||||
let err = err.downcast::<ApiError>().unwrap();
|
||||
assert_eq!(err.status, StatusCode::NOT_FOUND);
|
||||
if !err.headers.contains_key(X_BLAH_NONCE) {
|
||||
return Err(None);
|
||||
Err(match err.register_challenge {
|
||||
Some(UserRegisterChallenge::Pow { nonce, difficulty }) => {
|
||||
Some((nonce, difficulty))
|
||||
}
|
||||
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();
|
||||
Err(Some((challenge_nonce, difficulty)))
|
||||
Some(UserRegisterChallenge::Unknown) => unreachable!(),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1197,7 +1194,9 @@ async fn register_flow(server: Server) {
|
|||
// Invalid values.
|
||||
server_url: "http://localhost".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>| {
|
||||
server
|
||||
|
@ -1262,7 +1261,9 @@ async fn register_flow(server: Server) {
|
|||
register_fast(&req)
|
||||
.await
|
||||
.expect_invalid_request("invalid challenge nonce");
|
||||
req.challenge_nonce += 1;
|
||||
req.challenge = Some(UserRegisterChallengeResponse::Pow {
|
||||
nonce: challenge_nonce,
|
||||
});
|
||||
|
||||
register(sign_with_difficulty(&req, false))
|
||||
.await
|
||||
|
@ -1408,7 +1409,7 @@ unsafe_allow_id_url_single_label = {allow_single_label}
|
|||
// Unused values.
|
||||
id_url: server_url.parse().unwrap(),
|
||||
server_url: server_url.parse().unwrap(),
|
||||
challenge_nonce: 0,
|
||||
challenge: None,
|
||||
},
|
||||
);
|
||||
let ret = server
|
||||
|
@ -1433,9 +1434,10 @@ async fn register_nonce() {
|
|||
base_url="{BASE_URL}"
|
||||
[register]
|
||||
enable_public = true
|
||||
unsafe_allow_id_url_http = true
|
||||
[register.challenge.pow]
|
||||
difficulty = 64 # Should fail the challenge if nonce matches.
|
||||
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(),
|
||||
server_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)
|
||||
.unwrap();
|
||||
|
|
|
@ -85,17 +85,10 @@ paths:
|
|||
404:
|
||||
description: |
|
||||
The user is not registered, or no token is not provided.
|
||||
headers:
|
||||
x-blah-nonce:
|
||||
description: The challenge nonce for registration.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: integer
|
||||
format: uint32
|
||||
x-blah-difficulty:
|
||||
description: The challenge difficulty for registration.
|
||||
schema:
|
||||
type: integer
|
||||
format: uint32
|
||||
$ref: '#/components/schemas/ApiErrorWithRegisterChallenge'
|
||||
|
||||
post:
|
||||
summary: Register or update user identity
|
||||
|
@ -554,6 +547,24 @@ components:
|
|||
description: A human-readable error message.
|
||||
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:
|
||||
type: object
|
||||
required:
|
||||
|
@ -869,7 +880,13 @@ components:
|
|||
id_key:
|
||||
type: string
|
||||
description: Hex encoded user primary key `id_key`.
|
||||
challenge_nonce:
|
||||
challenge:
|
||||
type: object
|
||||
properties:
|
||||
pow:
|
||||
type: object
|
||||
properties:
|
||||
nonce:
|
||||
type: integer
|
||||
format: uint32
|
||||
description: The challenge nonce retrieved from a recent GET response of `/user/me`.
|
||||
|
|
|
@ -132,14 +132,28 @@ async function register() {
|
|||
const getResp = await fetch(`${apiUrl}/user/me`, {
|
||||
cache: 'no-store'
|
||||
})
|
||||
const challenge = parseInt(getResp.headers.get('x-blah-nonce'));
|
||||
const difficulty = parseInt(getResp.headers.get('x-blah-difficulty'));
|
||||
if (challenge === null) throw new Error('cannot get challenge nonce');
|
||||
if (getResp.status === 204) {
|
||||
log('already registered');
|
||||
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')
|
||||
const postResp = await signAndPost(`${apiUrl}/user/me`, {
|
||||
// sorted fields.
|
||||
challenge_nonce: challenge,
|
||||
challenge: {
|
||||
pow: {
|
||||
nonce: challenge_nonce,
|
||||
},
|
||||
},
|
||||
id_key: getIdPubkey(),
|
||||
id_url: norm(idUrl),
|
||||
server_url: norm(apiUrl.replace(/\/_blah\/?$/, '')),
|
||||
|
|
Loading…
Add table
Reference in a new issue