fix(registration): stop a Swiss trunk-0 phone number from breaking the signature - #910
fix(registration): stop a Swiss trunk-0 phone number from breaking the signature#910joshuakrueger-dfx wants to merge 5 commits into
Conversation
f0525d5 to
aaaffe4
Compare
|
@marassteiner please review This is a PR, not an issue: 15 files against |
|
EN: Working on this now — job |
marassteiner
left a comment
There was a problem hiding this comment.
EN: Approve — the trunk-0 strip fixes #905; two follow-ups are tracked in #911 and #912, neither blocks merge.
DE: Approve — der Trunk-0-Strip behebt #905; zwei Follow-ups stehen in #911 und #912 und blockieren den Merge nicht.
Details
Recommendation
Approve. No merge-blocker. CI on aaaffe4c is green (Analyze & Test, Coverage Floor Gate, Visual Regression, BitBox quirks audit, handbook smoke).
Findings
Follow-up — lib/packages/service/dfx/real_unit_registration_service.dart:215
The new exception split matches the free-text API message 'Invalid signature' instead of a structured code. If that wording ever changes, this 400 would again become RegistrationRejectedException and show “check your entries”. Tracked in #911.
Not a blocker: merged today this only stops attributing a known non-content 400 to the user’s entries. A message-text drift would restore the previous generic-failure path; it does not re-break registration. The #905 fix is the trunk-0 strip, not this mapping.
Follow-up — lib/widgets/form/phone_number_field.dart:48
The strip is prefix-agnostic. That is correct for the current ['+41', '+49'] list. Parallel work on feat/open-phone-country-prefix already accepts a free 1–3 digit dial code; combining both without an allow-list would mutate numbers whose leading 0 is significant (e.g. +39). Tracked in #912.
Not a blocker: on the surface this PR ships, CH/DE trunk-0 stripping is the intended #905 fix and is covered by the new widget tests (input, prefix switch, seed).
Local run
Documented CONTRIBUTING path on this host:
flutter pub getdart run tool/generate_localization.dartdart run tool/generate_release_info.dartflutter pub run build_runner build --delete-conflicting-outputsflutter analyzeon the five changed Dart files — No issues foundflutter test test/widgets/form/phone_number_field_test.dart test/packages/service/dfx/real_unit_registration_service_test.dart— 23/23 passed (includes the new trunk-0 andInvalid signaturecases)
A GUI flutter run was not possible on this review host. That is environmental and outside the diff (Case 2):
flutter run -d macos→Error: No macOS desktop project configured(repo has nomacos/tree; this PR does not add one)flutter run -d chrome→ compile failed inpackage:ffi(Only JS interop members may be 'external'); repo has noweb/tree and this PR does not touch web/ffiflutter doctor: no Android SDK; Xcode is Command Line Tools only (simctlunavailable); no iOS Simulator / Android emulator- The diff is phone-field normalisation + exception mapping + goldens — no platform runner, plugin, or toolchain change
13893c4 to
1955133
Compare
|
Ready after 3 review passes. DetailsPass 1 — 6 findings, 3 acted on:
Pass 2 — 4 findings, 3 acted on:
Pass 3 — 1 finding, no code finding: the counter-check table in the description undercounted the Comments: both review threads answered and resolved — #912 fixed by this PR including the Mergeability: Verification: 34/34 locally with the CI toolchain (Flutter 3.41.6) and the full workflow step order; 4963 tests in CI (4961 before the last two, so the delta matches exactly); Not verified: live end-to-end against the deployed API with a real EIP-712 signature, and a device run of the registration wizard. |
|
@marassteiner please review This is a PR, not an issue: 14 files against Re-review requested — the branch was rebased onto |
|
EN: Working on this now — job |
marassteiner
left a comment
There was a problem hiding this comment.
EN: Re-review of ee8d2f70 after the staging rebase: the scoped trunk-0 strip and tests look correct, #912 is addressed and #911 stays open, but the named CONTRIBUTING workaround still needs an explicit human grant before I can approve.
DE: Re-Review von ee8d2f70 nach dem Staging-Rebase: der begrenzte Trunk-0-Strip und die Tests sind korrekt, #912 ist erledigt und #911 bleibt offen, aber die benannte CONTRIBUTING-Abweichung braucht noch eine ausdrückliche menschliche Freigabe, bevor ich approven kann.
Details
Recommendation
Comment, not approve. The rebase onto staging after #909, the allow-list, and the new tests address the previous follow-up. I am not re-approving while the PR-declared CONTRIBUTING workaround (_trunkZeroPrefixes) is still ungranted.
Previous approval was on aaaffe4c and does not apply to this head.
What changed since aaaffe4c
- After #909 replaced the
+41/+49dropdown with a free 1–3 digit prefix field, a prefix-agnostic strip would have mutated Italian landlines (leading0is significant). The strip is now limited to_trunkZeroPrefixes(+41,+49,+43).+423is excluded: Liechtenstein has no national trunk 0. - Same strip on seed (
initState), so a pre-filled legacy+41079…is rewritten before sign/submit. - Widget tests pin
startsWith('0')vscontains('0'), exactly one leading zero, each list entry,+39preservation,+423preservation, prefix switch, and seed. Invalid signature400 still stays a plainApiException(notRegistrationRejectedException). Unchanged from the last review; still free-text. #911 remains open by design.- Goldens regenerated; Visual Regression is green on this head.
Follow-ups from the last review
- #912 (prefix-agnostic strip) — closed by this head. The
+39preservation test is present. - #911 (structured
INVALID_SIGNATUREcode) — still not in this PR. Failure mode if the API message text changes: generic error instead of “check your entries”; registration is not blocked. Thread already records that decision.
CONTRIBUTING
The PR body names _trunkZeroPrefixes as a deviation from “the API decides” / “do not add app-side workarounds”. The list does not reject any number (validators stay format-only). It only chooses whether to drop a trunk 0 so the signed bytes match the API’s E.164 form for CH/DE/AT.
That is still client-side numbering-plan knowledge. The fully compliant fix is DFXswiss/api#5073 (verify the signature against the submitted bytes, then normalize). Until a human maintainer grants this named workaround — or #5073 lands and the list is deleted — this review does not approve.
Residual, documented, not a regression of this head: trunk-0 countries outside the list (FR, UK, NL) still hit Invalid signature if the user types a leading 0. Lengthening the client list would copy libphonenumber into the app.
Checks on ee8d2f70
- Analyze & Test — pass
- Coverage Floor Gate — pass
- Visual Regression — pass
- BitBox quirks audit — pass
- Build handbook image + container smoke — pass
- Maestro handbook flows — skipped (expected without
tier3:full/ not targetingmain)
Not re-run locally in this pass: flutter test / flutter analyze / a device wizard. CI on this head is the evidence.
|
Closing in favour of DFXswiss/api#5073. WhyThe root cause is server-side: That fixes the problem for every country and every client at once. This PR fixed it app-side for three dial codes ( CONTRIBUTING is explicit about this shape of fix:
A server fix also reaches users who have not updated the app; an app fix does not. Not covered by api#5073 — worth a small follow-up PRTwo changes in this branch are independent of the signature problem and are lost by closing:
Both are small and carry tests on this branch. State of this branch
Issues
|
|
EN: DE: DetailsProduct rule: a national trunk 0 after Closing this in favour of the API-only change does not hold. The API can stop rewriting signed fields before verification, but it cannot rewrite the payload afterwards: the share register re-verifies the same EIP-712 signature. The only way the share register sees What to keep from this branch: strip a leading trunk 0 when composing ( #905 stays the report. This PR is the app half of the fix, not a workaround. |
51e0d11 to
de756e8
Compare
|
@marassteiner please re-review — the branch changed substantially since your last pass on This is a PR, not an issue: 6 files against What changed since your 2026-08-20 comment:
Counter-checks on this head with the pinned Flutter 3.41.6: 28/28 pass, 12 of 28 fail without the fix, 5 fail when canonicalisation is rolled back to the dial-code list, 2 fail when the new error key is reverted, 1 fails when the parse fallback is broken. Known limits, the bare- |
|
EN: Working on this now — job |
|
Vendor cannot run. Official lane: agent run --spec-file STATUS=unavailable rc=1. Direct: codex login status = Not logged in (rc=1); no auth.json. Direct codex exec: HTTP 401 Unauthorized on wss://api.openai.com/v1/responses. No approved recorded. No vendor substitute. |
|
Vendor cannot run. Codex not logged in (no auth.json). Official grok-pr both approved on 7a43320; codex-pr/logic cannot start. Official sibling lane (codex-pr/quality) STATUS=unavailable rc=1; direct exec HTTP 401 Unauthorized. No approved recorded. No vendor substitute. |
…ation The registration phone number was composed exactly as typed, so the customary Swiss trunk-zero spelling (prefix `+41`, national part `079…`) was EIP-712 signed as `+410791234567`. The server normalises the field to E.164 before it verifies the signature, so verification ran over different bytes than the client signed and a valid submission was rejected. Compose the canonical value in the field instead: - Remove exactly one national trunk zero, and only for the dial codes whose numbering plan has one (`+41`, `+49`, `+43`). - Match on the fully composed value rather than per field, so the result is the same however the digits are split between the free-form dial-code field and the national part. - Apply the same composition to a pre-filled value before any user interaction. - Reject a residual `+410…`, `+490…` or `+430…` so it cannot advance through the form. Italian and Liechtenstein leading zeroes are significant and stay untouched; `+423` is deliberately absent from the trunk-zero list because Liechtenstein has no national trunk zero.
The trunk-zero rejection returned `registerPhoneNumberInvalid`, which reads "Phone number is required" / "Telefonnummer ist erforderlich" — the same string the empty-field branch twelve lines above uses. Someone who typed `00791234567` therefore saw "is required" over a visibly filled field, with no hint that the leading zero was the cause. Add `registerPhoneNumberLeadingZero` and use it for that branch only; the empty-field branch keeps the old key. The two rejection tests now assert the new string. That also sharpens them: while both branches returned the same text, neither test could tell a trunk-zero rejection from an empty-field one.
…metadata The trunk-zero strip was driven by a hand-kept list of three dial codes, so every other numbering plan with a national trunk zero still composed a value the backend rejects. Measured with `libphonenumber-js` 1.12.25, the version the API resolves: `+330612345678` must be `+33612345678`, `+4407911123456` must be `+447911123456`, `+310612345678` must be `+31612345678`. Derive the canonical form from the same metadata the API uses instead, via `dlibphonenumber`. Measured over 490 cases built from libphonenumber's own example numbers across 245 countries — the canonical mobile number per country and the same number with a zero inserted after the dial code — the package agrees with `libphonenumber-js` on 490 of 490. `phone_numbers_parser` was measured too and differs in 5 of those cases, which is why it is not the one used here. `parse` throws while the national part is still being typed, so the raw value is passed through unchanged; the app must not gate on validity or length. `_trunkZeroPrefixes` stays, but only to report a surviving second leading zero in the field rather than letting it become a 400: `+4100791234567` is a fixed point of the canonicalisation, so the metadata alone does not catch it.
`_canonicalize` caught every exception and returned the raw value. The comment named the one case that is expected — incomplete input while the national part is still being typed — but the code also swallowed a genuine defect in the metadata library, which would then reach the API as a 400 instead of failing visibly. Narrow it to `NumberParseException`, the type measured for `+41`, `+417`, `+410`, `+49` and `+423`. Anything else now propagates. The incomplete-input test stays green, which is what pins that this is the type actually thrown.
The double-zero guard listed +41, +49 and +43, but only +41 was covered by a test: removing the other two entries left the suite green. Adds a case for each. The Liechtenstein test claimed LI has no national trunk 0. It has one — the zero is stripped as soon as the result has a possible length. The test only passed because its number was too short for that, so the comment described a rule that does not exist. Corrects it and pins the actual behaviour. Also moves the dlibphonenumber import behind the flutter imports, per the import order in CONTRIBUTING.md.
7a43320 to
1ad452d
Compare
Composes the RealUnit registration phone number as canonical E.164 before the EIP-712 signature, so the signed value is the one the backend accepts and forwards to the share register. A Swiss
079…typed with its customary trunk zero no longer breaks the signature — and neither does a French, British or Dutch one.Symptom (verbatim):
Invalid signatureonPOST /v1/realunit/register/complete, reported in #905 from production. A Swiss user completes the whole registration wizard, presses Abschliessen, and loses the submit; nothing is stored. The snackbar blames «your entries» and so points at the tax step the user happens to be looking at — the tax data is a red herring, the signature is verified before any tax check.Scale: Every user who types their national number the way it is written in their own country. The trunk zero is the customary spelling in roughly sixty numbering plans, Switzerland, Germany, Austria, France, the UK and the Netherlands among them. The field defaults to
+41, so the Swiss case is the default path through the registration wizard; the failure is total (registration cannot complete) and misattributes its own cause.Smaller fix considered: Strip a leading
0for+41only — about five lines inupdatePhoneNumber, and the first revision of this PR. Insufficient because the same backend rule rejects+330612345678,+4407911123456and+310612345678exactly as it rejects+410791234567: a per-dial-code list would have to grow to roughly sixty entries and would be a second, drifting copy of libphonenumber's metadata — the shapeCONTRIBUTING.md:112-118tells us to avoid.Closes #912.
Details
Summary
Why
DFXswiss/backend#5073 stops rewriting signed fields before signature verification and rejects a non-canonical signed phone on
/register/complete. It cannot rewrite the payload after verification, because Aktionariat re-verifies the same EIP-712 signature. The only way the share register sees+4179…is if this client composes that form beforeEip712Signer.signRegistration.This is the app half TaprootFreak asked for when reopening on 2026-08-26. The
Invalid signaturecopy stays follow-up work (#911) and is deliberately not part of this revision.It also fixes #905 on its own. Against the deployed dev API, a registration signed with
+410791234567is rejected at the signature check, while the same registration signed with+41791234567passes it and is stopped only by a later, unrelated precondition. Today's backend normalises+41791234567to itself, so a canonical value verifies without any API change — this PR does not wait on backend#5073.Why metadata instead of a longer list
The first revision stripped the trunk zero for
+41,+49and+43. That leaves every other trunk-zero plan composing a value the backend rejects:+330612345678(FR) must be+33612345678,+4407911123456(UK) must be+447911123456,+310612345678(NL) must be+31612345678. There are roughly sixty such plans; extending the list would have been a second, drifting copy of the metadata.dlibphonenumberwas chosen overphone_numbers_parserbecause it agrees with the API'slibphonenumber-json every case in a comparison built from libphonenumber's own example numbers (per country the canonical mobile number, and the same number with a zero inserted after the dial code), wherephone_numbers_parserdiverges on a handful. It adds one transitive dependency (protobuf);fixnumandcollectionwere already present, and the lockfile'ssdks:constraint is unchanged.Scope
Effective diff against
staging, six files:lib/widgets/form/phone_number_field.darttest/widgets/form/phone_number_field_test.dartassets/languages/strings_de.arb,strings_en.arb— one new key eachpubspec.yaml,pubspec.lock— the new dependency and its transitiveprotobufNo service, signing, golden-baseline or generated-artifact changes. The lockfile change is the two added packages, not a range-wide re-resolution.
On
Closes #912: that issue was filed by the reviewer against this PR on 2026-08-14 and asks for exactly what this revision does — derive canonicality from the numbering plan instead of stripping unconditionally. The reviewer recorded it as addressed in the 2026-08-20 re-review ("#912 (prefix-agnostic strip) — closed by this head"). It is closed here rather than deferred because the change that resolves it is the change this PR exists for.Rebased, deliberately
An earlier head of this branch was a merge commit carrying a
fix(...)subject line, and it introduced the core logic of the PR rather than any of the seven commits doing so:_canonicalizeappeared 0× in both parents and 3× in the merge itself, and the test count went 12 (staging) → 20 → 24 (the merge).staginghad never touched the file since the branch point, so the merge should have been a no-op for it. Reading the branch commit by commit therefore showed a history that did not produce the shipped code.The branch is now four commits on current
staging, force-pushed with--force-with-lease; nobody else pushes to this branch.Known limits, stated rather than hidden
+41/+49/+43. Canonicalisation does not remove it on its own: for+41the value is a fixed point (+4100791234567stays as typed, because no shorter form is a possible length), while for+49/+43one of the two zeroes is dropped and the second survives (+4900691234567→+490691234567). Either way a zero remains directly after the dial code, and that is the only thing_trunkZeroPrefixesstill does. For other dial codes the value reaches the API and comes back as a named 400 rather than a field error.0as the national part validates. Prefix+41, national part0composes to+41and passes the field, so a naked dial code is signed. The API rejects it with@IsDfxPhone, so it never reaches the share register — but the field claims it is valid.ValueNotifierand never back into the two text fields, so+41/079…on screen submits+41791234567. That is the intended behaviour — it is also why the KYC correction form asks the user to check a value it does not display in its final form.initStatenow writes to the sharedValueNotifier. None of the three consumers listens to the controller, so this cannot throw today. It becomessetState() called during buildthe moment someone wraps that controller in aValueListenableBuilder.Not built
Invalid signaturematch — Classify register/complete "Invalid signature" by API code, not message text #911, needs the backend to emit a code first.268still describes a prefix dropdown, which feat(kyc): open the phone country prefix field to any 1-3 digit dial code #909 replaced with a free 1–3 digit field; that block is not currently listed in Handbook leftovers after removing the phone-prefix-open golden #913.hintText'1231234567', which is not a Swiss number shape and is part of why users type the trunk zero. Named as a follow-up in Registration complete: +41 plus a leading-0 national number fails with Invalid signature #905 and by TaprootFreak on the reopen; it has no issue of its own yet.Review history
The 2026-08-14 approval lapsed with the rebase that followed. The 2026-08-20 re-review asked for an explicit human grant for the named CONTRIBUTING deviation before approving — a client-side allow-list is the shape
CONTRIBUTING.md:83-86and:112-118warn about. TaprootFreak's reopen of 2026-08-26 states the product rule directly: "a national trunk 0 after+41is always wrong … This PR is the app half of the fix, not a workaround." This revision also shrinks the deviation: the list no longer decides canonicality, only which markets get a field-level message instead of an API error.A re-review was requested on 2026-08-27. It has not concluded: two of the reviewer's four lanes reported
rejectedat that head for vendor reasons ("Codex not logged in … HTTP 401"), which is an unavailable check rather than a finding, and no review verdict has been recorded since.