account: stop rotating the SRP challenge on every getPassword read

GetPassword minted a brand-new random SRP server secret and B on every call
while only ever assigning SRPID once (when zero). Two account.getPassword
calls in a row -- e.g. a settings screen refreshing state, then the transfer-
ownership dialog's own cloudPassword().reload() moments later -- silently
invalidated each other's B with no signal the client could detect (SRPID
unchanged), so a password check built from the first response's B failed
with PASSWORD_HASH_INVALID even though the typed password was correct.

The challenge now stays stable across reads and only rotates when it's
missing entirely; UpdatePasswordSettings/RecoverPassword already mint their
own fresh challenge whenever the password actually changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Astra 2026-09-13 22:30:02 +01:00
parent 1f6f25074a
commit 6435406690
2 changed files with 62 additions and 6 deletions

View file

@ -271,18 +271,26 @@ func (s *Service) GetPassword(ctx context.Context, userID int64) (domain.Passwor
return defaultPasswordSettings(), nil
}
settings = normalizePasswordSettings(settings)
if settings.HasPassword {
// Only mint a fresh SRP challenge (secret/B/SRPID together) when none is
// outstanding yet. Regenerating B on every read while leaving SRPID
// untouched let any two account.getPassword calls silently invalidate each
// other's B without a signal the client could detect (SRPID unchanged) --
// a client that fetched the password state, had a second screen/dialog
// refresh it again, then submitted against the first B it saw got a false
// PASSWORD_HASH_INVALID even with the correct password. The challenge must
// stay stable across reads and only rotate when it's actually consumed by
// a password change (UpdatePasswordSettings/RecoverPassword already mint
// their own fresh challenge there).
if settings.HasPassword && (len(settings.SRPBSecret) == 0 || len(settings.SRPB) == 0 || settings.SRPID == 0) {
secret, b, err := makeSRPChallenge(settings.SRPVerifier)
if err != nil {
return domain.PasswordSettings{}, err
}
settings.SRPBSecret = secret
settings.SRPB = b
if settings.SRPID == 0 {
settings.SRPID, err = randomInt64()
if err != nil {
return domain.PasswordSettings{}, err
}
settings.SRPID, err = randomInt64()
if err != nil {
return domain.PasswordSettings{}, err
}
if err := s.passwords.Save(ctx, userID, settings); err != nil {
return domain.PasswordSettings{}, err