From fc99eff00599a4c3195e4c1573ba08f15909a10d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 4 Aug 2026 23:05:13 +0200 Subject: [PATCH] crdt_sync_dart 0.6.0: split Firebase settings by what is safe to publish 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) Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc --- crdt_sync_dart/lib/crdt_sync.dart | 1 + crdt_sync_dart/lib/src/firebase_settings.dart | 102 ++++++++++++++++++ crdt_sync_dart/pubspec.yaml | 2 +- .../test/firebase_settings_test.dart | 77 +++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 crdt_sync_dart/lib/src/firebase_settings.dart create mode 100644 crdt_sync_dart/test/firebase_settings_test.dart diff --git a/crdt_sync_dart/lib/crdt_sync.dart b/crdt_sync_dart/lib/crdt_sync.dart index 8bd2999..b209e36 100644 --- a/crdt_sync_dart/lib/crdt_sync.dart +++ b/crdt_sync_dart/lib/crdt_sync.dart @@ -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'; diff --git a/crdt_sync_dart/lib/src/firebase_settings.dart b/crdt_sync_dart/lib/src/firebase_settings.dart new file mode 100644 index 0000000..3bbe65a --- /dev/null +++ b/crdt_sync_dart/lib/src/firebase_settings.dart @@ -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 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) 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'; diff --git a/crdt_sync_dart/pubspec.yaml b/crdt_sync_dart/pubspec.yaml index 61f1881..af7a17a 100644 --- a/crdt_sync_dart/pubspec.yaml +++ b/crdt_sync_dart/pubspec.yaml @@ -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: diff --git a/crdt_sync_dart/test/firebase_settings_test.dart b/crdt_sync_dart/test/firebase_settings_test.dart new file mode 100644 index 0000000..386a92b --- /dev/null +++ b/crdt_sync_dart/test/firebase_settings_test.dart @@ -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); + }); + } + }); +}