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],