M .builds/android.yml => .builds/android.yml +7 -3
@@ 13,8 13,9 @@ secrets:
- 5874ac5a-905e-4596-a117-fed1401c60ce # deploy SSH key
- 6d21b97d-cd64-4490-b325-acf8b05a542f # keystore.jks
- 431b0b53-2af2-441b-b879-86c5913bab4d # keystore.properties
+ - 4e454305-057f-44c3-9a4e-eeb74d54545b # google-services.json
artifacts:
- - goguma/build/app/outputs/flutter-apk/app-release.apk
+ - goguma/build/app/outputs/flutter-apk/app-firebase-release.apk
tasks:
- setup-tools: |
sudo chown -R $USER /opt/flutter /opt/android-sdk
@@ 26,9 27,12 @@ tasks:
cd goguma
ln -s ~/keystore.properties android/keystore.properties
ln -s ~/keystore.jks android/keystore.jks
+ flutter pub get
+ flutter pub run tool/gen_firebase_options.dart ~/google-services.json lib/firebase_options.dart
- build: |
cd goguma
- flutter build apk --build-number="$(git rev-list --first-parent --count HEAD)"
+ build_number="$(git rev-list --first-parent --count HEAD)"
+ flutter build apk --build-number="$build_number" --flavor=firebase --target=lib/main_firebase.dart
- analyze: |
cd goguma
flutter analyze --no-fatal-infos
@@ 37,5 41,5 @@ tasks:
[ "$(git rev-parse origin/master)" = "$(git rev-parse HEAD)" ] || complete-build
build_number="$(git rev-list --first-parent --count HEAD)"
ssh_opts="-o StrictHostKeyChecking=no"
- scp $ssh_opts build/app/outputs/flutter-apk/app-release.apk deploy@emersion.fr:fdroid-goguma-nightly/repo/goguma-"$build_number".apk
+ scp $ssh_opts build/app/outputs/flutter-apk/app-firebase-release.apk deploy@emersion.fr:fdroid-goguma-nightly/repo/goguma-"$build_number".apk
ssh $ssh_opts deploy@emersion.fr "cd fdroid-goguma-nightly && (ls -t repo/*.apk | tail -n +5 | xargs -r rm --) && fdroid update"
M .gitignore => .gitignore +3 -0
@@ 44,3 44,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
+
+# Firebase API keys
+/lib/firebase_options.dart
M android/app/build.gradle => android/app/build.gradle +10 -0
@@ 74,6 74,16 @@ android {
}
}
}
+
+ flavorDimensions "app"
+
+ productFlavors {
+ firebase {
+ dimension "app"
+ versionNameSuffix "-firebase"
+ minSdkVersion 21
+ }
+ }
}
flutter {
M android/app/src/main/AndroidManifest.xml => android/app/src/main/AndroidManifest.xml +5 -0
@@ 41,5 41,10 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
+
+ <!-- https://firebase.google.com/docs/analytics/configure-data-collection?platform=android -->
+ <meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
+ <meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
+ <meta-data android:name="google_analytics_ssaid_collection_enabled" android:value="false" />
</application>
</manifest>
A doc/firebase.md => doc/firebase.md +19 -0
@@ 0,0 1,19 @@
+# Firebase Cloud Messaging
+
+Firebase Cloud Messaging can be used in combination with [pushgarden] to enable
+Web Push support on Android.
+
+First, create a Firebase app and obtain the `google-services.json` file. Then
+run:
+
+ flutter pub run tool/gen_firebase_options.dart /path/to/google-services.json lib/firebase_options.dart
+
+Then build Goguma with the `firebase` flavor, with the Firebase main entrypoint
+and your pushgarden instance:
+
+ flutter build apk --flavor=firebase --target=lib/main_firebase.dart --dart-define=pushgardenEndpoint='https://example.org'
+
+For instance, to connect from the Android emulator to a locally running
+instance of pushgarden, one can use `http://10.0.2.2:8080`.
+
+[pushgarden]: https://git.sr.ht/~emersion/pushgarden
M lib/client.dart => lib/client.dart +18 -0
@@ 73,6 73,7 @@ const _permanentCaps = [
'soju.im/bouncer-networks',
'soju.im/no-implicit-names',
'soju.im/read',
+ 'soju.im/webpush',
];
var _nextClientId = 0;
@@ 876,6 877,23 @@ class Client {
});
_params = _params.apply(realname: realname);
}
+
+ Future<void> webPushRegister(String endpoint, Map<String, List<int>> keys) {
+ Map<String, String> encodedKeys = Map.fromEntries(keys.entries.map((kv) {
+ return MapEntry(kv.key, base64Url.encode(kv.value));
+ }));
+ var msg = IrcMessage('WEBPUSH', ['REGISTER', endpoint, formatIrcTags(encodedKeys)]);
+ return _roundtripMessage(msg, (msg) {
+ return msg.cmd == 'WEBPUSH' && msg.params[0] == 'REGISTER' && msg.params[1] == endpoint;
+ });
+ }
+
+ Future<void> webPushUnregister(String endpoint) {
+ var msg = IrcMessage('WEBPUSH', ['UNREGISTER', endpoint]);
+ return _roundtripMessage(msg, (msg) {
+ return msg.cmd == 'WEBPUSH' && msg.params[0] == 'UNREGISTER' && msg.params[1] == endpoint;
+ });
+ }
}
class ClientMessage extends IrcMessage {
M lib/client_controller.dart => lib/client_controller.dart +55 -0
@@ 2,15 2,18 @@ import 'dart:async';
import 'dart:collection';
import 'dart:io';
+import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_background/flutter_background.dart';
import 'package:workmanager/workmanager.dart';
import 'client.dart';
import 'database.dart';
+import 'firebase.dart';
import 'irc.dart';
import 'models.dart';
import 'notification_controller.dart';
+import 'webpush.dart';
ConnectParams connectParamsFromServerEntry(ServerEntry entry, { required String defaultNickname, String? defaultRealname }) {
var nick = entry.nick ?? defaultNickname;
@@ 349,6 352,10 @@ class ClientController {
client.monitor(l);
}
+ if (client.caps.enabled.contains('soju.im/webpush')) {
+ _setupPushSync();
+ }
+
List<Future<void>> syncFutures = [];
// Query latest READ status for user targets
@@ 746,4 753,52 @@ class ClientController {
await _handleChatMessages(target.name, batch.messages);
}));
}
+
+ void _setupPushSync() async {
+ if (!Platform.isAndroid || !FirebaseMessaging.instance.isSupported()) {
+ return;
+ }
+
+ print('Enabling push synchronization');
+
+ var subs = await _db.listWebPushSubscriptions();
+ var vapidKey = client.isupport.vapid;
+
+ WebPushSubscription? oldSub;
+ for (var sub in subs) {
+ if (sub.network == network.networkId) {
+ oldSub = sub;
+ break;
+ }
+ }
+
+ if (oldSub != null) {
+ // TODO: also unregister on Firebase token change
+
+ if (oldSub.vapidKey == vapidKey) {
+ // Refresh our subscription
+ await client.webPushRegister(oldSub.endpoint, oldSub.getPublicKeys());
+ return;
+ }
+
+ // TODO: delete our pushgarden subscription
+ await client.webPushUnregister(oldSub.endpoint);
+ await _db.deleteWebPushSubscription(oldSub.id!);
+ }
+
+ var endpoint = await createFirebaseSubscription(vapidKey);
+ var webPush = await WebPush.generate();
+ var config = await webPush.exportPrivateKeys();
+ var newSub = WebPushSubscription(
+ network: network.networkId,
+ endpoint: endpoint,
+ vapidKey: vapidKey,
+ p256dhPrivateKey: config.p256dhPrivateKey,
+ p256dhPublicKey: config.p256dhPublicKey,
+ authKey: config.authKey,
+ );
+
+ await client.webPushRegister(endpoint, config.getPublicKeys());
+ await _db.storeWebPushSubscription(newSub);
+ }
}
M lib/database.dart => lib/database.dart +104 -1
@@ 1,5 1,6 @@
import 'dart:io';
import 'dart:async';
+import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart';
@@ 139,6 140,57 @@ class MessageEntry {
}
}
+class WebPushSubscription {
+ int? id;
+ final int network;
+ final String endpoint;
+ final String? vapidKey;
+ final Uint8List p256dhPrivateKey;
+ final Uint8List p256dhPublicKey;
+ final Uint8List authKey;
+ final DateTime createdAt;
+
+ Map<String, Object?> toMap() {
+ return <String, Object?>{
+ 'id': id,
+ 'network': network,
+ 'endpoint': endpoint,
+ 'vapid_key': vapidKey,
+ 'p256dh_private_key': p256dhPrivateKey,
+ 'p256dh_public_key': p256dhPublicKey,
+ 'auth_key': authKey,
+ 'created_at': formatIrcTime(createdAt),
+ };
+ }
+
+ WebPushSubscription({
+ required this.network,
+ required this.endpoint,
+ required this.p256dhPrivateKey,
+ required this.p256dhPublicKey,
+ required this.authKey,
+ this.vapidKey,
+ }) :
+ createdAt = DateTime.now();
+
+ WebPushSubscription.fromMap(Map<String, dynamic> m) :
+ id = m['id'] as int,
+ network = m['network'] as int,
+ endpoint = m['endpoint'] as String,
+ vapidKey = m['vapid_key'] as String?,
+ p256dhPrivateKey = m['p256dh_private_key'] as Uint8List,
+ p256dhPublicKey = m['p256dh_public_key'] as Uint8List,
+ authKey = m['auth_key'] as Uint8List,
+ createdAt = DateTime.parse(m['created_at'] as String);
+
+ Map<String, Uint8List> getPublicKeys() {
+ return {
+ 'p256dh': p256dhPublicKey,
+ 'auth': authKey,
+ };
+ }
+}
+
class DB {
final Database _db;
@@ 210,6 262,20 @@ class DB {
CREATE INDEX index_message_buffer_time
ON Message(buffer, time);
''');
+ batch.execute('''
+ CREATE TABLE WebPushSubscription (
+ id INTEGER PRIMARY KEY,
+ network INTEGER NOT NULL,
+ endpoint TEXT NOT NULL,
+ vapid_key TEXT,
+ p256dh_public_key BLOB,
+ p256dh_private_key BLOB,
+ auth_key BLOB,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (network) REFERENCES Network(id) ON DELETE CASCADE,
+ UNIQUE(network, endpoint)
+ );
+ ''');
await batch.commit();
},
onUpgrade: (db, prevVersion, newVersion) async {
@@ 242,12 308,28 @@ class DB {
ALTER TABLE Buffer ADD COLUMN realname TEXT;
''');
}
+ if (prevVersion < 7) {
+ batch.execute('''
+ CREATE TABLE WebPushSubscription (
+ id INTEGER PRIMARY KEY,
+ network INTEGER NOT NULL,
+ endpoint TEXT NOT NULL,
+ vapid_key TEXT,
+ p256dh_public_key BLOB,
+ p256dh_private_key BLOB,
+ auth_key BLOB,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (network) REFERENCES Network(id) ON DELETE CASCADE,
+ UNIQUE(network, endpoint)
+ );
+ ''');
+ }
await batch.commit();
},
onDowngrade: (_, prevVersion, newVersion) async {
throw Exception('Attempted to downgrade database from version $prevVersion to version $newVersion');
},
- version: 6,
+ version: 7,
);
return DB._(db);
}
@@ 412,4 494,25 @@ class DB {
}));
});
}
+
+ Future<List<WebPushSubscription>> listWebPushSubscriptions() async {
+ var entries = await _db.rawQuery('''
+ SELECT id, network, endpoint, vapid_key, p256dh_public_key,
+ p256dh_private_key, auth_key, created_at
+ FROM WebPushSubscription
+ ''');
+ return entries.map((m) => WebPushSubscription.fromMap(m)).toList();
+ }
+
+ Future<void> storeWebPushSubscription(WebPushSubscription entry) async {
+ if (entry.id == null) {
+ entry.id = await _db.insert('WebPushSubscription', entry.toMap());
+ } else {
+ await _updateById('WebPushSubscription', entry.toMap());
+ }
+ }
+
+ Future<void> deleteWebPushSubscription(int id) async {
+ await _db.rawDelete('DELETE FROM WebPushSubscription WHERE id = ?', [id]);
+ }
}
A lib/firebase.dart => lib/firebase.dart +187 -0
@@ 0,0 1,187 @@
+import 'dart:convert' show json, utf8, base64;
+import 'dart:io';
+
+import 'package:firebase_core/firebase_core.dart';
+import 'package:firebase_messaging/firebase_messaging.dart';
+import 'package:shared_preferences_android/shared_preferences_android.dart';
+
+import 'database.dart';
+import 'irc.dart';
+import 'models.dart';
+import 'notification_controller.dart';
+import 'prefs.dart';
+import 'webpush.dart';
+
+FirebaseOptions? firebaseOptions;
+
+final _gatewayEndpoint = Uri.parse(
+ String.fromEnvironment('pushgardenEndpoint', defaultValue: 'https://pushgarden.emersion.fr')
+);
+
+Future<String> createFirebaseSubscription(String? vapidKey) async {
+ var token = await FirebaseMessaging.instance.getToken();
+ var client = HttpClient();
+ try {
+ var url = _gatewayEndpoint.resolve('/firebase/${firebaseOptions!.projectId}/subscribe?token=$token');
+ var req = await client.postUrl(url);
+ req.headers.contentType = ContentType('application', 'webpush-options+json', charset: 'utf-8');
+ req.write(json.encode({
+ 'vapid': vapidKey,
+ }));
+ var resp = await req.close();
+ if (resp.statusCode ~/ 100 != 2) {
+ throw Exception('HTTP error ${resp.statusCode}');
+ }
+
+ // TODO: parse subscription resource URL as well
+
+ String? pushLink;
+ for (var rawLink in resp.headers['Link'] ?? <String>[]) {
+ var link = HeaderValue.parse(rawLink);
+ if (link.parameters['rel'] == 'urn:ietf:params:push') {
+ pushLink = link.value;
+ break;
+ }
+ }
+
+ if (pushLink == null || !pushLink.startsWith('<') || !pushLink.endsWith('>')) {
+ throw FormatException('No valid urn:ietf:params:push Link found');
+ }
+ var pushUrl = pushLink.substring(1, pushLink.length - 1);
+ return _gatewayEndpoint.resolve(pushUrl).toString();
+ } finally {
+ client.close();
+ }
+}
+
+void initFirebaseMessaging() async {
+ if (!Platform.isAndroid || firebaseOptions == null) {
+ return;
+ }
+
+ await Firebase.initializeApp(options: firebaseOptions!);
+
+ if (!FirebaseMessaging.instance.isSupported()) {
+ return;
+ }
+
+ FirebaseMessaging.onBackgroundMessage(_handleFirebaseMessage);
+ FirebaseMessaging.onMessage.listen(_handleFirebaseMessage);
+}
+
+// This function may called from a separate Isolate
+Future<void> _handleFirebaseMessage(RemoteMessage message) async {
+ print('Received push message: ${message.data}');
+
+ var encodedPayload = message.data['payload'] as String;
+ var endpoint = Uri.parse(message.data['endpoint'] as String);
+ var vapidKey = message.data['vapid_key'] as String?;
+
+ var db = await DB.open();
+ var subs = await db.listWebPushSubscriptions();
+ var sub = subs.firstWhere((sub) {
+ // data['endpoint'] is typically missing the hostname
+ var subEndpointUri = Uri.parse(sub.endpoint);
+ var msgEndpointUri = subEndpointUri.resolveUri(endpoint);
+ return subEndpointUri == msgEndpointUri;
+ });
+
+ if (sub.vapidKey != vapidKey) {
+ throw Exception('VAPID public key mismatch');
+ }
+
+ var config = WebPushConfig(
+ p256dhPublicKey: sub.p256dhPublicKey,
+ p256dhPrivateKey: sub.p256dhPrivateKey,
+ authKey: sub.authKey,
+ );
+ var webPush = await WebPush.import(config);
+
+ List<int> ciphertext = base64.decode(encodedPayload);
+ var bytes = await webPush.decrypt(ciphertext);
+ var str = utf8.decode(bytes);
+ var msg = IrcMessage.parse(str);
+
+ print('Decrypted push message payload: $msg');
+
+ // TODO: cancel existing notifications on READ
+ if (msg.cmd != 'PRIVMSG' && msg.cmd != 'NOTICE') {
+ print('Ignoring ${msg.cmd} message');
+ return;
+ }
+
+ var target = msg.params[0];
+
+ var networkEntry = await _fetchNetwork(db, sub.network);
+ if (networkEntry == null) {
+ throw Exception('Got push message for an unknown network #${sub.network}');
+ }
+ var serverEntry = await _fetchServer(db, networkEntry.server);
+ if (serverEntry == null) {
+ throw Exception('Network #${sub.network} has an unknown server #${networkEntry.server}');
+ }
+
+ // See: https://github.com/flutter/flutter/issues/98473#issuecomment-1060952450
+ if (Platform.isAndroid) {
+ SharedPreferencesAndroid.registerWith();
+ }
+ var prefs = await Prefs.load();
+
+ var nickname = serverEntry.nick ?? prefs.nickname;
+ var realname = prefs.realname ?? nickname;
+ var network = NetworkModel(serverEntry, networkEntry, nickname, realname);
+
+ var bufferEntry = await _fetchBuffer(db, target, sub.network);
+ if (bufferEntry == null) {
+ bufferEntry = BufferEntry(name: target, network: sub.network);
+ await db.storeBuffer(bufferEntry);
+ }
+
+ var buffer = BufferModel(entry: bufferEntry, network: network);
+
+ var msgEntry = MessageEntry(msg, bufferEntry.id!);
+
+ var notifController = NotificationController();
+ await notifController.initialize();
+
+ // TODO: use a cached CHANTYPES instead
+ var isChannel = target.startsWith('#');
+ if (isChannel) {
+ notifController.showHighlight([msgEntry], buffer);
+ } else {
+ notifController.showDirectMessage([msgEntry], buffer);
+ }
+}
+
+Future<NetworkEntry?> _fetchNetwork(DB db, int id) async {
+ var entries = await db.listNetworks();
+ for (var entry in entries) {
+ if (entry.id == id) {
+ return entry;
+ }
+ }
+ return null;
+}
+
+Future<ServerEntry?> _fetchServer(DB db, int id) async {
+ var entries = await db.listServers();
+ for (var entry in entries) {
+ if (entry.id == id) {
+ return entry;
+ }
+ }
+ return null;
+}
+
+Future<BufferEntry?> _fetchBuffer(DB db, String name, int networkId) async {
+ // TODO: use a cached CASEMAPPING instead
+ var cm = defaultCaseMapping;
+
+ var entries = await db.listBuffers();
+ for (var entry in entries) {
+ if (entry.network == networkId && cm(entry.name) == cm(name)) {
+ return entry;
+ }
+ }
+ return null;
+}
M lib/irc.dart => lib/irc.dart +9 -0
@@ 423,6 423,7 @@ class IrcIsupportRegistry {
int? _topicLen, _nickLen, _realnameLen;
List<String>? _chanModes;
IrcIsupportElist? _elist;
+ String? _vapid;
String? get network => _network;
String get chanTypes => _chanTypes ?? '';
@@ 437,6 438,7 @@ class IrcIsupportRegistry {
int? get realnameLen => _realnameLen;
List<String> get chanModes => UnmodifiableListView(_chanModes ?? ['beI', 'k', 'l', 'imnst']);
IrcIsupportElist? get elist => _elist;
+ String? get vapid => _vapid;
void parse(List<String> tokens) {
for (var tok in tokens) {
@@ 479,6 481,9 @@ class IrcIsupportRegistry {
case 'TOPIC':
_topicLen = null;
break;
+ case 'VAPID':
+ _vapid = null;
+ break;
case 'WHOX':
_whox = false;
break;
@@ 560,6 565,9 @@ class IrcIsupportRegistry {
}
_topicLen = int.parse(v);
break;
+ case 'VAPID':
+ _vapid = v;
+ break;
case 'WHOX':
_whox = true;
break;
@@ 580,6 588,7 @@ class IrcIsupportRegistry {
_nickLen = null;
_realnameLen = null;
_elist = null;
+ _vapid = null;
}
}
M lib/main.dart => lib/main.dart +2 -0
@@ 13,6 13,7 @@ import 'app.dart';
import 'client.dart';
import 'client_controller.dart';
import 'database.dart';
+import 'firebase.dart';
import 'models.dart';
import 'notification_controller.dart';
import 'prefs.dart';
@@ 39,6 40,7 @@ void _main() async {
WidgetsFlutterBinding.ensureInitialized();
_initWorkManager();
+ initFirebaseMessaging();
if (Platform.isAndroid) {
trustIsrgRootX1();
A lib/main_firebase.dart => lib/main_firebase.dart +9 -0
@@ 0,0 1,9 @@
+// firebase_options.dart is generated -- see doc/firebase.md
+import 'firebase_options.dart' as provider;
+import 'firebase.dart';
+import 'main.dart' as base;
+
+void main() {
+ firebaseOptions = provider.firebaseOptions;
+ base.main();
+}
A lib/webpush.dart => lib/webpush.dart +115 -0
@@ 0,0 1,115 @@
+import 'dart:typed_data';
+
+import 'package:webcrypto/webcrypto.dart';
+
+const _saltSize = 16;
+const _ikmSize = 32;
+const _contentEncryptionKeySize = 16;
+const _nonceSize = 12;
+
+const _paddingDelimiter = 0x02;
+
+class WebPush {
+ final EcdhPrivateKey _p256dhPrivateKey;
+ final EcdhPublicKey _p256dhPublicKey;
+ final Uint8List _authKey;
+
+ WebPush._(this._p256dhPrivateKey, this._p256dhPublicKey, this._authKey);
+
+ static Future<WebPush> generate() async {
+ var authKey = Uint8List(16);
+ fillRandomBytes(authKey);
+
+ var p256dhKeyPair = await EcdhPrivateKey.generateKey(EllipticCurve.p256);
+ return WebPush._(p256dhKeyPair.privateKey, p256dhKeyPair.publicKey, authKey);
+ }
+
+ static Future<WebPush> import(WebPushConfig raw) async {
+ var p256dhPrivateKey = await EcdhPrivateKey.importPkcs8Key(raw.p256dhPrivateKey, EllipticCurve.p256);
+ var p256dhPublicKey = await EcdhPublicKey.importRawKey(raw.p256dhPublicKey, EllipticCurve.p256);
+ return WebPush._(p256dhPrivateKey, p256dhPublicKey, raw.authKey);
+ }
+
+ Future<Map<String, Uint8List>> exportPublicKeys() async {
+ return {
+ 'p256dh': await _p256dhPublicKey.exportRawKey(),
+ 'auth': _authKey,
+ };
+ }
+
+ Future<WebPushConfig> exportPrivateKeys() async {
+ return WebPushConfig(
+ p256dhPrivateKey: await _p256dhPrivateKey.exportPkcs8Key(),
+ p256dhPublicKey: await _p256dhPublicKey.exportRawKey(),
+ authKey: _authKey,
+ );
+ }
+
+ Future<Uint8List> decrypt(List<int> buf) async {
+ var offset = 0;
+
+ var salt = buf.sublist(offset, offset + _saltSize);
+ offset += salt.length;
+
+ var recordSizeBytes = buf.sublist(offset, offset + 4);
+ offset += recordSizeBytes.length;
+
+ var serverPubKeySize = buf[offset];
+ offset++;
+
+ var serverPubKeyBytes = buf.sublist(offset, offset + serverPubKeySize);
+ offset += serverPubKeyBytes.length;
+
+ var body = buf.sublist(offset);
+
+ var recordSize = ByteData.sublistView(Uint8List.fromList(recordSizeBytes)).getUint32(0, Endian.big);
+ if (recordSize != buf.length) {
+ throw FormatException('Encrypted payload size (${buf.length}) doesn\'t match record size field ($recordSize)');
+ }
+
+ var serverOneTimePubKey = await EcdhPublicKey.importRawKey(serverPubKeyBytes, EllipticCurve.p256);
+ var sharedEcdhSecret = await _p256dhPrivateKey.deriveBits(32 * 8, serverOneTimePubKey);
+ var clientPubKeyRaw = await _p256dhPublicKey.exportRawKey();
+
+ var info = [
+ ...'WebPush: info'.codeUnits,
+ 0,
+ ...clientPubKeyRaw,
+ ...serverPubKeyBytes,
+ ];
+ var hkdfSecretKey = await HkdfSecretKey.importRawKey(sharedEcdhSecret);
+ var ikm = await hkdfSecretKey.deriveBits(_ikmSize * 8, Hash.sha256, _authKey, info);
+
+ info = [...'Content-Encoding: aes128gcm'.codeUnits, 0];
+ hkdfSecretKey = await HkdfSecretKey.importRawKey(ikm);
+ var contentEncryptionKeyBytes = await hkdfSecretKey.deriveBits(_contentEncryptionKeySize * 8, Hash.sha256, salt, info);
+ info = [...'Content-Encoding: nonce'.codeUnits, 0];
+ var nonce = await hkdfSecretKey.deriveBits(_nonceSize * 8, Hash.sha256, salt, info);
+
+ var aesSecretKey = await AesGcmSecretKey.importRawKey(contentEncryptionKeyBytes);
+ var cleartext = await aesSecretKey.decryptBytes(body, nonce, tagLength: _contentEncryptionKeySize * 8);
+
+ var paddingIndex = cleartext.lastIndexOf(_paddingDelimiter);
+ if (paddingIndex < 0) {
+ throw FormatException('Missing padding delimiter in cleartext');
+ }
+ cleartext = cleartext.sublist(0, paddingIndex);
+
+ return cleartext;
+ }
+}
+
+class WebPushConfig {
+ final Uint8List p256dhPublicKey;
+ final Uint8List p256dhPrivateKey;
+ final Uint8List authKey;
+
+ WebPushConfig({ required this.p256dhPublicKey, required this.p256dhPrivateKey, required this.authKey });
+
+ Map<String, Uint8List> getPublicKeys() {
+ return {
+ 'p256dh': p256dhPublicKey,
+ 'auth': authKey,
+ };
+ }
+}
M pubspec.lock => pubspec.lock +52 -1
@@ 92,6 92,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.2"
+ firebase_core:
+ dependency: "direct main"
+ description:
+ name: firebase_core
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.13.1"
+ firebase_core_platform_interface:
+ dependency: transitive
+ description:
+ name: firebase_core_platform_interface
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "4.2.5"
+ firebase_core_web:
+ dependency: transitive
+ description:
+ name: firebase_core_web
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.6.1"
+ firebase_messaging:
+ dependency: "direct main"
+ description:
+ name: firebase_messaging
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "11.2.8"
+ firebase_messaging_platform_interface:
+ dependency: transitive
+ description:
+ name: firebase_messaging_platform_interface
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "3.2.1"
+ firebase_messaging_web:
+ dependency: transitive
+ description:
+ name: firebase_messaging_web
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.2.9"
flutter:
dependency: "direct main"
description: flutter
@@ 299,7 341,7 @@ packages:
source: hosted
version: "2.0.13"
shared_preferences_android:
- dependency: transitive
+ dependency: "direct main"
description:
name: shared_preferences_android
url: "https://pub.dartlang.org"
@@ 464,6 506,15 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
+ webcrypto:
+ dependency: "direct main"
+ description:
+ path: "."
+ ref: cbd72d4bffb1334e0b57565d258ca93f9ae8375e
+ resolved-ref: cbd72d4bffb1334e0b57565d258ca93f9ae8375e
+ url: "https://github.com/google/webcrypto.dart.git"
+ source: git
+ version: "0.5.2"
win32:
dependency: transitive
description:
M pubspec.yaml => pubspec.yaml +9 -0
@@ 28,6 28,15 @@ dependencies:
flutter_background: ^1.1.0
connectivity_plus: ^2.2.1
flutter_typeahead: ^3.2.4
+ # TODO: replace "webcrypto" with the "cryptography" package once they have a
+ # pure Dart Ecdh.p256 implementation
+ webcrypto:
+ git:
+ url: https://github.com/google/webcrypto.dart.git
+ ref: cbd72d4bffb1334e0b57565d258ca93f9ae8375e
+ firebase_core: ^1.13.1
+ firebase_messaging: ^11.2.8
+ shared_preferences_android: ^2.0.11
dev_dependencies:
flutter_lints: ^1.0.4
A tool/gen_firebase_options.dart => tool/gen_firebase_options.dart +32 -0
@@ 0,0 1,32 @@
+import 'dart:convert' show json;
+import 'dart:io';
+
+void main(List<String> args) async {
+ if (args.length != 2) {
+ stderr.writeln('usage: gen_firebase_options google-services.json firebase_options.dart');
+ return;
+ }
+
+ var inputFilename = args[0];
+ var outputFilename = args[1];
+
+ var str = await File(inputFilename).readAsString();
+ var data = json.decode(str);
+
+ var projectId = data['project_info']['project_id'] as String;
+ var messagingSenderId = data['project_info']['project_number'] as String;
+ var appId = data['client'][0]['client_info']['mobilesdk_app_id'] as String;
+ var apiKey = data['client'][0]['api_key'][0]['current_key'] as String;
+
+ var gen = '''import 'package:firebase_core/firebase_core.dart';
+
+const firebaseOptions = FirebaseOptions(
+ apiKey: '$apiKey',
+ appId: '$appId',
+ messagingSenderId: '$messagingSenderId',
+ projectId: '$projectId',
+);
+''';
+
+ await File(outputFilename).writeAsString(gen);
+}