From bc6e6c20563fd3551a8c2fc878f6e5b98e0781cc Mon Sep 17 00:00:00 2001 From: oxalica Date: Tue, 1 Oct 2024 05:26:00 -0400 Subject: [PATCH] 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. --- blah-types/benches/crypto_ops.rs | 4 +- blah-types/src/msg.rs | 12 ++++- blah-types/src/server.rs | 15 ++++-- blahd/config.example.toml | 20 ++++---- blahd/src/database.rs | 2 - blahd/src/lib.rs | 41 ++++++++++------- blahd/src/middleware.rs | 29 ++++++------ blahd/src/register.rs | 78 +++++++++++++++++++------------- blahd/tests/webapi.rs | 68 ++++++++++++++-------------- docs/webapi.yaml | 45 ++++++++++++------ test-frontend/main.js | 22 +++++++-- 11 files changed, 206 insertions(+), 130 deletions(-) diff --git a/blah-types/benches/crypto_ops.rs b/blah-types/benches/crypto_ops.rs index de17d29..48801be 100644 --- a/blah-types/benches/crypto_ops.rs +++ b/blah-types/benches/crypto_ops.rs @@ -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, diff --git a/blah-types/src/msg.rs b/blah-types/src/msg.rs index 3e1394c..0c3f81c 100644 --- a/blah-types/src/msg.rs +++ b/blah-types/src/msg.rs @@ -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, +} + +/// 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. diff --git a/blah-types/src/server.rs b/blah-types/src/server.rs index 624e92b..a83ded1 100644 --- a/blah-types/src/server.rs +++ b/blah-types/src/server.rs @@ -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. diff --git a/blahd/config.example.toml b/blahd/config.example.toml index b34492b..631e801 100644 --- a/blahd/config.example.toml +++ b/blahd/config.example.toml @@ -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 diff --git a/blahd/src/database.rs b/blahd/src/database.rs index 748f129..c2a64d4 100644 --- a/blahd/src/database.rs +++ b/blahd/src/database.rs @@ -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 = std::result::Result; // `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 { diff --git a/blahd/src/lib.rs b/blahd/src/lib.rs index 0c47d39..23c68f3 100644 --- a/blahd/src/lib.rs +++ b/blahd/src/lib.rs @@ -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) -> 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 { +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, + } + 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() + } } } diff --git a/blahd/src/middleware.rs b/blahd/src/middleware.rs index 4fad9a0..32c157f 100644 --- a/blahd/src/middleware.rs +++ b/blahd/src/middleware.rs @@ -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}") } } diff --git a/blahd/src/register.rs b/blahd/src/register.rs index 5aba54d..915440a 100644 --- a/blahd/src/register.rs +++ b/blahd/src/register.rs @@ -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, 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, + }, +} + +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, } #[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 { + 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"); diff --git a/blahd/tests/webapi.rs b/blahd/tests/webapi.rs index da2f066..58994be 100644 --- a/blahd/tests/webapi.rs +++ b/blahd/tests/webapi.rs @@ -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 ResultExt for Result { #[track_caller] fn expect_api_err(self, status: StatusCode, code: &str) { - let err = self.unwrap_err().downcast::().unwrap(); + let err = self.unwrap_err().downcast::().unwrap(); assert_eq!( (err.status, &*err.code), (status, code), @@ -108,7 +111,7 @@ impl ResultExt for Result { #[track_caller] fn expect_invalid_request(self, message: &str) { - let err = self.unwrap_err().downcast::().unwrap(); + let err = self.unwrap_err().downcast::().unwrap(); assert_eq!( (err.status, &*err.code, &*err.message), (StatusCode::BAD_REQUEST, "invalid_request", message), @@ -118,14 +121,14 @@ impl ResultExt for Result { } #[derive(Debug)] -pub struct ApiErrorWithHeaders { +pub struct ApiError { status: StatusCode, code: String, message: String, - headers: HeaderMap, + register_challenge: Option, } -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, } #[derive(Deserialize)] struct RespErr { @@ -217,11 +221,11 @@ impl Server { let resp = serde_json::from_str::(&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::().unwrap(); + let err = err.downcast::().unwrap(); assert_eq!(err.status, StatusCode::NOT_FOUND); - if !err.headers.contains_key(X_BLAH_NONCE) { - return Err(None); - } - let challenge_nonce = err.headers[X_BLAH_NONCE] - .to_str() - .unwrap() - .parse::() - .unwrap(); - let difficulty = err.headers[X_BLAH_DIFFICULTY] - .to_str() - .unwrap() - .parse::() - .unwrap(); - Err(Some((challenge_nonce, difficulty))) + Err(match err.register_challenge { + Some(UserRegisterChallenge::Pow { nonce, difficulty }) => { + Some((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| { 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(); diff --git a/docs/webapi.yaml b/docs/webapi.yaml index 2650935..cd1ecee 100644 --- a/docs/webapi.yaml +++ b/docs/webapi.yaml @@ -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,10 +880,16 @@ components: 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`. + 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`. UserIdentityDescription: type: object diff --git a/test-frontend/main.js b/test-frontend/main.js index c05a66e..ede5b34 100644 --- a/test-frontend/main.js +++ b/test-frontend/main.js @@ -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\/?$/, '')),