Skip to content

fix: domain-bind wallet signature challenges (#118) - #124

Open
Marvelg256 wants to merge 1 commit into
StepFi-app:mainfrom
Marvelg256:security/118-domain-bound-signatures
Open

fix: domain-bind wallet signature challenges (#118)#124
Marvelg256 wants to merge 1 commit into
StepFi-app:mainfrom
Marvelg256:security/118-domain-bound-signatures

Conversation

@Marvelg256

Copy link
Copy Markdown

🔗 Related Issue

Closes #118


🔖 Title

Domain-bind wallet signature challenges — eliminate cross-service signature replay


📝 Description

AuthService.verifySignature() previously accepted two ad-hoc message formats — raw Ed25519 over the bare nonce hex, and the 'Stellar Signing Key: ' + nonce fallback — neither of which binds a signature to StepFi. An attacker who captured a (nonce, signature) pair from any other dApp, phishing prompt, or service using an identical raw scheme could self-issue the same nonce via POST /auth/nonce and authenticate as the victim. Because the server tried formats in order, the weakest accepted format defined the security floor.

This PR makes every accepted signature provably sign a StepFi-bound challenge:

  • Canonical challenge envelope. generateNonce() now issues a deterministic envelope — domain, address (wallet), statement, uri, version, nonce, issuedAt, expirationTime, networkPassphrase — and returns it as a new message field on the nonce response.
  • Hash-bound nonce rows. Each nonce row stores issued_at and message_hash (SHA-256 of the exact challenge text). Verification only ever runs against a message whose digest matches the stored hash — never against client-supplied alternatives (AUTH_CHALLENGE_MISMATCH otherwise).
  • Strict browser verification. signatureType: 'sep0043' verifies per SEP-53 (signature over SHA-256("Stellar Signed Message:\n" + envelope), which is what Freighter's signMessage() produces) with strict checks: envelope domain == our host, uri matches, network passphrase matches, and the envelope is not expired.
  • Native clients. signatureType: 'envelope' verifies raw Ed25519 over the envelope UTF-8 text.
  • Legacy deprecated. The raw-nonce scheme is gated behind AUTH_ALLOW_LEGACY_RAW_SIGNATURES (default true for mobile-client compatibility) with a documented sunset of 2026-10-31. When disabled, legacy requests fail with AUTH_LEGACY_SIGNATURE_DISABLED.
  • One scheme per request. The server selects exactly one format via signatureType; the old multi-format fallback is removed.

Compatibility choice (per issue ground rules): documented migration window. The legacy scheme stays accepted by default so current StepFi-App builds keep working, and the window is documented in docs/setup/environment-variables.md, .env.example, and the progress tracker.


🔄 Changes Made

  • supabase/migrations/20260825000000_add_nonce_message_binding.sql — add issued_at, message_hash to nonces
  • src/modules/auth/auth.service.ts — canonical envelope issuance, hash-bound verification, SEP-53 browser verification, flag-gated legacy path
  • src/modules/auth/dto/verify-request.dto.tssignatureType gains 'envelope'; optional message field
  • src/modules/auth/dto/nonce-response.dto.ts — nonce response gains message
  • Config plumbing — AUTH_CHALLENGE_DOMAIN, AUTH_ALLOW_LEGACY_RAW_SIGNATURES read via ConfigService; documented in docs/setup/environment-variables.md + .env.example
  • Unit tests — legacy rejected once flag off, sep0043/envelope happy paths, wrong-domain / wrong-network rejection, tampered message, expired envelope, rows without a stored hash, single-use replay
  • E2E tests — canonical envelope + SEP-53 flows, tampered / foreign-domain rejection
  • Docs — docs/api/endpoints.md, docs/setup/environment-variables.md, .env.example, context/progress-tracker.md, context/architecture-context.md, SECURITY.md

🧪 Verification

  • npm run build
  • npm test ✅ — 353 tests passing (up from 344; none removed)
  • Lint: no new issues in touched files (7 pre-existing no-explicit-any errors in audit.interceptor.ts / transaction-status-checker.processor.ts are unrelated)
  • E2E suite (npm run test:e2e) requires Supabase credentials and is not part of CI

🗒️ Additional Notes

  • SEP-0043 vs SEP-53 naming. The issue refers to the browser flow as "SEP-0043"; the actual signing scheme Freighter implements is SEP-53 ("Stellar Signed Message:\n" prefix + SHA-256), and the envelope structure follows the EIP-4361-style fields the issue describes (domain, URI, version, issued-at, expiration). The SEP-53 test vector was verified empirically against stellar-sdk (hash-then-sign) before implementation. The sep0043 string is kept in the API contract for backward compatibility.
  • Config placement. The legacy flag and challenge-domain config are read via ConfigService in AuthService (same pattern as STELLAR_NETWORK_PASSPHRASE elsewhere) rather than src/config/env.ts, which is purpose-built for the admin-wallet allowlist.

The nonce challenge was not domain-bound: verifySignature accepted raw
signatures over the bare nonce hex and a "Stellar Signing Key:" fallback,
so a (nonce, signature) pair captured from any other context could be
replayed against StepFi. Every accepted signature now signs a canonical
StepFi challenge envelope (domain, address, uri, version, nonce, issuedAt,
expirationTime, networkPassphrase); the nonce row stores a SHA-256 digest
of the exact message and verification only ever runs against that message.
Browser wallets verify per SEP-53; the legacy raw-nonce scheme is
deprecated behind AUTH_ALLOW_LEGACY_RAW_SIGNATURES with a 2026-10-31
sunset. Migration: 20260825000000_add_nonce_message_binding.sql.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
@Marvelg256
Marvelg256 requested a review from EmeditWeb as a code owner August 25, 2026 15:00

@EmeditWeb EmeditWeb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Automated Audit: solves

The PR targets the root cause rather than symptoms: nonce rows now persist issued_at plus a SHA-256 message_hash of the exact canonical envelope, verification selects exactly one scheme per request (removing the weakest-format fallback chain), and signatures are validated against the stored-hash-bound message with strict domain/URI/network-passphrase/expiry checks, making cross-service replay impossible for the canonical schemes. Both unit and e2e regression tests were added covering the issue's mandated cases (legacy rejected once flag off, sep0043/envelope happy paths, foreign-domain, tampered-message, expired envelope), satisfying the testing standard. The significant caveat is that AUTH_ALLOW_LEGACY_RAW_SIGNATURES defaults to true, so the replayable raw-nonce path remains open out-of-the-box until an operator flips the flag; this is expressly permitted by the issue's ground rule ('preserve mobile-client compatibility OR provide an explicit, documented migration window') and is documented with a sunset date, but the exported LEGACY_RAW_SIGNATURES_SUNSET constant appears to have no runtime enforcement. Additionally, the auth.service.ts patch is truncated mid-file, so verifyLegacyRawSignature/assertChallengeBinding internals and null-message_hash row handling could not be fully audited.

Gaps identified:

  • Legacy raw-nonce scheme is enabled by default (AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true), so the exact replay vulnerability in issue #118 remains exploitable in default deployments during the migration window; consider defaulting to false
  • No runtime enforcement of the 2026-10-31 sunset date — LEGACY_RAW_SIGNATURES_SUNSET appears informational only; a hard time-based reject would remove reliance on manual ops action
  • Truncated diff prevented full audit of resolveChallengeMessage()/assertChallengeBinding() and behavior for pre-migration nonce rows with null message_hash (unit test exists but implementation unverifiable)
  • Replay-across-environments isolation relies on networkPassphrase/domain equality checks; a dedicated cross-environment test vector (same keys, different API_URL) was not shown

Audited by stepfi-audit-bot 🤖

@EmeditWeb EmeditWeb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Automated Audit: partial

@Marvelg256 Good start — please look into the gaps identified below.

The code changes genuinely target the root cause: a canonical StepFi-bound challenge envelope, SHA-256 message hashes stored on nonce rows so verification only runs against the issued challenge, strict domain/URI/network/expiry checks, removal of the multi-format fallback, plus unit/e2e regression tests and documented migration window — matching the issue's requirements. However, the PR cannot be approved because independent verification reports merge conflicts with the base branch, and the sandbox test run failed entirely ('[WinError 2]'), so the claimed '353 tests passing' is unverified. Additionally, the legacy raw-nonce scheme (the actual vulnerable path) remains accepted by default (AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true), so cross-service replay stays exploitable out-of-the-box until the flag is flipped; this is permitted by the issue's migration-window clause but means the deployed fix is incomplete by default.

Gaps identified:

  • Merge conflicts with base branch must be resolved before approval
  • Independent test execution failed; PR-claimed results (353 passing) are unverified
  • Cross-service replay of legacy raw-nonce signatures remains possible by default until AUTH_ALLOW_LEGACY_RAW_SIGNATURES=false; no enforcement tied to the 2026-10-31 sunset (date is documentation-only)
  • Truncated diffs prevent confirming every claimed regression test (legacy-disabled rejection, wrong-network, expired-envelope, rows-without-hash) actually exists

CI checks: none configured
Merge conflicts: ⚠️ YES — this PR has conflicts with the base branch and cannot be merged.

Sandbox error: [WinError 2] The system cannot find the file specified

Audited by stepfi-audit-bot 🤖

@EmeditWeb EmeditWeb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Automated Audit: partial

@Marvelg256 Good start — please look into the gaps identified below.

The PR substantively addresses the root cause of #118: nonce rows now persist a SHA-256 digest of the exact canonical domain-bound challenge, verification runs against exactly one client-selected scheme with strict domain/URI/network-passphrase/expiry checks, the multi-format fallback ('try raw, then Stellar Signing Key') is removed, and regression tests were added for legacy-disabled, wrong-domain, tampered-message, expired-envelope, and replay paths. The independent sandbox run (29 suites / 353 tests passed) is consistent with the PR's claimed results, so no discrepancy needs flagging. However, the PR has unresolved merge conflicts with the base branch and per policy cannot be approved as-is, and because AUTH_ALLOW_LEGACY_RAW_SIGNATURES defaults to true, the exact cross-service replay attack described in the issue still succeeds in any default deployment until an operator manually flips the flag - the 2026-10-31 sunset is documentation-only with no server-side enforcement.

Gaps identified:

  • Unresolved merge conflicts with base branch - PR cannot be merged/approved until rebased
  • Legacy raw-nonce scheme remains accepted by default (AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true), so the issue's core replay vector is still live out-of-the-box; the sunset date has no enforcement mechanism (no runtime cutoff after 2026-10-31)
  • Strongest regression coverage lives in the e2e suite, which requires Supabase credentials and is excluded from both CI (none configured) and the independent verification run
  • Acceptance criterion 'every accepted signature provably signs a StepFi-bound challenge' is only conditionally met (flag-dependent), though this follows the migration window the issue itself permits

CI checks: none configured
Merge conflicts: ⚠️ YES — this PR has conflicts with the base branch and cannot be merged.

Independent test run: PASSED

actions.controller.spec.ts (12.833 s)
PASS test/unit/modules/auth/auth.controller.spec.ts (7.894 s)
PASS test/unit/modules/users/users.controller.spec.ts (17.977 s)
[Nest] 6840  - 08/26/2026, 8:54:46 AM   ERROR [LoansService] Failed to build create_loan XDR for pending-1787730886826-nhuroyao: Soroban unavailable
[Nest] 6840  - 08/26/2026, 8:54:46 AM   ERROR [LoansService] Failed to persist pending loan pending-1787730886842-3el0g8ju: insert failed
[Nest] 6840  - 08/26/2026, 8:54:46 AM   ERROR [LoansService] Failed to fetch reputation score for GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW: rpc timeout
[Nest] 6840  - 08/26/2026, 8:54:47 AM   ERROR [LoansService] Failed to update loan 11111111-2222-3333-4444-555555555555 status: update failed
[Nest] 6840  - 08/26/2026, 8:54:47 AM   ERROR [LoansService] Failed to fetch loans for GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW: db offline
PASS test/unit/modules/loans/loans.service.spec.ts (144.051 s)
PASS test/unit/modules/auth/jwt-auth.guard.spec.ts
PASS test/unit/modules/loans/loans.controller.spec.ts (22.426 s)
PASS test/unit/modules/health/health.controller.spec.ts
PASS test/unit/stellar/contracts/clients/creditline.client.spec.ts
PASS test/unit/modules/vendors/vendors.service.spec.ts (26.8 s)
PASS test/unit/modules/learners/learners.controller.spec.ts
PASS test/unit/modules/learners/learner-profile.dto.spec.ts
PASS test/unit/modules/liquidity/liquidity.controller.spec.ts (5.182 s)
PASS test/unit/modules/vouching/vouching.service.spec.ts (5.866 s)
PASS test/unit/stellar/stellar.service.spec.ts (5.24 s)
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.

Test Suites: 29 passed, 29 total
Tests:       353 passed, 353 total
Snapshots:   0 total
Time:        155.802 s
Ran all test suites.

Audited by stepfi-audit-bot 🤖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

critical: signature challenge is not domain-bound — raw-nonce and SEP-0043 fallback accept cross-service signature replay

2 participants