mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
security(auth): harden registration/identity against verified review findings
Adversarial review of the auth/registration surface (findings tracked privately). Backend half: - F2: npub-native register no longer binds an npub onto an existing wallet row on a seed_fingerprint match alone (unauthenticated account takeover). get_or_create_user_by_nostr_pubkey fails closed (409); linking an npub goes through the authenticated /auth/nostr/link flow. Integration test rewritten to assert the refuse-and-409 contract. - F3: `all`-mode gate verifies payment is settled (non-consuming) BEFORE consuming the single-use invite, so a still-confirming payment can't permanently burn the invite. verify_payment_settled split out. - F4: username availability is pre-checked before any gate token is consumed; collisions return 409, not 500 — a taken username no longer burns a paid invoice / invite. - F6: nostr_register now persists the submitted chain keys, so receive-by-username and restore self-verification work for npub wallets. - F10: the pay-to-register not-yet-settled response carries a stable PAYMENT_PENDING code so polling clients don't parse a free-text string. Validated against Postgres: full suite 283 passed / 0 failed.
This commit is contained in:
+85
-13
@@ -483,6 +483,8 @@ pub async fn extension_register(
|
|||||||
returning_by_pubkey,
|
returning_by_pubkey,
|
||||||
req.altcha_solution.as_ref(),
|
req.altcha_solution.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
|
// Reject a taken username BEFORE consuming any single-use gate token.
|
||||||
|
ensure_username_available(&state, returning_by_pubkey, req.username.as_deref()).await?;
|
||||||
// Invite + payment gates, composed per the operator's gate mode (`all` =
|
// Invite + payment gates, composed per the operator's gate mode (`all` =
|
||||||
// every enabled gate; `any` = one-of). PoW above is orthogonal to both.
|
// every enabled gate; `any` = one-of). PoW above is orthogonal to both.
|
||||||
enforce_method_gates(
|
enforce_method_gates(
|
||||||
@@ -649,8 +651,16 @@ async fn enforce_method_gates(
|
|||||||
has_payment,
|
has_payment,
|
||||||
) {
|
) {
|
||||||
GatePlan::Open => Ok(()),
|
GatePlan::Open => Ok(()),
|
||||||
// Conjunction: run both (each self-disables when its own flag is off).
|
// Conjunction: both enabled gates must pass (each self-disables when its
|
||||||
|
// own flag is off). Verify the payment gate is redeemable (settled,
|
||||||
|
// NON-consuming) BEFORE consuming the single-use invite, so a payment that
|
||||||
|
// is still confirming — the common poll state — can never burn the invite.
|
||||||
|
// Residual: a concurrent double-spend of the invoice between this check and
|
||||||
|
// the consume below could still burn the invite; that window is far
|
||||||
|
// narrower than the "still confirming" case. Full atomicity would require
|
||||||
|
// consuming both tokens + the user INSERT in one shared DB transaction.
|
||||||
GatePlan::All => {
|
GatePlan::All => {
|
||||||
|
verify_payment_settled(state, returning, pubkey_hash, payment_invoice_id).await?;
|
||||||
enforce_invite(state, returning, invite_code).await?;
|
enforce_invite(state, returning, invite_code).await?;
|
||||||
enforce_payment(state, returning, pubkey_hash, payment_invoice_id).await?;
|
enforce_payment(state, returning, pubkey_hash, payment_invoice_id).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -782,8 +792,44 @@ async fn enforce_payment(
|
|||||||
pubkey_hash: &str,
|
pubkey_hash: &str,
|
||||||
payment_invoice_id: Option<&str>,
|
payment_invoice_id: Option<&str>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
if returning || !state.config.registration.payment.require_payment {
|
// Verify (non-consuming) then consume. Callers that must confirm the gate is
|
||||||
|
// redeemable BEFORE committing ANOTHER single-use token (the `All` gate plan
|
||||||
|
// pairs this with the invite) reuse `verify_payment_settled` first so a
|
||||||
|
// still-confirming payment can never cost the invite.
|
||||||
|
let Some(id) =
|
||||||
|
verify_payment_settled(state, returning, pubkey_hash, payment_invoice_id).await?
|
||||||
|
else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Atomic single-use consume: exactly one registration per invoice, even under
|
||||||
|
// a concurrent race (the loser gets the already-used literal).
|
||||||
|
if !state.db.consume_payment_invoice(&id, pubkey_hash).await? {
|
||||||
|
return Err(AppError::ValidationError(
|
||||||
|
"This payment invoice has already been used.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
gate = "payment",
|
||||||
|
"payment invoice settled + consumed (new user)"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-consuming half of the pay-to-register gate: confirm the invoice exists,
|
||||||
|
/// is bound to THIS `pubkey_hash`, is unspent, and reads `Settled` from the
|
||||||
|
/// processor. Returns `Ok(Some(id))` when ready to consume, `Ok(None)` when the
|
||||||
|
/// gate does not apply (returning user / gate off). Splitting the check from the
|
||||||
|
/// consume lets the `all`-mode plan verify payment is settled BEFORE it burns the
|
||||||
|
/// single-use invite — a pending payment must never cost the invite.
|
||||||
|
async fn verify_payment_settled(
|
||||||
|
state: &AppState,
|
||||||
|
returning: bool,
|
||||||
|
pubkey_hash: &str,
|
||||||
|
payment_invoice_id: Option<&str>,
|
||||||
|
) -> Result<Option<String>, AppError> {
|
||||||
|
if returning || !state.config.registration.payment.require_payment {
|
||||||
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Gate on but no provider built — config validation prevents this, so it is
|
// Gate on but no provider built — config validation prevents this, so it is
|
||||||
// an internal misconfiguration (generic 500), not a client-facing error.
|
// an internal misconfiguration (generic 500), not a client-facing error.
|
||||||
@@ -837,7 +883,10 @@ async fn enforce_payment(
|
|||||||
// binding anchor; a foreign or underpaid invoice never reaches this point).
|
// binding anchor; a foreign or underpaid invoice never reaches this point).
|
||||||
let invoice = provider.get_invoice(id).await?;
|
let invoice = provider.get_invoice(id).await?;
|
||||||
if invoice.status != crate::infra::payment::InvoiceStatus::Settled {
|
if invoice.status != crate::infra::payment::InvoiceStatus::Settled {
|
||||||
return Err(AppError::ValidationError(
|
// Distinct machine-readable code (`PAYMENT_PENDING`) so a polling client
|
||||||
|
// can tell "keep waiting" from a terminal failure without matching on the
|
||||||
|
// human string.
|
||||||
|
return Err(AppError::PaymentPending(
|
||||||
"Payment not yet confirmed. Complete the payment and retry.".into(),
|
"Payment not yet confirmed. Complete the payment and retry.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -851,18 +900,33 @@ async fn enforce_payment(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(Some(id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
// Atomic single-use consume: exactly one registration per invoice, even under
|
/// Reject a NEW registration whose requested username is already taken BEFORE any
|
||||||
// a concurrent race (the loser gets the already-used literal).
|
/// single-use gate token is consumed, so a collision can't burn a paid invoice /
|
||||||
if !state.db.consume_payment_invoice(id, pubkey_hash).await? {
|
/// invite with no account created. Returning users are exempt (get-or-create
|
||||||
return Err(AppError::ValidationError(
|
/// only backfills NULL optionals and never re-points an existing username).
|
||||||
"This payment invoice has already been used.".into(),
|
///
|
||||||
));
|
/// Not fully atomic: a concurrent claim can still race the final `create_user`
|
||||||
|
/// INSERT between this read and the write. That narrow race is the price of not
|
||||||
|
/// threading a shared transaction through the gate consume + user INSERT; it is
|
||||||
|
/// far rarer than a user simply picking an already-taken name, which this closes.
|
||||||
|
async fn ensure_username_available(
|
||||||
|
state: &AppState,
|
||||||
|
returning: bool,
|
||||||
|
username: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if returning {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(name) = username.map(str::trim).filter(|n| !n.is_empty()) {
|
||||||
|
if state.db.get_user_by_username(name).await?.is_some() {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"That username is already taken. Choose another.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
info!(
|
|
||||||
gate = "payment",
|
|
||||||
"payment invoice settled + consumed (new user)"
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1646,6 +1710,8 @@ pub async fn nostr_register(
|
|||||||
// invoices bind to sha256(btc pubkey), so the payment check still uses the
|
// 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.
|
// BTC key carried in `keys` even though it is no longer the identity.
|
||||||
enforce_pow(&state, &pubkey, returning, req.altcha_solution.as_ref())?;
|
enforce_pow(&state, &pubkey, returning, req.altcha_solution.as_ref())?;
|
||||||
|
// Reject a taken username BEFORE consuming any single-use gate token.
|
||||||
|
ensure_username_available(&state, returning, req.username.as_deref()).await?;
|
||||||
let btc_hash = req
|
let btc_hash = req
|
||||||
.keys
|
.keys
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1676,6 +1742,12 @@ pub async fn nostr_register(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// 5b. Persist the submitted chain keys (tip addresses + restore anchors).
|
||||||
|
// The BTC path does this at registration; the npub path previously
|
||||||
|
// skipped it, leaving user_keys empty so receive-by-username lookups and
|
||||||
|
// restore self-verification broke for npub-native wallets. Idempotent.
|
||||||
|
upsert_all_keys(&state, user.id, &req.keys).await?;
|
||||||
|
|
||||||
// 6. Session.
|
// 6. Session.
|
||||||
let pair = issue_session(
|
let pair = issue_session(
|
||||||
&state,
|
&state,
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ pub enum AppError {
|
|||||||
Forbidden(String),
|
Forbidden(String),
|
||||||
/// Conflict: a unique resource (e.g. a username) is already taken.
|
/// Conflict: a unique resource (e.g. a username) is already taken.
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
/// Pay-to-register invoice exists and is bound but has not settled yet. A
|
||||||
|
/// SAFE variant with its OWN machine-readable code (`PAYMENT_PENDING`) so a
|
||||||
|
/// polling client can distinguish "keep waiting" from a terminal failure
|
||||||
|
/// without matching on the human-readable string.
|
||||||
|
PaymentPending(String),
|
||||||
/// Rate limit exceeded.
|
/// Rate limit exceeded.
|
||||||
RateLimited,
|
RateLimited,
|
||||||
/// Internal server error.
|
/// Internal server error.
|
||||||
@@ -49,6 +54,7 @@ impl std::fmt::Display for AppError {
|
|||||||
Self::NotFound(msg) => write!(f, "Not found: {}", msg),
|
Self::NotFound(msg) => write!(f, "Not found: {}", msg),
|
||||||
Self::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
|
Self::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
|
||||||
Self::Conflict(msg) => write!(f, "Conflict: {}", msg),
|
Self::Conflict(msg) => write!(f, "Conflict: {}", msg),
|
||||||
|
Self::PaymentPending(msg) => write!(f, "Payment pending: {}", msg),
|
||||||
Self::RateLimited => write!(f, "Rate limited"),
|
Self::RateLimited => write!(f, "Rate limited"),
|
||||||
Self::Internal(msg) => write!(f, "Internal error: {}", msg),
|
Self::Internal(msg) => write!(f, "Internal error: {}", msg),
|
||||||
}
|
}
|
||||||
@@ -69,6 +75,7 @@ impl AppError {
|
|||||||
AppError::NotFound(_) => "NOT_FOUND",
|
AppError::NotFound(_) => "NOT_FOUND",
|
||||||
AppError::Forbidden(_) => "FORBIDDEN",
|
AppError::Forbidden(_) => "FORBIDDEN",
|
||||||
AppError::Conflict(_) => "CONFLICT",
|
AppError::Conflict(_) => "CONFLICT",
|
||||||
|
AppError::PaymentPending(_) => "PAYMENT_PENDING",
|
||||||
AppError::RateLimited => "RATE_LIMITED",
|
AppError::RateLimited => "RATE_LIMITED",
|
||||||
AppError::Internal(_) => "INTERNAL_ERROR",
|
AppError::Internal(_) => "INTERNAL_ERROR",
|
||||||
}
|
}
|
||||||
@@ -126,6 +133,9 @@ impl IntoResponse for AppError {
|
|||||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||||
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
|
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
|
||||||
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||||||
|
// Keep 400 (not 402) so a legacy client still polling on the status +
|
||||||
|
// string keeps working during rollout; new clients match `code`.
|
||||||
|
AppError::PaymentPending(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||||
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limited".to_string()),
|
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limited".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -254,4 +264,17 @@ mod tests {
|
|||||||
assert_eq!(json["error"].as_str().unwrap(), msg);
|
assert_eq!(json["error"].as_str().unwrap(), msg);
|
||||||
assert_eq!(json["code"].as_str().unwrap(), "CONFLICT");
|
assert_eq!(json["code"].as_str().unwrap(), "CONFLICT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn payment_pending_has_stable_code_and_400() {
|
||||||
|
// A still-confirming pay-to-register invoice: 400 (so legacy string-poll
|
||||||
|
// clients keep working) but with the stable PAYMENT_PENDING code new
|
||||||
|
// clients match on to distinguish "keep waiting" from a terminal error.
|
||||||
|
let msg = "Payment not yet confirmed. Complete the payment and retry.";
|
||||||
|
let err = AppError::PaymentPending(msg.into());
|
||||||
|
let (status, json) = extract_json(err.into_response()).await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(json["error"].as_str().unwrap(), msg);
|
||||||
|
assert_eq!(json["code"].as_str().unwrap(), "PAYMENT_PENDING");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-21
@@ -357,27 +357,25 @@ impl Database {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let sql = format!(
|
// SECURITY: the seed_fingerprint is a lookup handle, not
|
||||||
"UPDATE users SET \
|
// a credential — the client transmits it unauthenticated on
|
||||||
nostr_pubkey = $2, \
|
// /auth/check-restore and register, and it is stored server-
|
||||||
wallet_birthday = COALESCE(wallet_birthday, $3), \
|
// side. Binding an npub onto a pre-existing (BTC-anchored /
|
||||||
xmr_start_height = COALESCE(xmr_start_height, $4), \
|
// link-less) row on a fingerprint MATCH ALONE would let anyone
|
||||||
wow_start_height = COALESCE(wow_start_height, $5), \
|
// who learns a victim's fingerprint take over that account and
|
||||||
updated_at = NOW() \
|
// be issued a session for it. The BTC rotation path guards the
|
||||||
WHERE id = $1 RETURNING {USER_COLS}"
|
// identical "known fingerprint at a new key" operation behind a
|
||||||
);
|
// signature over the ON-FILE key; this register endpoint proves
|
||||||
let updated = sqlx::query_as::<_, User>(&sql)
|
// control only of the NEW npub, never the on-file key, so it
|
||||||
.bind(existing.id)
|
// MUST NOT re-point the row. Fail closed exactly like the
|
||||||
.bind(nostr_pubkey)
|
// different-npub arm above: the wallet links its npub through
|
||||||
.bind(wallet_birthday)
|
// the authenticated POST /auth/nostr/link flow (which requires
|
||||||
.bind(xmr_start_height)
|
// an existing session proving the on-file key) instead.
|
||||||
.bind(wow_start_height)
|
return Err(AppError::Conflict(
|
||||||
.fetch_one(self.pool())
|
"This wallet already has an account. Sign in with your existing \
|
||||||
.await
|
credentials and link your Nostr identity from settings."
|
||||||
.map_err(unique_violation_as(
|
.into(),
|
||||||
"This Nostr identity is already linked to another account",
|
));
|
||||||
))?;
|
|
||||||
return Ok(updated);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-12
@@ -1,7 +1,9 @@
|
|||||||
//! L1 integration: npub-native registration (`POST /auth/nostr/register`) — the
|
//! L1 integration: npub-native registration (`POST /auth/nostr/register`) — the
|
||||||
//! create-by-npub path (no BTC signature) and the `seed_fingerprint` MERGE dedup
|
//! create-by-npub path (no BTC signature) and the `seed_fingerprint` handling.
|
||||||
//! that must never split one wallet into two identities. Skips without
|
//! A fingerprint match onto a pre-existing row is REFUSED: the endpoint
|
||||||
//! `TEST_DATABASE_URL` (see `common`).
|
//! proves only npub control, so it must not bind an npub onto an account keyed by
|
||||||
|
//! another (on-file) credential without proof. Skips without `TEST_DATABASE_URL`
|
||||||
|
//! (see `common`).
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
@@ -147,7 +149,14 @@ async fn register_replays_are_rejected() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_merges_onto_existing_seed_fingerprint_row() {
|
async fn register_refuses_to_bind_npub_onto_existing_seed_fingerprint_row_without_proof() {
|
||||||
|
// SECURITY: the npub-native register endpoint proves control of the NEW
|
||||||
|
// npub only, never of the key ALREADY ON FILE. seed_fingerprint is a lookup
|
||||||
|
// handle the client sends unauthenticated (check-restore / register), not a
|
||||||
|
// credential — so binding an npub onto a pre-existing BTC-anchored row on a
|
||||||
|
// fingerprint MATCH alone would let anyone who learns a victim's fingerprint
|
||||||
|
// take over that account. The endpoint must fail closed (409); the wallet
|
||||||
|
// links its npub through the authenticated POST /auth/nostr/link flow instead.
|
||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
@@ -174,21 +183,31 @@ async fn register_merges_onto_existing_seed_fingerprint_row() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(existing.nostr_pubkey.is_none());
|
assert!(existing.nostr_pubkey.is_none());
|
||||||
|
|
||||||
// Register npub-native with the SAME seed -> MERGE onto that row, not a new one.
|
// Register npub-native with the SAME seed -> REJECTED (no unauthenticated merge).
|
||||||
let sk = random_signer();
|
let sk = random_signer();
|
||||||
let pk = hex::encode(sk.verifying_key().to_bytes());
|
let pk = hex::encode(sk.verifying_key().to_bytes());
|
||||||
let (status, body) = register(&app, &sk, &base, Some(&fp)).await;
|
let (status, body) = register(&app, &sk, &base, Some(&fp)).await;
|
||||||
assert_eq!(status, StatusCode::OK, "body={body}");
|
assert_eq!(status, StatusCode::CONFLICT, "body={body}");
|
||||||
|
|
||||||
let merged = app
|
// The existing row is left UNTOUCHED and the npub binds to nothing.
|
||||||
|
assert!(
|
||||||
|
app.state
|
||||||
|
.db
|
||||||
|
.find_user_by_nostr_pubkey(&pk)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
|
"a rejected register must not bind the npub to any account"
|
||||||
|
);
|
||||||
|
let after = app
|
||||||
.state
|
.state
|
||||||
.db
|
.db
|
||||||
.find_user_by_nostr_pubkey(&pk)
|
.get_user(existing.id)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("npub resolves after register");
|
.expect("existing row still present");
|
||||||
assert_eq!(
|
assert!(
|
||||||
merged.id, existing.id,
|
after.nostr_pubkey.is_none(),
|
||||||
"npub register must MERGE onto the existing seed_fingerprint row, not split identity"
|
"the victim's row must not have gained an npub"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user