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

@ -68,6 +68,54 @@ func TestPasswordSRPRoundTrip(t *testing.T) {
}
}
// TestGetPasswordChallengeStableAcrossReads guards against a real production
// bug: GetPassword used to mint a brand-new random SRP server secret/B on
// every single call while leaving SRPID untouched. Two account.getPassword
// calls in a row (e.g. a settings screen and, moments later, the transfer-
// ownership dialog's own cloudPassword().reload()) would silently invalidate
// 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 must stay identical across reads until a real password change
// consumes it.
func TestGetPasswordChallengeStableAcrossReads(t *testing.T) {
ctx := context.Background()
const userID int64 = 1003
svc := NewService(memory.NewPasswordStore())
initial, err := svc.GetPassword(ctx, userID)
if err != nil {
t.Fatalf("GetPassword initial: %v", err)
}
algo := initial.NewAlgo
algo.Salt1 = append(append([]byte(nil), algo.Salt1...), bytes.Repeat([]byte{0x11}, 32)...)
if err := svc.UpdatePasswordSettings(ctx, userID, domain.PasswordCheck{Empty: true}, domain.PasswordInputSettings{
NewAlgo: &algo,
NewPasswordHash: verifierForPassword(algo, []byte("correct horse")),
}); err != nil {
t.Fatalf("UpdatePasswordSettings set password: %v", err)
}
first, err := svc.GetPassword(ctx, userID)
if err != nil {
t.Fatalf("GetPassword first: %v", err)
}
// Simulate a second, unrelated screen/dialog refreshing the same cloud
// password state before the user submits their check.
second, err := svc.GetPassword(ctx, userID)
if err != nil {
t.Fatalf("GetPassword second: %v", err)
}
if first.SRPID != second.SRPID || !bytes.Equal(first.SRPB, second.SRPB) {
t.Fatalf("challenge changed across reads: first=%+v second=%+v, want identical SRPID/SRPB", first, second)
}
check := clientPasswordCheck(t, first, []byte("correct horse"))
if err := svc.CheckPassword(ctx, userID, check); err != nil {
t.Fatalf("CheckPassword against first-read challenge after an intervening read: %v", err)
}
}
func TestRecoverPasswordClearsTwoFactorPassword(t *testing.T) {
ctx := context.Background()
const userID int64 = 1002