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:
jwinterm
2026-07-06 04:23:11 -04:00
parent 1232a80cc8
commit c802f416cd
4 changed files with 158 additions and 46 deletions
+31 -12
View File
@@ -1,7 +1,9 @@
//! L1 integration: npub-native registration (`POST /auth/nostr/register`) — the
//! create-by-npub path (no BTC signature) and the `seed_fingerprint` MERGE dedup
//! that must never split one wallet into two identities. Skips without
//! `TEST_DATABASE_URL` (see `common`).
//! create-by-npub path (no BTC signature) and the `seed_fingerprint` handling.
//! A fingerprint match onto a pre-existing row is REFUSED: the endpoint
//! 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;
@@ -147,7 +149,14 @@ async fn register_replays_are_rejected() {
}
#[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 base = app
.state
@@ -174,21 +183,31 @@ async fn register_merges_onto_existing_seed_fingerprint_row() {
.unwrap();
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 pk = hex::encode(sk.verifying_key().to_bytes());
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
.db
.find_user_by_nostr_pubkey(&pk)
.get_user(existing.id)
.await
.unwrap()
.expect("npub resolves after register");
assert_eq!(
merged.id, existing.id,
"npub register must MERGE onto the existing seed_fingerprint row, not split identity"
.expect("existing row still present");
assert!(
after.nostr_pubkey.is_none(),
"the victim's row must not have gained an npub"
);
}