From a10e9de75d52a58044aef3131da8d18d0a301112 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Sun, 17 Nov 2024 18:36:34 +0100 Subject: [PATCH] feat(notifications_push_repository): Verify push notification signature Signed-off-by: provokateurin --- .../lib/src/models/push_notification.dart | 20 +++++- .../lib/src/utils/encryption.dart | 18 ++++- .../test/model/push_notification_test.dart | 70 +++++++++++++++---- .../test/utils/encryption_test.dart | 56 ++++++++++++--- 4 files changed, 136 insertions(+), 28 deletions(-) diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_notification.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_notification.dart index e8d155eff65..f1404cae090 100644 --- a/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_notification.dart +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/models/push_notification.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:built_value/built_value.dart'; import 'package:built_value/serializer.dart'; import 'package:built_value/standard_json_plugin.dart'; @@ -19,16 +21,28 @@ abstract class PushNotification implements Built json, String accountID, - RSAPrivateKey privateKey, + RSAPrivateKey devicePrivateKey, + RSAPublicKey userPublicKey, ) { - final subject = notifications.DecryptedSubject.fromEncrypted(privateKey, json['subject'] as String); + final subject = json['subject'] as String; + final signature = json['signature'] as String; + + final valid = userPublicKey.verifySHA512Signature( + base64.decode(subject), + base64.decode(signature), + ); + if (!valid) { + throw Exception('Failed to verify push notification signature!'); + } + + final decryptedSubject = notifications.DecryptedSubject.fromEncrypted(devicePrivateKey, subject); return PushNotification( (b) => b ..accountID = accountID ..priority = json['priority'] as String ..type = json['type'] as String - ..subject.replace(subject), + ..subject.replace(decryptedSubject), ); } diff --git a/packages/neon_framework/packages/notifications_push_repository/lib/src/utils/encryption.dart b/packages/neon_framework/packages/notifications_push_repository/lib/src/utils/encryption.dart index 2167368c5dc..d7f6305d9c5 100644 --- a/packages/neon_framework/packages/notifications_push_repository/lib/src/utils/encryption.dart +++ b/packages/neon_framework/packages/notifications_push_repository/lib/src/utils/encryption.dart @@ -27,7 +27,20 @@ Future> parseEncryptedPushNotifications( Uint8List notifications, String accountID, ) async { - final privateKey = await getDevicePrivateKey(storage); + final subscriptions = await storage.readSubscriptions(); + + final subscription = subscriptions[accountID]; + if (subscription == null) { + throw Exception('Subscription for account $accountID not found.'); + } + + final pushDevice = subscription.pushDevice; + if (pushDevice == null) { + throw Exception('Push device for account $accountID not found.'); + } + + final userPublicKey = RSAPublicKey.fromPEM(pushDevice.publicKey); + final devicePrivateKey = await getDevicePrivateKey(storage); final builder = ListBuilder(); @@ -37,7 +50,8 @@ Future> parseEncryptedPushNotifications( PushNotification.fromEncrypted( data, accountID, - privateKey, + devicePrivateKey, + userPublicKey, ), ); } diff --git a/packages/neon_framework/packages/notifications_push_repository/test/model/push_notification_test.dart b/packages/neon_framework/packages/notifications_push_repository/test/model/push_notification_test.dart index 42e82869740..a3bd7443600 100644 --- a/packages/neon_framework/packages/notifications_push_repository/test/model/push_notification_test.dart +++ b/packages/neon_framework/packages/notifications_push_repository/test/model/push_notification_test.dart @@ -124,8 +124,8 @@ PushNotification { ); }); - test('fromEncrypted', () { - const privateKeyPEM = ''' + group('fromEncrypted', () { + const devicePrivateKeyPEM = ''' -----BEGIN RSA PRIVATE KEY----- MIICHwIBADANBgkqhkiG9w0BAQEFAASCAgkwggIFAgEAAm4BELTz808T8iAkvBkg tnWs4a1aNcCFAAX54ePLK40YAL/tQjUGoIe0+zO7yzMT0bydk6BFOdyrIP2iwALN @@ -140,21 +140,65 @@ VQI3BH+FwRTRntc3caGF4qVixb+Wu6OLwHg77MjdvKEo8KqTiQjxgAjmUkXPaS8N 4FkEfiY9QA36EQI3AKxizo9goAHnTmY1OVi+4GLp0HroWP64RjW8R/cUemggMqEa UJYvEQEss8/UoYhOACOm5PEqNg== -----END RSA PRIVATE KEY-----'''; - final privateKey = RSAPrivateKey.fromPEM(privateKeyPEM); + final devicePrivateKey = RSAPrivateKey.fromPEM(devicePrivateKeyPEM); + + const userPrivateKeyPEM = ''' +-----BEGIN RSA PRIVATE KEY----- +MIIB1wIBADANBgkqhkiG9w0BAQEFAASCAcEwggG9AgEAAl4BimSPD4wH/LwlJk3H +dj6FCPqwZDBgLQQGVZsC6iZuCRMOH9paXuCOSBMw6l9IDrHy23jEasfu6tOnA/vy +QP01oPa0Kkp7qUFdN/eVHOdfBp0KKEPhr6bGGr1Lh+BJAgMBAAECXgFFGmWPVEgV +PuaEr6LXRuwVHckfnXz6PpIWKQR7DZiw5ENFYJIUGZlPsnolCMv2JzvKc66MYlnK +G+I+Lpm9mc2bPbj3aq8vh25mjiyn2mgB9AdlGoDNcW4QN8pisQ0CLxnR3uq23oPB +1hpR/eU+vU5iQ1/ZwKZUf24CihDFGpS9je1X43TYCRDMH3sMFttXAi8PRlpU/AdO +xpqZYcoilhhu/numcU1qEBgiDiTuTHizVGw/OwmDeq3SHjJLhMV9XwIvAVwonbxc +JByFpoVDFlwjpIlQezABEcHJpIXFt/Rp3gPOAf5rILBwac4WqmiMm6kCLwSYBgbV +HWWFuW0zydUJCyQmiQ2PudaSLI/hbR32Bb75PuztVnkiZjBxQHMR5UtFAi8TXKJV +4gkQHdyTMlUgtwTItoS5AWmZU5FUJbDva9S3JerKrTkbeslJiHEhSYrbZw== +-----END RSA PRIVATE KEY----- +'''; + final userPrivateKey = RSAPrivateKey.fromPEM(userPrivateKeyPEM); const subject = 'AOXrekPv+79XU82vEXx5WiA9WREus8uYYkfijtKdl4ggWRvvykaY5hQP7OT5P7iKSCzjmO7yNQTuXDJXYtWo/1Pq0AYSVrA3y37pNYr8d/WZklfvQtxIB6o/HTG6pUd1kER7QxVkP7RSHvw/9PU='; - expect( - PushNotification.fromEncrypted( - json.decode( - '{"priority":"priority","type":"type","subject":"$subject"}', - ) as Map, - 'accountID', - privateKey, - ), - equalsBuilt(createPushNotification()), - ); + test('Valid signature', () { + const signature = + 'AFhc8Bp2PFwbrsqr9Ygk9T4JRwaqnsojvJ0MnkMIKpX8TYe0/SWj1bVQhWamMQ1uQ3xeFIOFHP3AkoqJ+id7f9CpqETOSqTrUHDFDedCbb8Lwoa95q4lnchDvI6Hbw=='; + + expect( + PushNotification.fromEncrypted( + json.decode( + '{"priority":"priority","type":"type","subject":"$subject","signature":"$signature"}', + ) as Map, + 'accountID', + devicePrivateKey, + userPrivateKey.publicKey, + ), + equalsBuilt(createPushNotification()), + ); + }); + + test('Invalid signature', () { + const signature = 'abcd'; + + expect( + () => PushNotification.fromEncrypted( + json.decode( + '{"priority":"priority","type":"type","subject":"$subject","signature":"$signature"}', + ) as Map, + 'accountID', + devicePrivateKey, + userPrivateKey.publicKey, + ), + throwsA( + isA().having( + (e) => e.toString(), + 'toString', + 'Exception: Failed to verify push notification signature!', + ), + ), + ); + }); }); }); } diff --git a/packages/neon_framework/packages/notifications_push_repository/test/utils/encryption_test.dart b/packages/neon_framework/packages/notifications_push_repository/test/utils/encryption_test.dart index 5e6b2603cba..5310f8b563b 100644 --- a/packages/neon_framework/packages/notifications_push_repository/test/utils/encryption_test.dart +++ b/packages/neon_framework/packages/notifications_push_repository/test/utils/encryption_test.dart @@ -1,17 +1,19 @@ import 'dart:convert'; +import 'package:built_collection/built_collection.dart'; import 'package:built_value_test/matcher.dart'; import 'package:crypton/crypton.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:notifications_push_repository/notifications_push_repository.dart'; +import 'package:notifications_push_repository/src/models/models.dart'; import 'package:notifications_push_repository/src/utils/encryption.dart'; import 'package:notifications_push_repository/testing.dart'; class _StorageMock extends Mock implements NotificationsPushStorage {} void main() { - const privateKeyPEM = ''' + const devicePrivateKeyPEM = ''' -----BEGIN RSA PRIVATE KEY----- MIICHwIBADANBgkqhkiG9w0BAQEFAASCAgkwggIFAgEAAm4BELTz808T8iAkvBkg tnWs4a1aNcCFAAX54ePLK40YAL/tQjUGoIe0+zO7yzMT0bydk6BFOdyrIP2iwALN @@ -41,34 +43,68 @@ UJYvEQEss8/UoYhOACOm5PEqNg== when(() => storage.readDevicePrivateKey()).thenAnswer((_) => null); when(() => storage.saveDevicePrivateKey(any())).thenAnswer((_) async {}); - final privateKey = await getDevicePrivateKey(storage); - expect(privateKey.toFormattedPEM(), isNot(privateKeyPEM)); + final devicePrivateKey = await getDevicePrivateKey(storage); + expect(devicePrivateKey.toFormattedPEM(), isNot(devicePrivateKeyPEM)); - verify(() => storage.saveDevicePrivateKey(privateKey)).called(1); + verify(() => storage.saveDevicePrivateKey(devicePrivateKey)).called(1); }); test('Existing', () async { - when(() => storage.readDevicePrivateKey()).thenAnswer((_) => RSAPrivateKey.fromPEM(privateKeyPEM)); + when(() => storage.readDevicePrivateKey()).thenAnswer((_) => RSAPrivateKey.fromPEM(devicePrivateKeyPEM)); - final privateKey = await getDevicePrivateKey(storage); - expect(privateKey.toFormattedPEM(), equals(privateKeyPEM)); + final devicePrivateKey = await getDevicePrivateKey(storage); + expect(devicePrivateKey.toFormattedPEM(), equals(devicePrivateKeyPEM)); verifyNever(() => storage.saveDevicePrivateKey(any())); }); }); test('parseEncryptedPushNotifications', () async { - when(() => storage.readDevicePrivateKey()).thenAnswer((_) => RSAPrivateKey.fromPEM(privateKeyPEM)); + const userPrivateKeyPEM = ''' +-----BEGIN RSA PRIVATE KEY----- +MIIB1wIBADANBgkqhkiG9w0BAQEFAASCAcEwggG9AgEAAl4BuCI4NDeaY4S8d2zj +U7znZjsA9WYlmn9HsYx8ITp1hZi83MWfQ3ckDhDlDe8eWi6C1OxXobFzESATcGkg +8IlnEar5PMqFB0DFa0CS4ruPDHJAb57G+WW2hIvEWKc/AgMBAAECXgGYrUWMzthv +cc/iAFxw46XlmgIA2xEtjOPQK8cSv4piO3macXG5nkX/PZbCQnbnUeB4NXBgRxw0 +mawTnRW5lIANlDkIGbyXYJ86JHS8Q1PGRtVHb1xDwIkbDHtSS1ECLx8kWzsn2oYA +gwe3yMMpJtxCJTwV7giist9gixv+GFhPH+ZOWu9RsPPqY5VZK7oXAi8OIhO+VbZi +8lB7ZJ9pHUFoJRZZYUBQ8aARwXLP6htbWm/zFknBpUl3tntlG6Q9GQIvHf5WH9Ny +lD1J9dT8d3rbAqhyVDyK1aZdwOarFHrV17fdcWNmEbkMROAqqV0I0GECLwQQQjL4 +e+8pEoDX5omXcsXZ2/oo3xAm2MoiH7ut6N20O/ndj6lQt7XmzsW8U9WJAi8Tutz8 +e/rwA4zvN4TD8v1bsHu3g6/6k60RTPp5xy0AvYPjYV+ur2H2W/eIDMkXQA== +-----END RSA PRIVATE KEY----- +'''; + final userPrivateKey = RSAPrivateKey.fromPEM(userPrivateKeyPEM); + + when(() => storage.readDevicePrivateKey()).thenAnswer((_) => RSAPrivateKey.fromPEM(devicePrivateKeyPEM)); + when(() => storage.readSubscriptions()).thenAnswer( + (_) async => BuiltMap({ + 'accountID': PushSubscription( + (b) => b.pushDevice.update( + (b) => b + ..publicKey = userPrivateKey.publicKey.toPEM() + ..deviceIdentifier = '' + ..signature = '', + ), + ), + }), + ); const subject1 = 'AOXrekPv+79XU82vEXx5WiA9WREus8uYYkfijtKdl4ggWRvvykaY5hQP7OT5P7iKSCzjmO7yNQTuXDJXYtWo/1Pq0AYSVrA3y37pNYr8d/WZklfvQtxIB6o/HTG6pUd1kER7QxVkP7RSHvw/9PU='; + const signature1 = + 'AH1A1S9v0uxwcukZXWMX2+3PwTx6ngtGinVX2DHeeQ8j5N6UjN2gl5c9XwhmqO934zSMgQi5lSDlh2NyZb3B44f5IEbehghokXoTd7Bc46auocR1ZIuxDfJGey03qw=='; + const subject2 = 'AGcV+V73rhvcT2OMu5AAQNd01zd4BWCJqgZc782MOXlj62yKv4AxfbXLZpKjH2tFn8WiZRg6DJmX25v3652mzaJefC4d/urfbIGYN1a30NNSpPJIxjZ1XWUe2MV+aKuaj+liKYukVvzOpK+scCM='; + const signature2 = + 'AI6nF0DSVgYAGe2UhdgsC9UhGWdvRlTQjBS/2Gj0rMxzYH3GAq5gy/oodJjfjP9iJnhBTltrl8j0Daowcp6DRP8WgKRXzSj3ECAeepHE4whLSGP5gR+nBPe3eCHN8w=='; + final messages = utf8.encode( Uri( queryParameters: { - 'message1': '{"priority":"priority","type":"type","subject":"$subject1"}', - 'message2': '{"priority":"priority","type":"type","subject":"$subject2"}', + 'message1': '{"priority":"priority","type":"type","subject":"$subject1","signature":"$signature1"}', + 'message2': '{"priority":"priority","type":"type","subject":"$subject2","signature":"$signature2"}', }, ).query, );