mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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:
+187
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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?)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user