Accept an empty password when parsing a stored Firebase account

Both the Google one-tap path and the seeded-session path deliberately write
`FirebaseAccount(email: ..., password: '')` -- on those devices the refresh
token in the keystore is the credential, and there is no password to store.

`tryParse` rejected exactly that blob. So a device signed in with Google
wrote an account marker it could never read back: every later launch saw
"not configured", fell through to the mirror, and left a perfectly good
refresh token unused in the keystore beside it.

Only widens what parses, so no caller that previously got an account back
can regress. Every call site already guards `password.isEmpty` and passes
null for it -- meaning "no password on this device" -- which is precisely
the case this now lets them see. An empty *email* stays rejected: it
carries no identity at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 17:19:49 +02:00
parent c89fee20ec
commit feaaf69103
3 changed files with 27 additions and 3 deletions

View File

@ -80,7 +80,17 @@ class FirebaseAccount {
final email = decoded['email'];
final password = decoded['password'];
if (email is! String || password is! String) return null;
if (email.isEmpty || password.isEmpty) return null;
// An empty password is legal, and rejecting it was a real bug: both the
// Google one-tap path and the seeded-session path deliberately store
// `password: ''` -- the refresh token, not an address or a password, is
// the credential on those devices. Refusing to parse the blob they had
// just written made every such device read back as "not configured" on
// the next launch, so a signed-in phone fell through to the mirror
// forever. Callers already handle it: each guards `password.isEmpty` and
// passes null, meaning "no password on this device".
//
// An empty *email* stays rejected: it carries no identity at all.
if (email.isEmpty) return null;
return FirebaseAccount(email: email, password: password);
}

View File

@ -2,7 +2,7 @@ name: crdt_sync
description: >-
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
cross-device apps. Pure Dart, no Flutter dependency.
version: 0.10.0
version: 0.11.0
publish_to: 'none'
environment:

View File

@ -67,11 +67,25 @@ void main() {
('a missing password', '{"email":"a@b.c"}'),
('a non-string email', '{"email":1,"password":"p"}'),
('an empty email', '{"email":"","password":"p"}'),
('an empty password', '{"email":"a@b.c","password":""}'),
]) {
test('treats $name as not configured', () {
expect(FirebaseAccount.tryParse(raw), isNull);
});
}
test('accepts an empty password, which Google sign-in writes', () {
// Regression: this used to return null, so a device that signed in with
// Google -- or adopted a seeded session -- wrote an account marker it
// could never read back. Every later launch reported "not configured"
// and fell through to the mirror, while a perfectly good refresh token
// sat unused in the keystore beside it.
final parsed = FirebaseAccount.tryParse(
const FirebaseAccount(email: 'a@b.c', password: '').toJsonString(),
);
expect(parsed, isNotNull);
expect(parsed!.email, 'a@b.c');
expect(parsed.password, isEmpty);
});
});
}