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