feat(auth): npub-native registration (POST /auth/nostr/register) - Nostr-native identity

Make npub the identity anchor: a wallet can register from its seed-derived Nostr
key alone, no BTC signature. Breaks the link chicken-and-egg (nostr_login is
create-never; nostr_link needs a prior BTC-authed JWT). Additive - /auth/extension
(BTC) and the link flow keep working for legacy backends during the transition.

- get_or_create_user_by_nostr_pubkey (db twin of the BTC path): keys/inserts on
  nostr_pubkey (raw, never peppered), pubkey_hash NULL. THE load-bearing dedup:
  looks up by seed_fingerprint and MERGES the npub onto an existing (BTC-anchored
  or link-less) row rather than splitting one wallet into two identities; a
  different npub already on that row fails closed (409). backfill_optionals shared
  helper.
- POST /auth/nostr/register + GET /auth/nostr/register-challenge: verify a NIP-98
  signed-action over a subject-less single-use nonce (replay-safe), then re-run
  the abuse gates keyed on the npub (a create path must not bypass PoW/invite/
  payment); pay-to-register invoices still bind to the BTC key carried in keys[].
  Empty-body descriptor contract (mirrors /auth/nostr/link); binding the key list
  into the proof is a documented hardening follow-up.
- Housekeeping: get_user_count counts npub-only users; record_login_event labels
  the nostr path.

