mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
This commit is contained in:
parent
6c9cc71ae1
commit
fc99eff005
@ -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';
|
||||
|
||||
102
crdt_sync_dart/lib/src/firebase_settings.dart
Normal file
102
crdt_sync_dart/lib/src/firebase_settings.dart
Normal 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';
|
||||
@ -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:
|
||||
|
||||
77
crdt_sync_dart/test/firebase_settings_test.dart
Normal file
77
crdt_sync_dart/test/firebase_settings_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user