SafePass account recovery v1
Account recovery changes the server OPAQUE password credential. It does not
decrypt or rewrite vault records. The 24-word recovery phrase remains on the
client and has two separate roles: it derives the Ed25519 key registered with
the API, and it unwraps the account-bound SPRE vault-key envelope locally.
Enrolling and rotating the recovery key
An authenticated client requests POST /v1/account/recovery-envelope/challenge.
The API returns a random 32-byte challenge ID, a random 32-byte nonce, and the
currently registered public key, if one exists. The challenge expires after
five minutes and is single-use.
The client derives the new Ed25519 public key from the recovery phrase, then signs this byte sequence:
"SafePass account recovery envelope v1\0"
|| account_id_ascii[36]
|| challenge_id[32]
|| challenge_nonce[32]
|| new_recovery_public_key[32]
|| SHA-256(recovery_envelope[81])The signature uses the phrase-derived Ed25519 key. On initial enrollment, the
new key signs the request, proving possession of the phrase. On key rotation,
the previously registered key signs the new key and envelope, so a stolen
access token cannot replace an established recovery key. PUT /v1/account/recovery-envelope verifies the proof and atomically stores the
ciphertext and public key while consuming the challenge. The API never receives
the phrase or vault key.
Resetting the OPAQUE password
The client starts a fresh OPAQUE registration for the existing account and
sends its registration request to POST /v1/auth/recovery/start. The API
returns the OPAQUE registration response, the registered recovery public key,
and a fresh, five-minute, single-use challenge. Unknown accounts and accounts
without an enrolled recovery key get the same response shape with a random
decoy public key. Recovery start and finish share the anonymous-auth rate limit
of 20 requests per IP per minute.
The client derives the recovery public key from the phrase and compares it to the response before continuing. It then creates the OPAQUE registration upload for the requested account and signs:
"SafePass account recovery reset v1\0"
|| account_id_ascii[36]
|| challenge_id[32]
|| challenge_nonce[32]
|| SHA-256(opaque_registration_upload)crypto_core applies its recovery-signature domain separator around this
payload. Including the upload hash binds the proof to the exact replacement
credential and prevents a captured signature from authorizing another
password registration.
POST /v1/auth/recovery/finish verifies the signature with the registered
public key, derives the server-side OPAQUE password file, then uses one
PostgreSQL transaction to replace that file, consume all outstanding recovery
challenges, and revoke every refresh-token family for the account. Only after
the transaction commits does the API issue the recovering device a new access
and refresh token pair. Invalid proofs, expired challenges, unknown accounts,
and replays return the same generic recovery failure.
Recovery immediately invalidates access JWTs with sid for the revoked
sessions. Legacy JWTs without sid can remain usable until their 15-minute
lifetime ends. Recovery does not reset an existing local device master password. The
user recovers the vault key from the SPRE envelope on the client, then
chooses a new local master password as part of the local restore flow.
On an already linked device, the reset leaves the open local vault and records
intact. On a new empty device, the client fetches the envelope after reset,
verifies and unwraps it with the account phrase, wraps the shared key under that
device's chosen local master password, then syncs the encrypted records. A
non-empty unlinked local vault cannot be overwritten by this flow.
Persistent data and compatibility
Migration 006_account_recovery_challenges.sql adds a 32-byte public-key
column and a table containing challenge IDs, public nonces, purpose, expiry,
single-use state, and a snapshot of the key that must verify the request. A
challenge row is removed after expiry cleanup. These values contain no
password, mnemonic, private key, or vault plaintext.
Recovery envelopes written before v1 may have no registered public key. Such accounts must enroll the key from an authenticated device before password recovery is available. The existing encrypted envelope remains unreadable to the API.