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
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/nid_store.dart';
void main() {
group('NidEntry', () {
test('round-trips through JSON with a chat token', () {
const entry = NidEntry(
nid: 42,
notificationId: 42,
tag: 'talk_abc123',
chatToken: 'abc123',
);
final restored = NidEntry.fromJson(entry.toJson());
expect(restored.nid, 42);
expect(restored.notificationId, 42);
expect(restored.tag, 'talk_abc123');
expect(restored.chatToken, 'abc123');
});
test('omits the chat token when absent', () {
const entry = NidEntry(nid: 7, notificationId: 7, tag: 'nc_7');
final json = entry.toJson();
expect(json.containsKey('chatToken'), isFalse);
final restored = NidEntry.fromJson(json);
expect(restored.chatToken, isNull);
expect(restored.tag, 'nc_7');
});
});
}
+92
View File
@@ -0,0 +1,92 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypton/crypton.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_decryptor.dart';
import 'package:pointycastle/export.dart' as pc;
/// Encrypts [plain] with [publicKey] using OAEP (SHA-1), mirroring Nextcloud's
/// default padding.
String _encryptOaep(RSAPublicKey publicKey, String plain) {
final cipher = pc.OAEPEncoding(pc.RSAEngine())
..init(
true,
pc.PublicKeyParameter<pc.RSAPublicKey>(publicKey.asPointyCastle),
);
final out = cipher.process(Uint8List.fromList(utf8.encode(plain)));
return base64.encode(out);
}
/// Signs the encrypted bytes with the server private key (SHA512withRSA).
String _sign(RSAPrivateKey serverPrivate, String subjectBase64) {
final signature = serverPrivate.createSHA512Signature(
Uint8List.fromList(base64.decode(subjectBase64)),
);
return base64.encode(signature);
}
void main() {
final device = RSAKeypair.fromRandom();
final server = RSAKeypair.fromRandom();
const subjectJson =
'{"app":"spreed","subject":"Max: Hallo","type":"chat","id":"abc123","nid":42}';
group('PushDecryptor', () {
test('decrypts an OAEP-encrypted subject', () {
final encrypted = _encryptOaep(device.publicKey, subjectJson);
final decryptor = PushDecryptor(
devicePrivateKey: device.privateKey,
serverPublicKey: server.publicKey,
);
expect(
decryptor.verify(encrypted, _sign(server.privateKey, encrypted)),
isTrue,
);
final subject = decryptor.decrypt(encrypted);
expect(subject, isNotNull);
expect(subject!.app, 'spreed');
expect(subject.isTalk, isTrue);
expect(subject.id, 'abc123');
expect(subject.nid, 42);
});
test('decrypts a PKCS1-encrypted subject (fallback)', () {
// crypton's encrypt uses PKCS#1 v1.5.
final encrypted = device.publicKey.encrypt(subjectJson);
final subject = PushDecryptor(
devicePrivateKey: device.privateKey,
).decrypt(encrypted);
expect(subject, isNotNull);
expect(subject!.subject, 'Max: Hallo');
});
test('rejects a signature made with the wrong key', () {
final encrypted = _encryptOaep(device.publicKey, subjectJson);
final wrong = RSAKeypair.fromRandom();
final decryptor = PushDecryptor(
devicePrivateKey: device.privateKey,
serverPublicKey: server.publicKey,
);
expect(
decryptor.verify(encrypted, _sign(wrong.privateKey, encrypted)),
isFalse,
);
});
test('parses delete-multiple subjects', () {
const deleteJson = '{"delete-multiple":true,"nids":[1,2,3]}';
final encrypted = _encryptOaep(device.publicKey, deleteJson);
final subject = PushDecryptor(
devicePrivateKey: device.privateKey,
).decrypt(encrypted);
expect(subject, isNotNull);
expect(subject!.deleteMultiple, isTrue);
expect(subject.isAnyDelete, isTrue);
expect(subject.nids, [1, 2, 3]);
});
});
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_registration.dart';
void main() {
group('PushRegistration.endpointChanged', () {
const live = 'https://connect.marianum-fulda.de/push-proxy/';
const beta = 'https://connect-beta.marianum-fulda.de/push-proxy/';
test('different registered endpoint forces re-registration', () {
expect(
PushRegistration.endpointChanged(registered: live, current: beta),
isTrue,
);
});
test('matching endpoint does not force re-registration', () {
expect(
PushRegistration.endpointChanged(registered: live, current: live),
isFalse,
);
});
test('missing stored endpoint (pre-tracking install) does not force', () {
expect(
PushRegistration.endpointChanged(registered: null, current: live),
isFalse,
);
expect(
PushRegistration.endpointChanged(registered: '', current: live),
isFalse,
);
});
test('nextcloud base URL change is detected the same way', () {
expect(
PushRegistration.endpointChanged(
registered: 'https://cloud.marianum-fulda.de',
current: 'https://mhsl.eu/marianum/marianummobile/cloud',
),
isTrue,
);
});
});
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:crypton/crypton.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_keypair.dart';
void main() {
group('generatePushKeypairPems', () {
test('public PEM matches the Nextcloud SPKI format', () {
final pems = generatePushKeypairPems();
final pem = pems.publicKeyPem;
expect(pem, startsWith('-----BEGIN PUBLIC KEY-----\n'));
expect(pem, endsWith('\n-----END PUBLIC KEY-----'));
// RSA-2048 SPKI base64 wrapped at 64 columns is byte-exactly 450 or 451
// characters — the length Nextcloud validates against.
expect(pem.length, anyOf(450, 451));
// The END marker sits at a fixed offset (header + 392 base64 chars + 6
// embedded newlines = 425, then '\n-----END...').
expect(pem.indexOf('\n-----END PUBLIC KEY-----'), 425);
// Body lines (between the header and footer) wrap at 64 columns.
final body = pem
.split('\n')
.where((l) => !l.startsWith('-----'))
.toList();
for (final line in body.take(body.length - 1)) {
expect(line.length, 64);
}
});
test('private PEM round-trips back into a usable key', () {
final pems = generatePushKeypairPems();
final restored = RSAPrivateKey.fromPEM(pems.privateKeyPem);
final restoredPublicPem = restored.publicKey.toFormattedPEM();
expect(restoredPublicPem, pems.publicKeyPem);
});
});
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_message_handler.dart';
void main() {
group('classifyPush', () {
test('nextcloud push has subject + signature', () {
expect(
classifyPush({'subject': 'enc', 'signature': 'sig', 'type': 'chat'}),
PushKind.nextcloud,
);
});
test('connect push identified by source', () {
expect(
classifyPush({'source': 'connect', 'title': 'Hi', 'body': 'There'}),
PushKind.connect,
);
});
test('subject without signature is not a nextcloud push', () {
expect(classifyPush({'subject': 'enc'}), PushKind.unknown);
});
test('empty subject/signature is unknown', () {
expect(classifyPush({'subject': '', 'signature': ''}), PushKind.unknown);
});
test('unrelated payload is unknown', () {
expect(classifyPush({'foo': 'bar'}), PushKind.unknown);
});
});
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_registration.dart';
void main() {
group('PushRegistration.isPermissionUsable', () {
test('explicit denial blocks registration', () {
expect(
PushRegistration.isPermissionUsable(AuthorizationStatus.denied),
isFalse,
);
});
test('all other statuses allow registration', () {
const usable = [
AuthorizationStatus.authorized,
AuthorizationStatus.provisional,
AuthorizationStatus.notDetermined,
];
for (final status in usable) {
expect(
PushRegistration.isPermissionUsable(status),
isTrue,
reason: '$status should be usable',
);
}
});
test('covers every AuthorizationStatus value', () {
// Guard: if firebase_messaging ever adds a status, revisit the gate.
expect(AuthorizationStatus.values, hasLength(4));
});
});
}