sec: require proof of key possession on POST /users/register - #363
Merged
Conversation
POST /users/register aceptaba cualquier stellar_address (solo validaba longitud 56) y devolvia un JWT de 24h: cualquiera podia registrar la direccion publica de otra persona antes que ella. Ver el finding "Registro sin prueba de posesion de llave" en AUDIT_MOBILE_MAINNET.md. - Extrae el challenge/response de auth.ts a challenge.service.ts (issueChallenge/verifyAndConsumeChallenge), ahora compartido por /auth/token y /users/register. - register exige challenge+signature y valida con StrKey. - El JWT de registro ahora lleva jti, asi que es revocable desde el primer momento (antes solo lo era tras el primer login). - Frontend: registerUser() hace el mismo baile challenge -> firma -> registro; se elimina generateFallbackAddress de registerUser y getAuthToken (fabricaba direcciones invalidas en vez de fallar). CAMBIO DE CONTRATO DE API: 2 campos nuevos requeridos. Backend y APK deben desplegarse juntos; un APK viejo contra este backend recibe 400 en el registro. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extracted from #344, which has been blocked in
CONFLICTINGsince 2026-07-27. That PR carries four security fixes hostage behind 62 files of map work; this is the third and most serious of them (#361 and #362 cover the other two).The vulnerability
POST /users/registeronmainacceptsstellar_addressandusernameand nothing else. It never verifies that the caller controls the address they are registering.Stellar addresses are public. Anyone can take someone else's address off-chain and register it before they do — address squatting. The victim then finds their own address already taken, bound to an account they do not control.
The challenge/response machinery already existed for
/auth/token(login). It simply was not applied to signup, which is where it matters most.What changed
services/challenge.service.ts(new) — the challenge store, shared by/auth/tokenand/users/register, withissueChallenge()andverifyAndConsumeChallenge(). Challenges are address-bound, expiring and single-use.routes/users.ts— register now requireschallenge+signature, and validates the address withStrKey.isValidEd25519PublicKeybefore anything else.routes/auth.ts— uses the shared module instead of its own local store.frontend/src/services/api.ts—registerUserperforms the challenge/sign round trip before posting.Conflict resolutions, for review
Three blocks conflicted against 44 commits of drift. Each decision:
1. Memory-leak protections were ported, not dropped. The shared module as written had no size cap and no pruning.
mainhad both, from the memory-leak fix in #341 (SEC-16). Moving the store without moving its bounds would have silently reintroduced that leak — an attacker rotating IPs grows the Map without limit.CHALLENGES_MAX_SIZE, oldest-entry eviction and the 60s prune interval with.unref()are now inchallenge.service.ts, with a comment naming their origin so they do not get deleted again as apparent duplication.2.
phoneHashwas preserved.maingainedregisterUser(username, phoneHash?)from the anti-abuse work in #319, andRegister.tsx:53passes it. It is forwarded untouched, orthogonal to key possession.3. Map work was excluded. The conflicting hunk also carried
MerchantLocation/updateMerchantLocation, which are not onmainand belong to the map feature. They stay in #344; this PR is security only.Also dropped
generateFallbackAddressfromregisterUser: a synthetic address whose key the device does not hold could never sign the challenge, so it would fail server-side regardless.Tests
The test file that came with the original commit was not running correctly. It declared
MOCK_STELLAR=truein its docstring but never set it, and that default changed onmain, so it failed oninvalid checksum. It is now self-sufficient via a dynamic import after setting the env —configis built at import time, so a static import evaluates too early.More importantly, those tests run in mock mode, where signature bytes are not verified at all. They cover address binding, expiry and single-use, but not the actual cryptography. Added
challengeSignature.test.ts, which runs withMOCK_STELLAR=false:It lives in its own file on purpose:
configis cached per process, so both modes cannot coexist in one run. A dynamic-import query trick appears to work but does not — it creates a fresh service module that still resolves the cachedconfig, so verification stays skipped and the test passes without testing anything.Behaviour change worth knowing
registerUsernow requires a device keypair to exist. Of the three call sites,Register.tsx:53generates one first andrecoverSessionis keypair-based by definition. The third is the demo-mode auto-provision atApp.tsx:876, which does not generate one in that block and will now throw instead of creating a throwaway user. That seems correct — provisioning a user against an address nobody controls is exactly what this PR prevents — but if demo mode is in use, it needs agenerateAndStoreKeypair()call there.