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
+134
View File
@@ -269,6 +269,65 @@
]
}
},
"/auth/nostr/register": {
"post": {
"tags": [
"auth"
],
"summary": "npub-native registration. Verifies a NIP-98 signed-action over a server nonce,\nre-runs the abuse gates keyed on the npub (a create path must not bypass the\nPoW/invite/payment gates that `/auth/extension` enforces), then get-or-creates\nby npub with a `seed_fingerprint` MERGE so a wallet that already has a row\n(BTC-anchored, or previously linked) is never split into two identities.",
"description": "SECURITY NOTE: the proof binds an EMPTY-body descriptor (the same contract as\n`/auth/nostr/link`), so replay is prevented by the single-use nonce and npub\ncontrol is proven, but the chain `keys` are TLS-bound rather than\nproof-bound. Binding the key list into the signed payload is a documented\nhardening follow-up (defends a TLS-breaking active MITM swapping the keys).",
"operationId": "nostr_register",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NostrRegisterRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Wallet registered or authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthResponse"
}
}
}
},
"401": {
"description": "Invalid proof or nonce"
},
"409": {
"description": "Seed already registered under a different Nostr identity"
}
}
}
},
"/auth/nostr/register-challenge": {
"get": {
"tags": [
"auth"
],
"summary": "Issue a single-use nonce for npub-native registration. UNAUTHENTICATED: no\nuser exists yet, so (unlike the link challenge) the nonce is subject-less; the\nregister signed-action binds it purely as replay protection.",
"operationId": "nostr_register_challenge",
"responses": {
"200": {
"description": "Single-use register nonce",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NostrLinkChallengeResponse"
}
}
}
}
}
}
},
"/auth/payment-invoice": {
"post": {
"tags": [
@@ -3088,6 +3147,81 @@
}
}
},
"NostrRegisterRequest": {
"type": "object",
"description": "Register (or resolve) a wallet keyed by its **Nostr identity**: the\nself-sovereign, npub-native create path. Unlike [`nostr_login`] (create-never)\nand [`nostr_link`] (needs a prior BTC-authed JWT), this MINTS a user from the\nnpub alone: no BTC signature is ever required. The npub is the identity anchor;\nthe chain `keys` still ship (for tip addresses + restore) but are NOT the auth\nproof.",
"required": [
"nostr_token",
"nonce",
"keys"
],
"properties": {
"altcha_solution": {
"type": "object"
},
"invite_code": {
"type": [
"string",
"null"
]
},
"keys": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetPublicKey"
},
"description": "Chain public keys (for tip addresses + restore). A `btc` key is expected\nfor pay-to-register invoice binding, but it is no longer the identity."
},
"nonce": {
"type": "string",
"description": "The server-issued single-use nonce (from `/auth/nostr/register-challenge`)."
},
"nostr_token": {
"type": "string",
"description": "`Nostr <base64(event)>` signed-action token proving control of the npub AND\nbinding the server nonce + the `nostr_register` purpose."
},
"payment_invoice_id": {
"type": [
"string",
"null"
]
},
"seed_fingerprint": {
"type": [
"string",
"null"
]
},
"username": {
"type": [
"string",
"null"
]
},
"wallet_birthday": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Wallet creation time (unix seconds), to bound chain scans."
},
"wow_start_height": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"xmr_start_height": {
"type": [
"integer",
"null"
],
"format": "int64"
}
}
},
"OkResponse": {
"type": "object",
"required": [