A number nobody verified cannot get anybody back in #142
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Reference
Postulo/postulo#142
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observation
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: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
EmailAddresscarries averifiedflag 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.
PhoneNumberuses a generic holder, soone 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 anythingtouching 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.
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
AddFieldand a test asserts that, because granting verification retroactively lookslike a convenience and is a way into somebody's account.
The strict order is respected rather than worked around.
record_verified()exists andnothing 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 answerand 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:
number is not.
verification_has_lapsedis kept apart from never-verified, so an interfacecan say "still yours?" rather than "no".
save()rather than in a form, so a rowwritten 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.
mine()filters on the holder being theperson's profile, not on
owner— a recruiter's switchboard is a number this account owns andnever 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 anumber 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_VERSIONis6.
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
d5e6361on0.3.0, withmainkept level.