diff --git a/src/Pusher.php b/src/Pusher.php index 72b6b8a..acb74e1 100755 --- a/src/Pusher.php +++ b/src/Pusher.php @@ -358,8 +358,10 @@ public function make_request($channels, string $event, $data, array $params = [] if ($has_encrypted_channel) { if (count($channels) > 1) { - // For rationale, see limitations of end-to-end encryption in the README - throw new PusherException('You cannot trigger to multiple channels when using encrypted channels'); + $data_encoded = $this->crypto->encrypt_payload_multi( + $channels, + $already_encoded ? $data : json_encode($data, JSON_THROW_ON_ERROR) + ); } else { try { $data_encoded = $this->crypto->encrypt_payload( diff --git a/src/PusherCrypto.php b/src/PusherCrypto.php index 8a55216..7488876 100644 --- a/src/PusherCrypto.php +++ b/src/PusherCrypto.php @@ -9,6 +9,8 @@ class PusherCrypto // The prefix any e2e channel must have public const ENCRYPTED_PREFIX = 'private-encrypted-'; + public const MULTI_PREFIX = 'private-encrypted-multi-'; + /** * Checks if a given channel is an encrypted channel. * @@ -21,6 +23,18 @@ public static function is_encrypted_channel(string $channel): bool return strpos($channel, self::ENCRYPTED_PREFIX) === 0; } + /** + * Checks if a given channel is an encrypted channel. + * + * @param string $channel the name of the channel + * + * @return bool true if channel is an encrypted channel + */ + public static function is_multi_encrypted_channel(string $channel): bool + { + return strpos($channel, self::MULTI_PREFIX) === 0; + } + /** * Checks if channels are a mix of encrypted and non-encrypted types. * @@ -47,7 +61,7 @@ public static function has_mixed_channels(array $channels): bool } } } - + return false; } @@ -98,8 +112,13 @@ public function __construct(string $encryption_master_key) */ public function decrypt_event(object $event): object { - $parsed_payload = $this->parse_encrypted_message($event->data); - $shared_secret = $this->generate_shared_secret($event->channel); + if (self::is_multi_encrypted_channel($event->data)) { + $parsed_payload = $this->parse_multi_encrypted_message($event->data); + $shared_secret = $this->multi_channel_secret($parsed_payload->channels, $parsed_payload->random); + } else { + $parsed_payload = $this->parse_encrypted_message($event->data); + $shared_secret = $this->generate_shared_secret($event->channel); + } $decrypted_payload = $this->decrypt_payload($parsed_payload->ciphertext, $parsed_payload->nonce, $shared_secret); if (!$decrypted_payload) { throw new PusherException('Decryption of the payload failed. Wrong key?'); @@ -109,6 +128,111 @@ public function decrypt_event(object $event): object return $event; } + /** + * Encode multiple channel names into a parseable header + * + * @param array $channels + * @param string $random + * @return string + * + * @throws \SodiumException + */ + public function multi_channel_encode(array $channels, string $random = ''): string + { + // Determine a stable order of channel names: + $sorted = array_values($channels); + sort($sorted); + + $list = []; + $pos = strlen(self::ENCRYPTED_PREFIX); + foreach ($sorted as $ch) { + if (!self::is_encrypted_channel($ch)) { + continue; + } + // Strip off encrypted prefix: + $list []= substr($ch, $pos); + } + $flat = json_encode(['c' => $list, 'r' => base64_decode($random)]); + return self::MULTI_PREFIX . sodium_bin2hex(pack('J', strlen($flat)) . $flat); + } + + /** + * Decode the header into a list of encrypted channel names + * + * @param string $header + * @return array + * @throws PusherException + * @throws \SodiumException + */ + public function multi_channel_decode(string $header): array + { + // multi-{hex}, validate "multi-" + $len = strlen(self::MULTI_PREFIX); + $multi = substr($header, 0, $len); + if (!hash_equals($multi, self::MULTI_PREFIX)) { + throw new PusherException('Not a multi-channel'); + } + // decode {hex} + $hex_decoded = sodium_hex2bin(substr($header, $len)); + if (strlen($hex_decoded) < 8) { + throw new PusherException('Multi-channel name must be at least 8 characters'); + } + + // |json|, json + $json_len = unpack('J', substr($hex_decoded, 0, 8))[1]; + $json = substr($hex_decoded, 8); + if (strlen($json) !== $json_len) { + throw new PusherException('Invalid channel length'); + } + // JSON-decode + $decoded = json_decode($json, JSON_THROW_ON_ERROR); + $random = base64_decode($decoded['r'] ?? ''); + // Re-assemble actual channel names: + $channels = []; + foreach ($decoded['c'] as $c) { + $channels [] = self::ENCRYPTED_PREFIX . $c; + } + return [$channels, $random]; + } + + /** + * Derive a secret for broadcasting to multiple channels + * + * Algorithm: HMAC-SHA256 + * + * @param string[] $channels + * @param string|null $random + * @return string + * @throws PusherException + */ + public function multi_channel_secret(array $channels, ?string $random = ''): string + { + // Determine a stable order of channel names: + $sorted = array_values($channels); + sort($sorted); + $secrets = []; + + // Get the secret for each channel: + foreach ($sorted as $channel) { + $secrets []= self::generate_shared_secret($channel); + } + $count = count($sorted); + $sha = hash_init('sha256', HASH_HMAC, $this->encryption_master_key); + // Begin with randomness: + hash_update($sha, pack('J', strlen($random))); + hash_update($sha, $random); + // Prepend the hash of the number of elements: + hash_update($sha, pack('J', $count)); + for ($i = 0; $i < $count; ++$i) { + // update hash with ... |channelname|, channelname, |secret|, secret + hash_update($sha, pack('J', strlen($sorted[$i]))); + hash_update($sha, $sorted[$i]); + hash_update($sha, pack('J', strlen($secrets[$i]))); + hash_update($sha, $secrets[$i]); + } + return hash_final($sha, true); + } + /** * Derives a shared secret from the secret key and the channel to broadcast to. * @@ -126,6 +250,27 @@ public function generate_shared_secret(string $channel): string return hash('sha256', $channel . $this->encryption_master_key, true); } + /** + * Encrypts a given plaintext for broadcast on a particular channel. + * + * @param string[] $channels the names of the channel the payloads event will be broadcast on + * @param string $plaintext the data to encrypt + * + * @return string a string ready to be sent as the data of an event. + * @throws PusherException + * @throws \SodiumException + * @throws \JsonException + */ + public function encrypt_payload_multi(array $channels, string $plaintext): string + { + $secret = $this->multi_channel_secret($channels); + $header = $this->multi_channel_encode($channels); + $nonce = $this->generate_nonce(); + $cipher_text = sodium_crypto_secretbox($plaintext, $nonce, $secret); + + return $header . ':' . $this->format_encrypted_message($nonce, $cipher_text); + } + /** * Encrypts a given plaintext for broadcast on a particular channel. * @@ -190,6 +335,29 @@ private function format_encrypted_message(string $nonce, string $ciphertext): st return json_encode($encrypted_message, JSON_THROW_ON_ERROR); } + /** + * @param string $payload + * @return object + * @throws PusherException + * @throws \SodiumException + */ + private function parse_multi_encrypted_message(string $payload): object + { + $split = strpos($payload, ':'); + $header = substr($payload, 0, $split); + $payload = substr($payload, $split + 1); + $decoded = $this->parse_encrypted_message($payload); + if (!is_object($decoded)) { + throw new PusherException( + 'Invalid encrypted message: expected an object, got ' . gettype($decoded) + ); + } + [$channels, $random] = $this->multi_channel_decode($header); + $decoded->channels = $channels; + $decoded->random = $random; + return $decoded; + } + /** * Parses an encrypted message into its nonce and ciphertext components. * diff --git a/tests/unit/CryptoTest.php b/tests/unit/CryptoTest.php index fda0d9c..19cddce 100644 --- a/tests/unit/CryptoTest.php +++ b/tests/unit/CryptoTest.php @@ -87,6 +87,21 @@ public function testGenerateSharedSecret(): void self::assertNotEquals($expected, base64_encode($crypto2->generate_shared_secret('private-encrypted-channel-a'))); } + public function testMultiChannelSecret(): void + { + $expected = 'kTv4FchvZyR8lIc0l0M4F9/3HSpIFlfdrzNQibk032U='; + $first = base64_encode($this->crypto->multi_channel_secret([ + 'private-encrypted-test', + 'private-encrypted-another' + ])); + $second = base64_encode($this->crypto->multi_channel_secret([ + 'private-encrypted-another', + 'private-encrypted-test' + ])); + self::assertEquals($expected, $first); + self::assertEquals($expected, $second); + } + public function testGenerateSharedSecretNoChannel(): void { $this->expectException(\Pusher\PusherException::class); @@ -110,6 +125,25 @@ public function testHasMixedChannels(): void self::assertEquals(false, PusherCrypto::has_mixed_channels(['test', 'another'])); } + public function testEncryptDecryptMultiEventValid(): void + { + $channels = [ + 'private-encrypted-bla', + 'private-encrypted-foo', + 'private-encrypted-bar' + ]; + $payload = "now that's what I call a payload!"; + $encrypted_payload = $this->crypto->encrypt_payload_multi($channels, $payload); + self::assertNotNull($encrypted_payload); + + // Create a mock Event object + $event = new stdClass(); + $event->data = $encrypted_payload; + $decrypted_event = $this->crypto->decrypt_event($event); + $decrypted_payload = $decrypted_event->data; + self::assertEquals($payload, $decrypted_payload); + } + public function testEncryptDecryptEventValid(): void { $channel = 'private-encrypted-bla';