Skip to content

fix: validate SS58 in deep links and repair checkphrase failure path (L9) - #580

Merged
n13 merged 5 commits into
mainfrom
fix/l9-deeplink-ss58-checksum
Jul 29, 2026
Merged

fix: validate SS58 in deep links and repair checkphrase failure path (L9)#580
n13 merged 5 commits into
mainfrom
fix/l9-deeplink-ss58-checksum

Conversation

@n13

@n13 n13 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Deep links (/pay): the recipient address is now validated with SubstrateService.isValidSS58Address — the same validator used at send-flow entry — before the payment intent is set. Links with a missing or invalid to parameter are dropped (fail closed).
  • Shared-account sheet: _sendToAddress validates the address before navigating to the send flow; on invalid input it shows an error toast and does not proceed (fail closed).
  • Checkphrase service: HumanReadableChecksumService.getHumanReadableName now returns null instead of '' on error (isolate failure, empty result, or exception), so the _recipientChecksum != null gate on the review screen actually works and the anti-phishing checkphrase no longer silently disappears. Callers that need a non-null string (checksumNameProvider, getMyInviteCode, cold-wallet checkphraseProvider) coalesce to '' locally; all other callers already stored the result in String? fields.
  • Cache-eviction key bug: eviction in the error path used the raw address while the store key is address + '#U'; both now use the identical key.

Addresses finding L9 of the 2026-07-22 mobile wallet security audit.

Verification

  • dart analyze: no issues in mobile-app, quantus_sdk, and cold-wallet-app
  • flutter test test/unit in mobile-app: all 222 tests passed

…(L9)

- /pay deep links now validate the recipient with isValidSS58Address
  before pre-filling the send flow; invalid links are dropped
- Shared-account sheet validates the address before navigating to send,
  showing an error toast and failing closed on invalid input
- HumanReadableChecksumService.getHumanReadableName returns null instead
  of '' on error so the _recipientChecksum != null gate works, and cache
  eviction now uses the same '#U'-suffixed key as the cache store

Addresses finding L9 of the 2026-07-22 mobile wallet security audit.
@n13

n13 commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 Review — L9 (deep-link SS58 validation & checkphrase failure path)

Verdict: 🟡 Approve with non-blocking comments

The PR correctly closes two unvalidated recipient-entry points (deep-link /pay, shared-account sheet) and repairs the checkphrase failure path so an empty/failed lookup no longer silently bypasses the anti-phishing gate. All 16 call sites of the now-nullable getHumanReadableName handle null safely; the only side effect worth flagging is that the receive screen can hang on a loader if the (own-address) checkphrase lookup ever fails, plus a total absence of new tests for these security-relevant paths.

What it does

  • Deep link /pay (deep_link_service.dart:60, PR head): gates paymentIntentProvider on isValidSS58Address(payment.to) in addition to payment != null. This matters because home_screen.dart:99 _onPaymentIntent navigates the deep-link to directly into InputAmountScreen (home_screen.dart:105-107), bypassing SelectRecipientScreen's validation — so the boundary check is genuinely load-bearing, not redundant.
  • Shared-account sheet (shared_address_action_sheet.dart:66-73): _sendToAddress validates SS58 and shows an error toast + returns before startNewSendSession()/navigation; the only other entry into the send flow from this sheet.
  • Checkphrase service (human_readable_checksum_service.dart:62-104, PR head): return type Future<String?>; returns null on isolate-null, null/empty result, or exception (previously ''). key hoisted above the try so the catch can evict; empty/failed results are not cached, so transient failures retry.
  • Cache-eviction key fix: catch now does _checkPhraseCache.remove(key) (was remove(address)) matching the #U-suffixed store key.
  • Caller coalescing: checksumNameProvider (mobile-app/.../wallet_providers.dart:49), cold-wallet checkphraseProvider (cold-wallet-app/.../wallet_providers.dart:115), and getMyInviteCode (referral_service.dart:147) each ?? ''. select_recipient_screen.dart:82 now guards name != null before writing into the non-nullable Map<String,String> _checksums.

