implemented an E2E-encrypted Nextcloud push-v2 notification system with support for RSA decryption and signature verification; introduced an iOS Notification Service Extension and native AppDelegate handlers for Talk actions (inline reply and mark-as-read); replaced the legacy notification registration with a new lifecycle managing app passwords and secure keypair storage; added background message handling with tray synchronization and a test notification utility in the settings.

This commit is contained in:
2026-07-04 22:50:18 +02:00
parent 32f7c311bc
commit 74a2ddd17f
56 changed files with 2987 additions and 285 deletions
+193
View File
@@ -0,0 +1,193 @@
import 'dart:developer';
import 'package:crypton/crypton.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../notification/notification_service.dart';
import 'nid_store.dart';
import 'push_decryptor.dart';
import 'push_keypair.dart';
import 'push_registration_store.dart';
import 'push_renderer.dart';
import 'push_subject.dart';
/// How an incoming FCM payload should be interpreted.
enum PushKind {
/// Encrypted Nextcloud push-v2 notification (`subject` + `signature`).
nextcloud,
/// Plaintext MarianumConnect direct push (`source == "connect"`).
connect,
/// Neither — ignored.
unknown,
}
/// Classifies a raw FCM data map. Pure, so it's unit-testable and safe in any
/// isolate. Nextcloud pushes are distinguished by the presence of both
/// `subject` and `signature`; Connect pushes by `source == "connect"`.
PushKind classifyPush(Map<String, dynamic> data) {
final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false;
final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false;
if (hasSubject && hasSignature) return PushKind.nextcloud;
if (data['source'] == 'connect') return PushKind.connect;
return PushKind.unknown;
}
/// Verifies, decrypts, and renders incoming push messages. Delete-pushes cancel
/// the matching tray notification via [NidStore]. Works both in the FCM
/// background isolate and the foreground.
class PushMessageHandler {
final PushKeypair _keypair;
final PushRegistrationStore _registrationStore;
final PushRenderer _renderer;
final NidStore _nidStore;
PushMessageHandler({
PushKeypair? keypair,
PushRegistrationStore? registrationStore,
PushRenderer? renderer,
NidStore? nidStore,
}) : _keypair = keypair ?? const PushKeypair(),
_registrationStore = registrationStore ?? const PushRegistrationStore(),
_renderer = renderer ?? PushRenderer(),
_nidStore = nidStore ?? NidStore();
/// Background isolate entry point registered with
/// `FirebaseMessaging.onBackgroundMessage`.
@pragma('vm:entry-point')
static Future<void> onBackgroundMessage(RemoteMessage message) async {
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message);
}
/// Processes [message]. In the foreground, pass [foreground] true and
/// [openChatToken] so a message for the currently open chat is suppressed
/// (the long-poll already shows it) instead of raising a tray notification.
Future<void> handle(
RemoteMessage message, {
bool foreground = false,
String? openChatToken,
}) async {
final data = message.data;
switch (classifyPush(data)) {
case PushKind.connect:
await _handleConnect(message, foreground: foreground);
break;
case PushKind.nextcloud:
await _handleNextcloud(
data,
foreground: foreground,
openChatToken: openChatToken,
);
break;
case PushKind.unknown:
break;
}
}
Future<void> _handleConnect(
RemoteMessage message, {
required bool foreground,
}) async {
// On iOS the alert is delivered natively by the system; only Android needs
// to render the plaintext payload locally.
final data = message.data;
final title = data['title'] as String?;
final body = data['body'] as String?;
if (title == null) return;
await _renderer.renderConnect(
title: title,
body: body ?? '',
data: data.map((k, v) => MapEntry(k, '$v')),
);
}
Future<void> _handleNextcloud(
Map<String, dynamic> data, {
required bool foreground,
required String? openChatToken,
}) async {
final subjectBase64 = data['subject'] as String;
final signatureBase64 = data['signature'] as String;
final privateKey = await _keypair.loadPrivateKey();
if (privateKey == null) {
log('Push: no device private key, cannot decrypt');
return;
}
final serverPublicKey = await _loadServerPublicKey();
final decryptor = PushDecryptor(
devicePrivateKey: privateKey,
serverPublicKey: serverPublicKey,
);
if (!decryptor.verify(subjectBase64, signatureBase64)) {
log('Push: signature verification failed');
return;
}
final subject = decryptor.decrypt(subjectBase64);
if (subject == null) {
log('Push: could not decrypt subject');
return;
}
if (subject.isAnyDelete) {
await _handleDelete(subject);
return;
}
// Foreground + the referenced chat already open: the long-poll renders the
// message, so just make sure no stale tray entry lingers.
if (foreground &&
subject.isTalk &&
subject.id != null &&
subject.id == openChatToken) {
return;
}
await _renderer.render(subject);
}
Future<void> _handleDelete(PushSubject subject) async {
if (subject.deleteAll) {
final all = await _nidStore.all();
for (final entry in all) {
await _cancel(entry);
}
await _nidStore.clear();
return;
}
final nids = <int>[
if (subject.delete && subject.nid != null) subject.nid!,
...subject.nids,
];
for (final nid in nids) {
final entry = await _nidStore.get(nid);
if (entry != null) await _cancel(entry);
await _nidStore.delete(nid);
}
}
Future<void> _cancel(NidEntry entry) async {
try {
await NotificationService().flutterLocalNotificationsPlugin.cancel(
id: entry.notificationId,
tag: entry.tag,
);
} on Object catch (e) {
log('Push: cancel ${entry.nid} failed: $e');
}
}
Future<RSAPublicKey?> _loadServerPublicKey() async {
final pem = await _registrationStore.serverPublicKeyPem();
if (pem == null || pem.isEmpty) return null;
try {
return RSAPublicKey.fromPEM(pem);
} on Object {
return null;
}
}
}