feat(passkeys): a password reset takes the passkeys with it (#42)
The last issue in the passkeys project, and the only one that adds no capability. It is the safety net, and it exists because this is the piece most likely to be skipped and most expensive to discover missing. A reset is the recovery path, and recovery has to be complete. The reset already deletes every session on the account, on the reasoning that a reset prompted by a compromise must not leave an intruder signed in for the remaining thirty days of their cookie. A passkey an intruder registered has no expiry at all. Leaving those behind would mean a customer can recover their password and still not have their account back. The obvious objection is that this hands whoever controls the mailbox a way to strip a customer's passkeys. It does, and it costs nothing: anyone who can complete a reset already controls the email address and therefore already controls the account. The passkeys were not protecting anything by that point. Anything in flight goes too. An intruder who pressed add a passkey moments before the reset could otherwise finish that ceremony afterwards and put a credential straight back onto the account the reset had just cleared. Changing a password deliberately does not do this, and the asymmetry is the point. A change requires the current password from someone already signed in, so nothing about it suggests a lockout or a compromise, and it already spares the current session for the same reason. A customer who suspects one particular device revokes that device by name from the account page, which is a better tool than deleting everything. A reset has no idea which credential is the problem, so it takes all of them. The customer is told twice. Before, in the reset email and on the reset form, unconditionally — that form has no session and is never told whether the account has passkeys, because answering that would make the reset page an oracle for it, so the wording has to read the same to someone who has none. After, with a count, and only when the count is not zero. That moment is the only one where the count can be reported: the rows are gone by the time anyone could go and look. A customer told two were removed who only remembers registering one has just learned something they could not otherwise find out. That notice is a panel that waits to be dismissed rather than a toast, because a toast dismisses itself and this is the message a customer needs to still be looking at while they decide what to do about it. The other question this issue asks — whether a reset ends a session established by a passkey — turns out to need no code, because #39 made both paths call one createSession. But "it falls out for free" is a claim, so there is now a test that establishes a session through that exact function and watches the reset end it. docs/ops/account-recovery.md records the whole policy, including the two answers that are not code. Losing an authenticator is not a lockout: the customer signs in with their password and revokes the lost credential themselves, which is why this change implements nothing for it. Losing the email address is a lockout, and there is deliberately no self-service route out — this shop holds no second proof of identity, and anything invented to fill that gap would be a weaker credential than the one it replaced. The manual route runs through the shop owner verifying against order history, and its third step, changing the address from the admin screen, does not exist yet. That is written down as a gap with its own notification and audit questions rather than smuggled in here. Verified: backend tsc clean for src and tests, 521 unit tests pass, lint clean apart from warnings that predate this branch; frontend tsc, lint and build clean. The integration and end-to-end suites need a database this machine has no Docker for, so CI is what proves those. Closes #42 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a0238ea4ed
commit
36dbf18916
@@ -0,0 +1,102 @@
|
||||
# Account recovery
|
||||
|
||||
What happens when a customer cannot get into their account, and what the shop
|
||||
can do about it. Written for #42, which exists because this is the piece most
|
||||
likely to be skipped and most expensive to discover missing.
|
||||
|
||||
Every other issue in the passkeys project adds a capability. This one is the
|
||||
safety net.
|
||||
|
||||
## The short version
|
||||
|
||||
| The customer has lost | They can recover by | Self-service |
|
||||
| --- | --- | --- |
|
||||
| Their password | A reset link emailed to them | Yes |
|
||||
| Their passkey or the device holding it | Signing in with their password | Yes |
|
||||
| Every passkey and their password | A reset link emailed to them | Yes |
|
||||
| Access to their email address | Nothing. See below. | No |
|
||||
|
||||
The email address is the root of trust. Every self-service route above ends at
|
||||
it, and none of them can work without it.
|
||||
|
||||
## A password reset removes every passkey
|
||||
|
||||
This is the decision #42 existed to make, and it is deliberate.
|
||||
|
||||
A reset is the recovery path, and recovery has to be complete. The reset already
|
||||
deletes every session on the account, on the reasoning that a reset prompted by
|
||||
a compromise must not leave an intruder signed in for the remaining 30 days of
|
||||
their cookie. A passkey an intruder registered has no expiry at all. Leaving
|
||||
those behind would mean a customer can recover their password and still not have
|
||||
their account back.
|
||||
|
||||
The obvious objection is that this lets whoever controls the mailbox strip a
|
||||
customer's passkeys. It does, and it costs nothing: anyone who can complete a
|
||||
reset already controls the email address and therefore already controls the
|
||||
account. The passkeys were not protecting anything by that point.
|
||||
|
||||
Consequences worth knowing:
|
||||
|
||||
- The customer is told before they act. The reset email and the reset form both
|
||||
say it, unconditionally. The form is not signed in and is never told whether
|
||||
the account has passkeys, because answering that would make the reset page an
|
||||
oracle for it.
|
||||
- The customer is told after they act, with a count, and only when the count is
|
||||
more than zero. This is the one moment that count can be reported: the rows
|
||||
are gone by the time anyone could go and look. A customer told two were
|
||||
removed who only remembers registering one has just learned something they
|
||||
could not otherwise find out.
|
||||
- Any WebAuthn challenge in flight goes too. An intruder who pressed "add a
|
||||
passkey" moments before the reset could otherwise finish the ceremony
|
||||
afterwards and put a credential straight back.
|
||||
|
||||
## Changing a password does not remove passkeys
|
||||
|
||||
The asymmetry with the paragraph above is intentional.
|
||||
|
||||
`change-password` requires the current password from someone already signed in.
|
||||
Nothing about that suggests a lockout or a compromise, and it already spares the
|
||||
current session for the same reason. A customer who suspects one particular
|
||||
device can revoke that device by name from the account page, which is a better
|
||||
tool than deleting everything.
|
||||
|
||||
A reset has no idea which credential is the problem, so it takes all of them. A
|
||||
change knows the customer is present and in control, so it takes none.
|
||||
|
||||
## Losing the authenticator is not a lockout
|
||||
|
||||
A customer who loses the phone or key holding their passkey signs in with their
|
||||
password as normal, and revokes the lost credential from the account page. This
|
||||
needs no support involvement and no new capability, which is why #42 implements
|
||||
nothing for it.
|
||||
|
||||
This holds only while every account has a password. It stops holding when #332
|
||||
lands social sign-in, which creates the first customers with no password at all.
|
||||
Their recovery route is the identity provider, not a reset link, and #332 owns
|
||||
that question. The revocation guard in `backend/src/routes/passkeys.ts` already
|
||||
refuses to delete a customer's only way in, written against that condition
|
||||
rather than against today's schema, so it starts holding on its own the moment
|
||||
the condition changes.
|
||||
|
||||
## Losing the email address is a lockout
|
||||
|
||||
There is no self-service recovery, and there should not be. Recovering an
|
||||
account whose email is gone means proving identity some other way, and this shop
|
||||
holds no other way — no phone number, no security questions, no identity
|
||||
documents. Anything invented to fill that gap would be a weaker credential than
|
||||
the one it replaces, and would become the easiest way to take an account over.
|
||||
|
||||
The route is manual, and it runs through the shop owner:
|
||||
|
||||
1. The customer makes contact by whatever means they have.
|
||||
2. The owner verifies them against order history — items bought, dates, the
|
||||
shipping address on file. A stranger has none of that.
|
||||
3. The owner changes the address on the account.
|
||||
|
||||
**Step 3 does not exist yet.** The admin customer screen can disable, enable and
|
||||
release reservations, but it cannot change an email address. Until it does, the
|
||||
answer to a locked-out customer is a database edit by hand. That gap is worth
|
||||
its own issue rather than being smuggled into the passkeys project, because it
|
||||
is an admin capability with its own audit and notification questions — the
|
||||
customer whose address is being replaced has to be told, exactly as the
|
||||
self-service change already tells them.
|
||||
Reference in New Issue
Block a user