Strengths

  • The null-return change is the correct mechanism: input_amount_screen.dart:209 (_recipientChecksum == null → block _openReview) and :252 (disable send button) only fire because failure now yields null rather than ''. The review screen genuinely renders the checkphrase (review_send_screen.dart:192-193 AddressCheckphraseWithInitial), and it can only be reached with a non-null, non-empty checksum. Anti-phishing gate is real.
  • Fail-closed is layered: even if an invalid address reached InputAmountScreen, the checksum can't resolve → _recipientChecksum stays null → review blocked. Defense in depth.
  • Every one of the 16 getHumanReadableName call sites was checked: all receiving fields are String? (_recipientChecksum, _senderCheckphrase, _toCheckphrase, _creatorChecksum, _checksum, _referralDataCache) or coalesced. No caller concatenates or calls a method on the raw result unguarded, so the dart analyze clean claim holds.
  • Completeness of entry points is good: the three navigations into InputAmountScreen (shared sheet, deep link, SelectRecipientScreen._continue) are all now validated; QR scan is covered by the scanner validator (select_recipient_screen.dart:158) plus the controller-driven _lookupAddress SS58 check (:110); recents flow through the same controller. No contacts/address-book recipient picker exists.

Findings

  1. [non-blocking] Receive screen can hang on a loader when the checkphrase failsreceive_screen.dart:92: isLoading = _accountId == null || _checksum == null. Pre-PR a failed lookup returned '' (non-null) → screen rendered with a blank checkphrase; post-PR it returns null_checksum stays nullisLoading is permanently true → infinite Loader() (:111) with no error/retry, and the address can't be viewed at all. Low likelihood (it's the user's own deterministic address), but it's a behavior change introduced as a side effect of the '' → null switch. account_details_screen.dart:36 handles the same case gracefully (sets _isLoading = false regardless, card takes nullable checksum) — receive is the one screen that traps. Consider degrading to an error/retry state rather than an unbounded spinner.
  2. [non-blocking] No tests added for any of the changed behavior — the PR touches 7 files, none are tests, and there is no existing coverage referencing getHumanReadableName, DeepLinkService, isValidSS58Address, or PaymentIntent under mobile-app/test or quantus_sdk/test. All three fixes are trivially unit-testable (service returns null on empty/failure; /pay with a malformed to leaves paymentIntentProvider null; _sendToAddress no-ops on invalid input). For security-remediation changes this is a real gap. The "222 tests passed" claim is a regression check, not new coverage.
  3. [nit] Reused l10n keyshared_address_action_sheet.dart:69 uses l10nProvider.addHardwareAccountInvalidAddress ("Invalid address" in en). Displayed text is correct, but the key belongs to the add-hardware-account feature; a neutral key would read better semantically.
  4. [nit / informational] Eviction fix is essentially a no-op in practice — the fix is correct, but because values are only cached on success and containsKey(key) early-returns before the isolate call, a throw in the catch path almost always occurs when key isn't in the cache, so remove(key) rarely evicts anything. Fine to land as defensive correctness; just noting the practical impact of the original bug was near-zero.

Verification

  • PR-body claims all check out against the diff/PR-head code: deep-link SS58 gate (necessary, not redundant), shared-sheet validation + toast + fail-closed, null-on-error return with the _recipientChecksum != null gate now functional, and the #U eviction-key fix (with key correctly hoisted for catch-scope). PaymentIntent.tryParseUrl (route_intent_providers.dart:61) already rejects missing/empty to, so the new check covers the malformed-but-present case — the "missing or invalid" wording is accurate.
  • Regression scan for '' → null: no equality/.isEmpty breakage found — shared_address_action_sheet.dart:140-143 already had an explicit snapshot.data == null branch; checksumNameProvider consumers (account_ready_screen.dart:120, multisig_propose_strategy.dart:120) still receive '' via coalesce, so their behavior is unchanged. The receive-screen loader (finding 1) is the sole behavioral regression.
  • Gap: no automated test asserts any of the new fail-closed behavior (finding 2).

🤖 AI-assisted review generated with Claude Code

n13 added 4 commits July 25, 2026 16:23
- receive screen: degrade to blank checkphrase on lookup failure instead
  of an unbounded loader
- use a neutral invalidAddress l10n key in the shared-address sheet
  instead of the add-hardware-account key
- checksum service: swallow abandoned completer future on init failure
  so it no longer surfaces as an unhandled async error
- add unit/widget tests for PaymentIntent parsing, /pay deep-link
  fail-closed behavior, checkphrase service, and the shared-address
  sheet send guard
…checksum

# Conflicts:
#	mobile-app/lib/features/components/shared_address_action_sheet.dart
#	mobile-app/lib/services/deep_link_service.dart
#	quantus_sdk/lib/src/services/human_readable_checksum_service.dart
@n13
n13 merged commit 7479e86 into main Jul 29, 2026
1 check passed
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.

1 participant