diff --git a/lib/Exceptions/PushSigningException.php b/lib/Exceptions/PushSigningException.php
new file mode 100644
index 000000000..a002a9459
--- /dev/null
+++ b/lib/Exceptions/PushSigningException.php
@@ -0,0 +1,13 @@
+payloadsToSend[$proxyServer])) {
+ $this->payloadsToSend[$proxyServer] = [];
+ }
+
+ // Encrypt and sign payload
try {
$payload = json_encode($this->encryptAndSign($userKey, $device, $id, $notification, $isTalkNotification), JSON_THROW_ON_ERROR);
-
- $proxyServer = rtrim($device['proxyserver'], '/');
- if (!isset($this->payloadsToSend[$proxyServer])) {
- $this->payloadsToSend[$proxyServer] = [];
- }
- $this->payloadsToSend[$proxyServer][] = $payload;
} catch (\JsonException $e) {
$this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]);
+ // TODO: throw? continue?
} catch (\InvalidArgumentException) {
// Failed to encrypt message for device: public key is invalid
$this->deletePushToken($device['token']);
+ } catch (PushSigningException $e) {
+ // Server-side key problem: log already done; skip this device.
+ continue;
}
+
+ // All good!
+ $this->payloadsToSend[$proxyServer][] = $payload;
}
+
$this->printInfo('');
if (!$this->deferPayloads) {
@@ -316,6 +325,7 @@ public function pushToDevice(int $id, INotification $notification, ?OutputInterf
*/
public function pushDeleteToDevice(string $userId, ?array $notificationIds, string $app = ''): void {
if (!$this->config->getSystemValueBool('has_internet_connection', true)) {
+ $this->printInfo('Internet connectivity is disabled in configuration file - no push delete notifications will be sent');
return;
}
@@ -364,6 +374,7 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri
$devices = $this->filterDeviceList($devices, $app);
}
if (empty($devices)) {
+ $this->printInfo('No devices found / left for user');
return;
}
@@ -371,42 +382,54 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri
$maxAge = time() - 60 * 24 * 60 * 60;
$userKey = $this->keyManager->getKey($user);
+
+ $this->printInfo('Private user key size: ' . strlen($userKey->getPrivate()));
+ $this->printInfo('Public user key size: ' . strlen($userKey->getPublic()));
+
+ $this->printInfo('');
+ $this->printInfo('Found ' . count($devices) . ' devices registered for push delete notifications');
+
foreach ($devices as $device) {
$device['token'] = (int)$device['token'];
+ $this->printInfo('');
+ $this->printInfo('Device token: ' . $device['token']);
+
if (!$this->validateToken($device['token'], $maxAge)) {
// Token does not exist anymore
continue;
}
+
+ $proxyServer = rtrim($device['proxyserver'], '/');
+ if (!isset($this->payloadsToSend[$proxyServer])) {
+ $this->payloadsToSend[$proxyServer] = [];
+ }
try {
- $proxyServer = rtrim($device['proxyserver'], '/');
- if (!isset($this->payloadsToSend[$proxyServer])) {
- $this->payloadsToSend[$proxyServer] = [];
- }
-
if ($deleteAll) {
$data = $this->encryptAndSignDelete($userKey, $device, null);
- try {
- $this->payloadsToSend[$proxyServer][] = json_encode($data['payload'], JSON_THROW_ON_ERROR);
- } catch (\JsonException $e) {
- $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]);
- }
+ $payload = json_encode($data['payload'], JSON_THROW_ON_ERROR);
+ // All good!
+ $this->payloadsToSend[$proxyServer][] = $payload;
} else {
- $temp = $notificationIds;
-
- while (!empty($temp)) {
- $data = $this->encryptAndSignDelete($userKey, $device, $temp);
- $temp = $data['remaining'];
- try {
- $this->payloadsToSend[$proxyServer][] = json_encode($data['payload'], JSON_THROW_ON_ERROR);
- } catch (\JsonException $e) {
- $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]);
- }
+ $queue = $notificationIds;
+ while (!empty($queue)) {
+ $data = $this->encryptAndSignDelete($userKey, $device, $queue);
+ $payload = json_encode($data['payload'], JSON_THROW_ON_ERROR);
+ // All good!
+ $this->payloadsToSend[$proxyServer][] = $payload;
+ $queue = $data['remaining'];
}
}
+ } catch (\JsonException $e) {
+ $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]);
+ // TODO: throw? continue?
} catch (\InvalidArgumentException) {
// Failed to encrypt message for device: public key is invalid
$this->deletePushToken($device['token']);
+ } catch (PushSigningException $e) {
+ // Server-side key problem: log already done; skip this device.
+ // TODO: Confirm remaining/queue is handled properly here (not a factor in pushToDevice())
+ continue;
}
}
@@ -603,22 +626,47 @@ protected function callSafelyForToken(IToken $token, string $method): ?int {
* @psalm-return array{deviceIdentifier: string, pushTokenHash: string, subject: string, signature: string, priority: string, type: string}
* @throws InvalidTokenException
* @throws \InvalidArgumentException
+ * @throws \JsonException
+ * @throws PushSigningException
*/
protected function encryptAndSign(Key $userKey, array $device, int $id, INotification $notification, bool $isTalkNotification): array {
+
+ if (empty($device['devicepublickey']) || empty($userKey->getPrivate())) {
+ throw new \InvalidArgumentException('Missing device public key or user private key');
+ }
+
$data = [
'nid' => $id,
'app' => $notification->getApp(),
- 'subject' => '',
+ 'subject' => '', // will get populated
'type' => $notification->getObjectType(),
'id' => $notification->getObjectId(),
];
- // Max length of encryption is ~240, so we need to make sure the subject is shorter.
- // Also, subtract two for encapsulating quotes will be added.
- $maxDataLength = 200 - strlen(json_encode($data)) - 2;
- $data['subject'] = Util::shortenMultibyteString($notification->getParsedSubject(), $maxDataLength);
- if ($notification->getParsedSubject() !== $data['subject']) {
- $data['subject'] .= '…';
+ // Calculate maximum plaintext data length
+ // Assume a gross maximum data length of 245 - i.e. the minimum potential RSA public key modulus length of 256 bytes (2048 bits) minus 11 bytes for PKCS#1 v1.5 padding
+ $maxPlain = 256 - 11 - 2; // adjust if modulus length and/or padding changes
+
+ // Calculate encoded base data length and maximum subject length
+ $encodedBaseData = json_encode($data, JSON_THROW_ON_ERROR);
+ $encodedBaseDataBytes = strlen($encodedBaseData); // length of encoded data before populating subject
+ $maxSubjectBytes = max(0, $maxPlain - $encodedBaseDataBytes - 2); // subtract two for quotes
+
+ // Use proposed subject as-is if it's length is compliant otherwise truncate
+ $subject = $notification->getParsedSubject();
+ $originalBytes = strlen($subject);
+ if ($originalBytes <= $maxSubjectBytes) {
+ $data['subject'] = $subject;
+ } else {
+ $availableForText = max(0, $maxSubjectBytes - 3); // subtract three for ellipsis
+ $data['subject'] = Util::shortenMultibyteString($subject, $availableForText) . '…';
+ $this->log->debug('Truncated push notification subject to fit RSA size limit', [
+ 'app' => 'notifications',
+ 'user' => $notification->getUser(),
+ 'deviceIdentifier' => $device['deviceidentifier'] ?? null,
+ 'original_bytes' => $originalBytes,
+ 'truncated_bytes' => strlen($data['subject']),
+ ]);
}
if ($isTalkNotification) {
@@ -632,29 +680,44 @@ protected function encryptAndSign(Key $userKey, array $device, int $id, INotific
$type = 'alert';
}
+ $encodedData = json_encode($data, JSON_THROW_ON_ERROR);
+
$this->printInfo('Device public key size: ' . strlen($device['devicepublickey']));
- $this->printInfo('Data to encrypt is: ' . json_encode($data));
+ $this->printInfo('Data to encrypt is: ' . $encodedData);
- if (!openssl_public_encrypt(json_encode($data), $encryptedSubject, $device['devicepublickey'], OPENSSL_PKCS1_PADDING)) {
+ // Encrypt
+ if (openssl_public_encrypt($encodedData, $encryptedSubject, $device['devicepublickey'], OPENSSL_PKCS1_PADDING)) {
+ $this->printInfo('Encrypted push subject successfully');
+ } else {
$error = openssl_error_string();
- $this->log->error($error, ['app' => 'notifications']);
- $this->printInfo('Error while encrypting data: "' . $error . '"');
- throw new \InvalidArgumentException('Failed to encrypt message for device');
+ $this->log->error('Error while encrypting push message: ' . $error, [
+ 'app' => 'notifications',
+ 'deviceIdentifier' => $device['deviceidentifier'] ?? null,
+ 'uid' => $device['uid'] ?? null,
+ ]);
+ $this->printInfo('Error while encrypting push message: "' . $error . '"');
+ throw new \InvalidArgumentException('Failed to encrypt push message for device');
}
+ // Sign
if (openssl_sign($encryptedSubject, $signature, $userKey->getPrivate(), OPENSSL_ALGO_SHA512)) {
$this->printInfo('Signed encrypted push subject');
} else {
- $this->printInfo('Failed to signed encrypted push subject');
+ $error = openssl_error_string();
+ $this->log->error('Error while signing push message subject: ' . $error, [
+ 'app' => 'notifications',
+ 'deviceIdentifier' => $device['deviceidentifier'] ?? null,
+ 'uid' => $device['uid'] ?? null,
+ ]);
+ $this->printInfo('Error while signing encrypted push subject');
+ throw new PushSigningException('Failed to sign push message for device');
}
- $base64EncryptedSubject = base64_encode($encryptedSubject);
- $base64Signature = base64_encode($signature);
return [
'deviceIdentifier' => $device['deviceidentifier'],
'pushTokenHash' => $device['pushtokenhash'],
- 'subject' => $base64EncryptedSubject,
- 'signature' => $base64Signature,
+ 'subject' => base64_encode($encryptedSubject),
+ 'signature' => base64_encode($signature),
'priority' => $priority,
'type' => $type,
];
@@ -668,8 +731,15 @@ protected function encryptAndSign(Key $userKey, array $device, int $id, INotific
* @psalm-return array{remaining: list, payload: array{deviceIdentifier: string, pushTokenHash: string, subject: string, signature: string, priority: string, type: string}}
* @throws InvalidTokenException
* @throws \InvalidArgumentException
+ * @throws \JsonException
+ * @throws PushSigningException
*/
protected function encryptAndSignDelete(Key $userKey, array $device, ?array $ids): array {
+
+ if (empty($device['devicepublickey']) || empty($userKey->getPrivate())) {
+ throw new \InvalidArgumentException('Missing device public key or user private key');
+ }
+
$remainingIds = [];
if ($ids === null) {
$data = [
@@ -688,22 +758,46 @@ protected function encryptAndSignDelete(Key $userKey, array $device, ?array $ids
];
}
- if (!openssl_public_encrypt(json_encode($data), $encryptedSubject, $device['devicepublickey'], OPENSSL_PKCS1_PADDING)) {
- $this->log->error(openssl_error_string(), ['app' => 'notifications']);
- throw new \InvalidArgumentException('Failed to encrypt message for device');
+ $encodedData = json_encode($data, JSON_THROW_ON_ERROR);
+
+ $this->printInfo('Device public key size: ' . strlen($device['devicepublickey']));
+ $this->printInfo('Data to encrypt is: ' . $encodedData);
+
+ // Encrypt
+ if (openssl_public_encrypt($encodedData, $encryptedSubject, $device['devicepublickey'], OPENSSL_PKCS1_PADDING)) {
+ $this->printInfo('Encrypted push delete subject successfully');
+ } else {
+ $error = openssl_error_string();
+ $this->log->error('Error while encrypting push delete message: ' . $error, [
+ 'app' => 'notifications',
+ 'deviceIdentifier' => $device['deviceidentifier'] ?? null,
+ 'uid' => $device['uid'] ?? null,
+ ]);
+ $this->printInfo('Error while encrypting push delete message: "' . $error . '"');
+ throw new \InvalidArgumentException('Failed to encrypt push delete message for device');
}
- openssl_sign($encryptedSubject, $signature, $userKey->getPrivate(), OPENSSL_ALGO_SHA512);
- $base64EncryptedSubject = base64_encode($encryptedSubject);
- $base64Signature = base64_encode($signature);
+ // Sign
+ if (openssl_sign($encryptedSubject, $signature, $userKey->getPrivate(), OPENSSL_ALGO_SHA512)) {
+ $this->printInfo('Signed encrypted push delete subject successfully');
+ } else {
+ $error = openssl_error_string();
+ $this->log->error('Error while signing encrypted push delete message subject: ' . $error, [
+ 'app' => 'notifications',
+ 'deviceIdentifier' => $device['deviceidentifier'] ?? null,
+ 'uid' => $device['uid'] ?? null,
+ ]);
+ $this->printInfo('Error while signing encrypted push delete message');
+ throw new PushSigningException('Failed to sign push delete message for device');
+ }
return [
'remaining' => $remainingIds,
'payload' => [
'deviceIdentifier' => $device['deviceidentifier'],
'pushTokenHash' => $device['pushtokenhash'],
- 'subject' => $base64EncryptedSubject,
- 'signature' => $base64Signature,
+ 'subject' => base64_encode($encryptedSubject),
+ 'signature' => base64_encode($signature),
'priority' => 'normal',
'type' => 'background',
]
diff --git a/tests/Unit/PushTest.php b/tests/Unit/PushTest.php
index 1f7758b0f..42fb54fd4 100644
--- a/tests/Unit/PushTest.php
+++ b/tests/Unit/PushTest.php
@@ -786,6 +786,172 @@ public function testPushToDeviceTalkNotification(array $deviceTypes, bool $isTal
$push->pushToDevice(200718, $notification);
}
+ public function testPushToDeviceSigningError(): void {
+ $push = $this->getPush(['createFakeUserObject', 'getDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken']);
+ $this->clientService->expects($this->never())
+ ->method('newClient');
+
+ $this->config->expects($this->once())
+ ->method('getSystemValueBool')
+ ->with('has_internet_connection', true)
+ ->willReturn(true);
+
+ /** @var INotification&MockObject $notification */
+ $notification = $this->createMock(INotification::class);
+ $notification
+ ->method('getUser')
+ ->willReturn('valid');
+ $user = $this->createMock(IUser::class);
+
+ $push->expects($this->once())
+ ->method('createFakeUserObject')
+ ->with('valid')
+ ->willReturn($user);
+
+ $push->expects($this->once())
+ ->method('getDevicesForUser')
+ ->willReturn([[
+ 'proxyserver' => 'proxyserver1',
+ 'token' => 23,
+ 'apptype' => 'other',
+ ]]);
+
+ $this->l10nFactory
+ ->method('getUserLanguage')
+ ->with($user)
+ ->willReturn('ru');
+
+ $this->notificationManager->expects($this->once())
+ ->method('prepare')
+ ->with($notification, 'ru')
+ ->willReturnArgument(0);
+
+ $key = $this->createMock(Key::class);
+ $this->keyManager->expects($this->once())
+ ->method('getKey')
+ ->with($user)
+ ->willReturn($key);
+
+ $push->expects($this->once())
+ ->method('validateToken')
+ ->willReturn(true);
+
+ $push->expects($this->once())
+ ->method('encryptAndSign')
+ ->willThrowException(new \OCA\Notifications\Exceptions\PushSigningException());
+
+ $push->expects($this->never())
+ ->method('deletePushToken');
+
+ $push->pushToDevice(1971, $notification);
+ }
+
+ public function testPushDeleteToDeviceSigningError(): void {
+ $push = $this->getPush(['createFakeUserObject', 'getDevicesForUser', 'encryptAndSignDelete', 'deletePushToken', 'validateToken']);
+ $this->clientService->expects($this->never())
+ ->method('newClient');
+
+ $this->config->expects($this->once())
+ ->method('getSystemValueBool')
+ ->with('has_internet_connection', true)
+ ->willReturn(true);
+
+ $userId = 'valid';
+ $user = $this->createMock(IUser::class);
+ $push->expects($this->once())
+ ->method('createFakeUserObject')
+ ->with($userId)
+ ->willReturn($user);
+
+ $push->expects($this->once())
+ ->method('getDevicesForUser')
+ ->willReturn([[
+ 'proxyserver' => 'proxyserver1',
+ 'token' => 23,
+ 'apptype' => 'other',
+ ]]);
+
+ $push->expects($this->once())
+ ->method('validateToken')
+ ->willReturn(true);
+
+ $key = $this->createMock(Key::class);
+ $this->keyManager->expects($this->once())
+ ->method('getKey')
+ ->with($user)
+ ->willReturn($key);
+
+ $push->expects($this->once())
+ ->method('encryptAndSignDelete')
+ ->willThrowException(new \OCA\Notifications\Exceptions\PushSigningException());
+
+ $push->expects($this->never())
+ ->method('deletePushToken');
+
+ $push->pushDeleteToDevice($userId, [42], 'other');
+ }
+
+ public function testPushDeleteToDeviceSigningErrorHaltsQueue(): void {
+ $push = $this->getPush([
+ 'createFakeUserObject',
+ 'getDevicesForUser',
+ 'encryptAndSignDelete',
+ 'deletePushToken',
+ 'validateToken'
+ ]);
+ $this->clientService->expects($this->never())
+ ->method('newClient');
+
+ $this->config->expects($this->once())
+ ->method('getSystemValueBool')
+ ->with('has_internet_connection', true)
+ ->willReturn(true);
+
+ $userId = 'valid';
+ $user = $this->createMock(\OCP\IUser::class);
+
+ $push->expects($this->once())
+ ->method('createFakeUserObject')
+ ->with($userId)
+ ->willReturn($user);
+
+ $push->expects($this->once())
+ ->method('getDevicesForUser')
+ ->willReturn([[
+ 'proxyserver' => 'proxyserver1',
+ 'token' => 23,
+ 'apptype' => 'other',
+ ]]);
+
+ $push->expects($this->once())
+ ->method('validateToken')
+ ->willReturn(true);
+
+ $key = $this->createMock(\OC\Security\IdentityProof\Key::class);
+ $this->keyManager->expects($this->once())
+ ->method('getKey')
+ ->with($user)
+ ->willReturn($key);
+
+ // Simulate queue of 3 notification IDs, but always throw signing error
+ $push->expects($this->once())
+ ->method('encryptAndSignDelete')
+ ->with($key, [
+ 'proxyserver' => 'proxyserver1',
+ 'token' => 23,
+ 'apptype' => 'other',
+ ], [101, 102, 103])
+ ->willThrowException(new \OCA\Notifications\Exceptions\PushSigningException());
+
+ // Should NOT delete the token, nor call encryptAndSignDelete a second time
+ $push->expects($this->never())
+ ->method('deletePushToken');
+
+ // Call function: expects no exception, but also expects no further queue processing
+ $push->pushDeleteToDevice($userId, [101, 102, 103], 'other');
+ // Consider adding assertions about $push->payloadsToSend
+ }
+
public static function dataValidateToken(): array {
return [
[1239999999, 1230000000, OCPIToken::WIPE_TOKEN, false],