A number nobody verified cannot get anybody back in #142

Closed
opened 2026-09-09 10:47:59 +00:00 by tiagoagueda · 1 comment
Owner

Observation

the primary contact can be used as recovery route when sms notifications is written on the
code

Prerequisite, and the one that has to be settled first, because the thing being proposed as
a way back into an account is currently a string anybody may type.

What exists

PhoneNumber (#90) has no verification and says so. The wiki states it plainly:

Numbers are unique but unverified: Postulo cannot send an SMS and is not going to
start. The first account to type a number holds it.

The original issue was explicit about why that mattered: "An email address is unique and
verified, and the verification is what makes uniqueness meaningful — otherwise anybody can
squat any address."
Numbers shipped without it because there was no channel to verify them
over, and nothing depended on them.

Email is the counter-example in the same codebase: allauth's EmailAddress carries a
verified flag per address, and Settings → Account shows it beside each one.

What this asks for

A verified state on a telephone number, and a rule that only a verified number counts for
anything that matters.

Worth being careful about

Unverified plus unique plus recoverable is account takeover, and the ordering makes it
worse.
Numbers have been storable since #90 and are first-come-first-served. If they later
become a way back into an account, every number typed before that day was a claim nobody
checked — and somebody who typed a stranger's number a month ago is pre-positioned. No
existing row may be promoted to recovery-capable by a migration.
Verification starts empty
and is earned, however much churn that causes.

Verifying a number needs the channel that verifying it is meant to unlock. A code has to
reach the handset, which needs SMS, which is the other prerequisite. That is a strict order,
not a preference: no channel, no verification; no verification, no recovery route.

Uniqueness stops being a nicety. The instance-wide rule was chosen in #90 with its
disclosure written down; if a number identifies an account for recovery, uniqueness becomes a
security property rather than a tidiness one — and the message that says "already recorded
here" becomes a way to test whether a number is registered. That is the same shape as email
enumeration, and it needs the same care.

A verified number that changes hands. People give up numbers; carriers reissue them. An
address is forever in a way a number is not, and a recovery route pointing at a number
somebody else now holds is the failure mode with no equivalent in email. Re-verification
after a period, or an explicit "still yours?" prompt, belongs in this decision.

Only the account's own numbers are candidates. PhoneNumber uses a generic holder, so
one row may belong to a profile and the next to a contact at a company — both owned by the
same person. "My numbers" is a query (holder is my profile), not a field, and anything
touching recovery has to filter on it rather than on owner.

Verification state is per number, not per account, exactly as allauth does it for
addresses, and it travels in the export like everything else — which means an archive can
carry a claim of verification into an instance that never checked it. Imports should land
numbers unverified.

## Observation > the primary contact can be used as recovery route when sms notifications is written on the > code Prerequisite, and the one that has to be settled first, because the thing being proposed as a way back into an account is currently a string anybody may type. ## What exists `PhoneNumber` (#90) has no verification and says so. The wiki states it plainly: > Numbers are unique but **unverified**: Postulo cannot send an SMS and is not going to > start. The first account to type a number holds it. The original issue was explicit about why that mattered: *"An email address is unique and verified, and the verification is what makes uniqueness meaningful — otherwise anybody can squat any address."* Numbers shipped without it because there was no channel to verify them over, and nothing depended on them. Email is the counter-example in the same codebase: allauth's `EmailAddress` carries a `verified` flag per address, and *Settings → Account* shows it beside each one. ## What this asks for A verified state on a telephone number, and a rule that only a verified number counts for anything that matters. ## Worth being careful about **Unverified plus unique plus recoverable is account takeover, and the ordering makes it worse.** Numbers have been storable since #90 and are first-come-first-served. If they later become a way back into an account, every number typed before that day was a claim nobody checked — and somebody who typed a stranger's number a month ago is pre-positioned. **No existing row may be promoted to recovery-capable by a migration.** Verification starts empty and is earned, however much churn that causes. **Verifying a number needs the channel that verifying it is meant to unlock.** A code has to reach the handset, which needs SMS, which is the other prerequisite. That is a strict order, not a preference: no channel, no verification; no verification, no recovery route. **Uniqueness stops being a nicety.** The instance-wide rule was chosen in #90 with its disclosure written down; if a number identifies an account for recovery, uniqueness becomes a security property rather than a tidiness one — and the message that says "already recorded here" becomes a way to test whether a number is registered. That is the same shape as email enumeration, and it needs the same care. **A verified number that changes hands.** People give up numbers; carriers reissue them. An address is forever in a way a number is not, and a recovery route pointing at a number somebody else now holds is the failure mode with no equivalent in email. Re-verification after a period, or an explicit "still yours?" prompt, belongs in this decision. **Only the account's own numbers are candidates.** `PhoneNumber` uses a generic holder, so one row may belong to a profile and the next to a contact at a company — both owned by the same person. "My numbers" is a query (`holder is my profile`), not a field, and anything touching recovery has to filter on it rather than on `owner`. **Verification state is per number, not per account**, exactly as allauth does it for addresses, and it travels in the export like everything else — which means an archive can carry a claim of verification into an instance that never checked it. Imports should land numbers unverified.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:47:59 +00:00
Author
Owner

PhoneNumber.verified_at, and nothing on this instance ever sets it.

No existing row is promoted, exactly as the issue required. The migration contains a
single AddField and a test asserts that, because granting verification retroactively looks
like a convenience and is a way into somebody's account.

The strict order is respected rather than worked around. record_verified() exists and
nothing calls it: proving a number means sending a code to it, which is #143. recovery_ candidates() therefore returns an empty list on every instance, which is the correct answer
and not a placeholder — and #146's contract already says why, distinguishing provable with no
carrier yet
from never provable.

Three sub-decisions the issue raised, settled:

  • A proof lapses after a year. Carriers reissue numbers; an address is forever in a way a
    number is not. verification_has_lapsed is kept apart from never-verified, so an interface
    can say "still yours?" rather than "no".
  • Editing the digits forgets the proof, dropped in save() rather than in a form, so a row
    written by the API, a management command or a shell cannot keep a verification that was never
    about the number now stored. Re-spacing the same digits keeps it, because the digits decide.
  • Only the account's own numbers are candidates. mine() filters on the holder being the
    person's profile, not on owner — a recruiter's switchboard is a number this account owns and
    never one it is.

Uniqueness as a security property. The disclosure cannot be avoided and was decided in #90;
what changes is the rate. POSTULO_NUMBER_RATE (20/h) bounds how often one account is told a
number is already recorded here, and only the informative answer is charged for — somebody
recording their own numbers never meets it, somebody sweeping a numbering range meets it within
a minute. Past the limit it says when the answer is available again rather than pretending the
save failed for another reason: a vague message would refuse the save anyway, so it would
disclose the same thing while sounding evasive. All four call sites go through it, the API
included, since that is the surface a sweep would actually use.

Imports land numbers unverified. The export writes the date because it is the person's own
record; the importer reads it and throws it away, with a comment saying why. FORMAT_VERSION is
6.

24 tests in tests/test_phone_verification.py, and the wiki paragraph that used to say
"unverified: Postulo cannot send an SMS and is not going to start" now says what the state is
for and that nothing will be promoted when it arrives.

Shipped in d5e6361 on 0.3.0, with main kept level.

`PhoneNumber.verified_at`, and nothing on this instance ever sets it. **No existing row is promoted**, exactly as the issue required. The migration contains a single `AddField` and a test asserts that, because granting verification retroactively looks like a convenience and is a way into somebody's account. **The strict order is respected rather than worked around.** `record_verified()` exists and nothing calls it: proving a number means sending a code to it, which is #143. `recovery_ candidates()` therefore returns an empty list on every instance, which is the correct answer and not a placeholder — and #146's contract already says why, distinguishing *provable with no carrier yet* from *never provable*. Three sub-decisions the issue raised, settled: - **A proof lapses after a year.** Carriers reissue numbers; an address is forever in a way a number is not. `verification_has_lapsed` is kept apart from never-verified, so an interface can say "still yours?" rather than "no". - **Editing the digits forgets the proof**, dropped in `save()` rather than in a form, so a row written by the API, a management command or a shell cannot keep a verification that was never about the number now stored. Re-spacing the same digits keeps it, because the digits decide. - **Only the account's own numbers are candidates.** `mine()` filters on the holder being the person's profile, not on `owner` — a recruiter's switchboard is a number this account owns and never one it is. **Uniqueness as a security property.** The disclosure cannot be avoided and was decided in #90; what changes is the rate. `POSTULO_NUMBER_RATE` (20/h) bounds how often one account is told a number is already recorded here, and **only the informative answer is charged for** — somebody recording their own numbers never meets it, somebody sweeping a numbering range meets it within a minute. Past the limit it says when the answer is available again rather than pretending the save failed for another reason: a vague message would refuse the save anyway, so it would disclose the same thing while sounding evasive. All four call sites go through it, the API included, since that is the surface a sweep would actually use. **Imports land numbers unverified.** The export writes the date because it is the person's own record; the importer reads it and throws it away, with a comment saying why. `FORMAT_VERSION` is 6. 24 tests in `tests/test_phone_verification.py`, and the wiki paragraph that used to say "unverified: Postulo cannot send an SMS and is not going to start" now says what the state is for and that nothing will be promoted when it arrives. Shipped in `d5e6361` on `0.3.0`, with `main` kept level.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#142
No description provided.