3 integration KATs (create-by-npub + idempotent, replay rejected, seed_fingerprint
MERGE dedup) pass against a real DB; 211 lib tests pass; openapi regenerated.
This commit is contained in:
jwinterm
2026-07-05 08:02:47 -04:00
parent 6f0fca532d
commit c49872b62b
5 changed files with 669 additions and 5 deletions
+187
View File
@@ -1520,6 +1520,191 @@ pub async fn nostr_link(
}))
}
// ── GET /auth/nostr/register-challenge (issue the register nonce) ─────────────
/// Issue a single-use nonce for npub-native registration. UNAUTHENTICATED: no
/// user exists yet, so (unlike the link challenge) the nonce is subject-less; the
/// register signed-action binds it purely as replay protection.
#[utoipa::path(
get,
path = "/auth/nostr/register-challenge",
responses((status = 200, description = "Single-use register nonce", body = NostrLinkChallengeResponse)),
tag = "auth"
)]
#[instrument(skip(state))]
pub async fn nostr_register_challenge(
State(state): State<Arc<AppState>>,
) -> Result<Json<NostrLinkChallengeResponse>, AppError> {
let nonce = state
.db
.issue_challenge("nostr_register", None, NOSTR_LINK_NONCE_TTL_SECS)
.await?;
Ok(Json(NostrLinkChallengeResponse { nonce }))
}
// ── POST /auth/nostr/register (npub-native create) ───────────────────────────
/// Register (or resolve) a wallet keyed by its **Nostr identity**: the
/// self-sovereign, npub-native create path. Unlike [`nostr_login`] (create-never)
/// and [`nostr_link`] (needs a prior BTC-authed JWT), this MINTS a user from the
/// npub alone: no BTC signature is ever required. The npub is the identity anchor;
/// the chain `keys` still ship (for tip addresses + restore) but are NOT the auth
/// proof.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct NostrRegisterRequest {
/// `Nostr <base64(event)>` signed-action token proving control of the npub AND
/// binding the server nonce + the `nostr_register` purpose.
pub nostr_token: String,
/// The server-issued single-use nonce (from `/auth/nostr/register-challenge`).
pub nonce: String,
/// Chain public keys (for tip addresses + restore). A `btc` key is expected
/// for pay-to-register invoice binding, but it is no longer the identity.
pub keys: Vec<AssetPublicKey>,
pub username: Option<String>,
/// Wallet creation time (unix seconds), to bound chain scans.
pub wallet_birthday: Option<i64>,
pub seed_fingerprint: Option<String>,
pub xmr_start_height: Option<i64>,
pub wow_start_height: Option<i64>,
#[serde(default)]
#[schema(value_type = Object)]
pub altcha_solution: Option<altcha::Payload>,
#[serde(default)]
pub invite_code: Option<String>,
#[serde(default)]
pub payment_invoice_id: Option<String>,
}
/// npub-native registration. Verifies a NIP-98 signed-action over a server nonce,
/// re-runs the abuse gates keyed on the npub (a create path must not bypass the
/// PoW/invite/payment gates that `/auth/extension` enforces), then get-or-creates
/// by npub with a `seed_fingerprint` MERGE so a wallet that already has a row
/// (BTC-anchored, or previously linked) is never split into two identities.
///
/// SECURITY NOTE: the proof binds an EMPTY-body descriptor (the same contract as
/// `/auth/nostr/link`), so replay is prevented by the single-use nonce and npub
/// control is proven, but the chain `keys` are TLS-bound rather than
/// proof-bound. Binding the key list into the signed payload is a documented
/// hardening follow-up (defends a TLS-breaking active MITM swapping the keys).
#[utoipa::path(
post,
path = "/auth/nostr/register",
request_body = NostrRegisterRequest,
responses(
(status = 200, description = "Wallet registered or authenticated", body = AuthResponse),
(status = 401, description = "Invalid proof or nonce"),
(status = 409, description = "Seed already registered under a different Nostr identity")
),
tag = "auth"
)]
#[instrument(skip(state, headers, req, peer))]
pub async fn nostr_register(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<NostrRegisterRequest>,
) -> Result<Json<AuthResponse>, AppError> {
let ip = client_ip(&state, &headers, peer);
if let Some(ref username) = req.username {
validate_username(username)?;
}
// 1. Atomically consume the subject-less register nonce (single-use replay
// guard). Consume BEFORE proof verification so a failed proof burns it.
let _consumed = state
.db
.consume_challenge(&req.nonce, "nostr_register")
.await?
.ok_or_else(|| AppError::AuthError("Invalid or expired nonce".into()))?;
// 2. Prove npub control + bind the nonce/purpose. Empty-body descriptor
// contract (mirrors /auth/nostr/link). The verifier lowercases the pubkey.
let url = nip98_url(&state, "/auth/nostr/register")?;
let descriptor = request_descriptor("POST", "/api/v1/auth/nostr/register", "", b"");
let payload_sha256 = descriptor_sha256(&descriptor);
let pubkey = verify_signed_action(
&req.nostr_token,
&url,
"POST",
"nostr_register",
&req.nonce,
&payload_sha256,
None,
None,
Utc::now().timestamp(),
NIP98_ACTION_MAX_AGE_SECS,
)
.map_err(|_| AppError::AuthError("Invalid Nostr proof".into()))?;
// 3. Returning npub bypasses the abuse gates, exactly like a returning
// pubkey_hash on the BTC path.
let returning = state.db.find_user_by_nostr_pubkey(&pubkey).await?.is_some();
// 4. Re-run the abuse gates on the npub path; a user-minting endpoint must
// not be a gate bypass. PoW keys on the npub (the per-pubkey allowlist is
// BTC-only, so only the GLOBAL requirement applies here). Pay-to-register
// invoices bind to sha256(btc pubkey), so the payment check still uses the
// BTC key carried in `keys` even though it is no longer the identity.
enforce_pow(&state, &pubkey, returning, req.altcha_solution.as_ref())?;
let btc_hash = req
.keys
.iter()
.find(|k| k.asset.eq_ignore_ascii_case("btc"))
.map(|k| hash_public_key(&k.public_key));
enforce_method_gates(
&state,
returning,
btc_hash.as_deref().unwrap_or(&pubkey),
req.invite_code.as_deref(),
req.payment_invoice_id.as_deref(),
)
.await?;
// 5. Get-or-create by npub (seed_fingerprint MERGE dedup inside).
let wallet_birthday = req
.wallet_birthday
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
let user = state
.db
.get_or_create_user_by_nostr_pubkey(
&pubkey,
req.username.clone(),
wallet_birthday,
req.seed_fingerprint.clone(),
req.xmr_start_height,
req.wow_start_height,
)
.await?;
// 6. Session.
let pair = issue_session(
&state,
user.id,
Platform::Nostr,
"Nostr",
Some(IpNetwork::from(ip)),
)
.await?;
let _ = state
.db
.record_login_event(
Some(user.id),
"nostr",
Platform::Nostr.as_str(),
None,
Some(&ip.to_string()),
)
.await;
info!(user_id = %user.id, is_new = %!returning, "npub-native register/auth (NIP-98)");
Ok(Json(AuthResponse {
access_token: pair.access_token,
refresh_token: pair.refresh_token,
expires_in: pair.expires_in,
user: user_info(&user),
is_new: !returning,
}))
}
// ── router ───────────────────────────────────────────────────────────────────
/// Auth routes, RELATIVE to the `/api/v1` mount point. The application is
@@ -1536,6 +1721,8 @@ pub fn routes() -> Router<Arc<AppState>> {
.route("/auth/logout", post(logout))
.route("/auth/me", get(get_me))
.route("/auth/nostr", post(nostr_login))
.route("/auth/nostr/register-challenge", get(nostr_register_challenge))
.route("/auth/nostr/register", post(nostr_register))
.route("/auth/nostr/link-challenge", get(nostr_link_challenge))
.route("/auth/nostr/link", post(nostr_link))
}
+3
View File
@@ -27,6 +27,8 @@ use utoipa::OpenApi;
crate::api::auth::logout,
crate::api::auth::get_me,
crate::api::auth::nostr_login,
crate::api::auth::nostr_register_challenge,
crate::api::auth::nostr_register,
crate::api::auth::nostr_link_challenge,
crate::api::auth::nostr_link,
crate::api::website::website_challenge,
@@ -93,6 +95,7 @@ use utoipa::OpenApi;
crate::api::auth::NostrLinkChallengeResponse,
crate::api::auth::NostrLinkRequest,
crate::api::auth::NostrLinkResponse,
crate::api::auth::NostrRegisterRequest,
crate::api::website::WebsiteChallengeRequest,
crate::api::website::WebsiteChallengeResponse,
crate::api::website::AssetSignature,
+151 -5
View File
@@ -293,6 +293,150 @@ impl Database {
.await
}
/// Get-or-create a user keyed by their **Nostr pubkey**: the npub-native
/// registration path for the self-sovereign backend (no BTC signature).
/// `nostr_pubkey` is lowercase x-only hex, stored RAW (public, never peppered,
/// mirroring [`find_user_by_nostr_pubkey`]).
///
/// DEDUP is load-bearing: a wallet may already have a row from the BTC path or
/// a prior link, joined only by `seed_fingerprint` (derivation-independent). A
/// naive insert would collide on the `seed_fingerprint` UNIQUE and split one
/// wallet into two identities. So, in order:
/// 1. npub already known -> that user (COALESCE-backfill the optional fields).
/// 2. else `seed_fingerprint` matches an existing row -> MERGE: backfill the
/// npub onto THAT row. If the row already carries a DIFFERENT npub the
/// wallet is already registered under another identity -> fail closed.
/// 3. else create a fresh npub-keyed row (`pubkey_hash` NULL).
#[instrument(skip(self))]
pub async fn get_or_create_user_by_nostr_pubkey(
&self,
nostr_pubkey: &str,
username: Option<String>,
wallet_birthday: Option<chrono::DateTime<chrono::Utc>>,
seed_fingerprint: Option<String>,
xmr_start_height: Option<i64>,
wow_start_height: Option<i64>,
) -> Result<User, AppError> {
// 1. Known npub -> that user, backfilling any newly-supplied optionals.
if let Some(existing) = self.find_user_by_nostr_pubkey(nostr_pubkey).await? {
return self
.backfill_optionals(
existing,
wallet_birthday,
seed_fingerprint,
xmr_start_height,
wow_start_height,
)
.await;
}
// 2. DEDUP: same seed already has a row (BTC-anchored or link-less) -> MERGE
// the npub onto it rather than split the identity.
if let Some(fp) = seed_fingerprint.as_deref() {
if let Some(existing) = self.get_user_by_seed_fingerprint(fp).await? {
match existing.nostr_pubkey.as_deref() {
Some(pk) if pk == nostr_pubkey => {
// Row already carries this npub (find-by-npub missed only if
// the columns disagree) so treat as the known user.
return self
.backfill_optionals(
existing,
wallet_birthday,
None,
xmr_start_height,
wow_start_height,
)
.await;
}
Some(_) => {
// Same seed, different npub on file (e.g. a rotation): do not
// silently re-point on an unauthenticated register.
return Err(AppError::Conflict(
"This wallet is already registered under a different Nostr identity. Sign in instead."
.into(),
));
}
None => {
let sql = format!(
"UPDATE users SET \
nostr_pubkey = $2, \
wallet_birthday = COALESCE(wallet_birthday, $3), \
xmr_start_height = COALESCE(xmr_start_height, $4), \
wow_start_height = COALESCE(wow_start_height, $5), \
updated_at = NOW() \
WHERE id = $1 RETURNING {USER_COLS}"
);
let updated = sqlx::query_as::<_, User>(&sql)
.bind(existing.id)
.bind(nostr_pubkey)
.bind(wallet_birthday)
.bind(xmr_start_height)
.bind(wow_start_height)
.fetch_one(self.pool())
.await
.map_err(unique_violation_as(
"This Nostr identity is already linked to another account",
))?;
return Ok(updated);
}
}
}
}
// 3. Fresh npub-keyed identity (no BTC anchor).
self.create_user(NewUser {
username,
pubkey_hash: None,
nostr_pubkey: Some(nostr_pubkey.to_string()),
wallet_birthday,
seed_fingerprint,
xmr_start_height,
wow_start_height,
})
.await
}
/// COALESCE-backfill only the NULL optional fields on an existing user row,
/// returning it unchanged when nothing is newly supplied. Shared by the
/// BTC and npub get-or-create paths.
async fn backfill_optionals(
&self,
existing: User,
wallet_birthday: Option<chrono::DateTime<chrono::Utc>>,
seed_fingerprint: Option<String>,
xmr_start_height: Option<i64>,
wow_start_height: Option<i64>,
) -> Result<User, AppError> {
let needs = existing.wallet_birthday.is_none() && wallet_birthday.is_some()
|| existing.seed_fingerprint.is_none() && seed_fingerprint.is_some()
|| existing.xmr_start_height.is_none() && xmr_start_height.is_some()
|| existing.wow_start_height.is_none() && wow_start_height.is_some();
if !needs {
return Ok(existing);
}
let peppered_fp = seed_fingerprint
.as_deref()
.map(|v| self.pepper("seed_fingerprint", v));
let sql = format!(
"UPDATE users SET \
wallet_birthday = COALESCE(wallet_birthday, $2), \
seed_fingerprint = COALESCE(seed_fingerprint, $3), \
xmr_start_height = COALESCE(xmr_start_height, $4), \
wow_start_height = COALESCE(wow_start_height, $5), \
updated_at = NOW() \
WHERE id = $1 RETURNING {USER_COLS}"
);
let updated = sqlx::query_as::<_, User>(&sql)
.bind(existing.id)
.bind(wallet_birthday)
.bind(peppered_fp)
.bind(xmr_start_height)
.bind(wow_start_height)
.fetch_one(self.pool())
.await?;
Ok(updated)
}
/// Update a user's username. UNIQUE collision -> 409 CONFLICT.
#[instrument(skip(self))]
pub async fn update_username(
@@ -325,13 +469,15 @@ impl Database {
Ok(())
}
/// Count registered wallets (users with a pubkey_hash).
/// Count registered wallets. A wallet is anchored by EITHER a BTC pubkey_hash
/// (legacy/BTC path) OR a nostr_pubkey (npub-native path), so count both; an
/// npub-only user is still a registered wallet.
#[instrument(skip(self))]
pub async fn get_user_count(&self) -> Result<i64, AppError> {
Ok(
sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE pubkey_hash IS NOT NULL")
.fetch_one(self.pool())
.await?,
Ok(sqlx::query_scalar(
"SELECT COUNT(*) FROM users WHERE pubkey_hash IS NOT NULL OR nostr_pubkey IS NOT NULL",
)
.fetch_one(self.pool())
.await?)
}
}