crdt_sync_dart 0.6.0: split Firebase settings by what is safe to publish
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run

Every repo in this fleet is public, so where each value lives is a disclosure
decision rather than a style choice.

FirebaseProject holds apiKey + databaseUrl: these identify the *project*, and
the Web API key is a public identifier by design -- it ships inside every
Android APK, and the security rules, not the key, are what protect the data.
Safe to bake into app source, which keeps a reinstall zero-setup.

FirebaseAccount holds the email and password: these identify *a person*, so
they are entered once per device and kept in the platform keystore, never in
SharedPreferences and never in a repo.

The uid is deliberately absent from both. Nothing client-side needs it -- the
rules compare auth.uid server-side, so a wrong account is refused by the
database. A local copy would add a way to be wrong without adding protection.

Keyed at `firebase.account`, distinct from `sync.token` (the GitHub PAT) and
`firebase.credentials` (the refresh token), so all three coexist during the
mirrored cutover.

13 tests, 100% line coverage; 241 in the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
This commit is contained in:
Krzysztof Rudnicki 2026-08-04 23:05:13 +02:00
parent 6c9cc71ae1
commit fc99eff005
4 changed files with 181 additions and 1 deletions

View File

@ -4,6 +4,7 @@ library;
export 'src/firebase_auth_rest.dart';
export 'src/firebase_client.dart';
export 'src/firebase_config.dart';
export 'src/firebase_settings.dart';
export 'src/github_client.dart';
export 'src/hlc.dart';
export 'src/log.dart';

View File

@ -0,0 +1,102 @@
/// Per-device Firebase sign-in settings, split by what is safe to publish.
///
/// Every repo in this fleet is **public**, so the split is not cosmetic:
///
/// * [apiKey] and [databaseUrl] identify the *project*. The Web API key is a
/// public identifier by design -- it ships inside every Android APK, and
/// the security rules, not the key, are what protect the data. Baking these
/// into source is safe and keeps a reinstall zero-setup.
/// * The account [email], its `uid` and its password identify **a person**.
/// Those must never reach a public repo, so they are entered once per
/// device and kept in the platform keystore alongside the refresh token.
///
/// The uid deliberately does not appear here at all. Nothing client-side
/// needs it: the security rules compare `auth.uid` server-side, so a wrong
/// account is refused by the database rather than by a local check.
library;
import 'dart:convert';
import 'firebase_config.dart';
/// The shared project's public identifiers.
///
/// [databaseUrl] is the **regional** host. The plain `*.firebaseio.com` form
/// answers 404 with a `correctUrl` body rather than an obvious error, so a
/// wrong value here reads like an auth failure and wastes a debugging session.
class FirebaseProject {
const FirebaseProject({required this.apiKey, required this.databaseUrl});
/// Parses the project half of a `firebase.json`.
factory FirebaseProject.fromJson(Map<String, dynamic> json) =>
FirebaseProject(
apiKey: json['apiKey'] as String,
databaseUrl: json['databaseUrl'] as String,
);
/// Public project identifier; ships in the APK by design.
final String apiKey;
/// The regional Realtime Database origin, no trailing slash.
final String databaseUrl;
/// Combines this project with a per-device [email] into a full config.
///
/// `projectId` and `uid` are filled with empty strings: neither is used by
/// the REST client, and the uid in particular is checked server-side by the
/// security rules, so carrying a copy of it in the app would add a way to
/// be wrong without adding any protection.
FirebaseConfig configFor(String email) => FirebaseConfig(
apiKey: apiKey,
databaseUrl: databaseUrl,
projectId: '',
uid: '',
email: email,
);
}
/// The account half: entered per device, never committed.
///
/// Stored as one blob in the platform keystore next to the refresh token, so
/// a reinstall asks for it once and nothing identifying is written to
/// `SharedPreferences`, where it would sit in plaintext.
class FirebaseAccount {
const FirebaseAccount({required this.email, required this.password});
/// Reads an account blob previously written by [toJsonString].
///
/// Returns null for absent, empty, corrupt or wrong-shaped values: the
/// caller's next step is to ask for the credentials again, which repairs
/// it. Throwing instead would leave sync permanently unusable.
static FirebaseAccount? tryParse(String? raw) {
if (raw == null || raw.isEmpty) return null;
final Object? decoded;
try {
decoded = jsonDecode(raw);
} on FormatException {
return null;
}
if (decoded is! Map<String, dynamic>) return null;
final email = decoded['email'];
final password = decoded['password'];
if (email is! String || password is! String) return null;
if (email.isEmpty || password.isEmpty) return null;
return FirebaseAccount(email: email, password: password);
}
/// The sync account's email address.
final String email;
/// The sync account's password. Keystore only -- never prefs, never source.
final String password;
/// Serializes for the keystore.
String toJsonString() => jsonEncode({'email': email, 'password': password});
}
/// Where the account blob lives in the platform secret store.
///
/// Namespaced away from `sync.token` (each app's GitHub PAT) and from
/// `firebase.credentials` (the refresh token), so all three coexist for the
/// whole mirrored cutover.
const kFirebaseAccountKey = 'firebase.account';

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.5.1
version: 0.6.0
publish_to: 'none'
environment:

View File

@ -0,0 +1,77 @@
/// Tests for the public/private split of the Firebase settings.
///
/// Every repo in this fleet is public, so which half a value lands in is a
/// disclosure decision, not a style choice. These tests pin that split.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:test/test.dart';
const _project = FirebaseProject(
apiKey: 'AIzaSyExample',
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
);
void main() {
group('FirebaseProject', () {
test('parses the project half of a firebase.json', () {
final project = FirebaseProject.fromJson(const {
'apiKey': 'AIzaSyExample',
'databaseUrl': 'https://x-rtdb.europe-west1.firebasedatabase.app',
'projectId': 'ignored',
'uid': 'ignored',
'email': 'ignored',
});
expect(project.apiKey, 'AIzaSyExample');
expect(project.databaseUrl, endsWith('firebasedatabase.app'));
});
test('builds a usable config from a per-device email', () {
final config = _project.configFor('someone@example.com');
expect(config.apiKey, _project.apiKey);
expect(config.databaseUrl, _project.databaseUrl);
expect(config.email, 'someone@example.com');
});
test('carries no uid, because the rules check it server-side', () {
// A client-side copy of the uid adds a way to be wrong without adding
// any protection: the database refuses a wrong account regardless.
expect(_project.configFor('a@b.c').uid, isEmpty);
});
});
group('FirebaseAccount', () {
test('round-trips through the keystore blob', () {
const account = FirebaseAccount(email: 'a@b.c', password: 'hunter2');
final parsed = FirebaseAccount.tryParse(account.toJsonString());
expect(parsed, isNotNull);
expect(parsed!.email, 'a@b.c');
expect(parsed.password, 'hunter2');
});
test('is keyed apart from the token and the refresh credentials', () {
// All three coexist for the whole mirrored cutover.
expect(kFirebaseAccountKey, isNot('sync.token'));
expect(kFirebaseAccountKey, isNot(SecureCredentialStore.defaultKey));
});
for (final (name, raw) in <(String, String?)>[
('null', null),
('empty', ''),
('corrupt JSON', '{oops'),
('a list', '[1,2]'),
('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);
});
}
});
}