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:
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user