From 53c4c0f96dd51a0cad317f569f7a4094c079c933 Mon Sep 17 00:00:00 2001 From: Paragon Initiative Enterprises Date: Tue, 23 Apr 2024 13:12:20 -0400 Subject: [PATCH] Support multiple encrypted channels for a single event To encrypt a message against multiple channels, two things are needed: 1. A method of encoding _which_ channels are included in the key derivation that's order-independent. 2. A method of deriving a shared secret for multiple channels. This implements both and integrates it into the pusher workflow. A message encrypted to multiple recipients encodes the channel metadata into an encrypted header. For example: ``` private-encrypted-multi-00000000000000207b2263223a5b22626172222c22626c61222c22666f6f225d2c2272223a22227d:{"nonce":"MyNLvxXvz3nJ\/lMv+oRtK991TXW\/00Kx","ciphertext":"+nkcgt+SUDY42NqKCsxB5DgfGz1uhbNgx\/DJkHeL4ffH0JAkbUGgSpd+AzXGx9C6uQ=="} ``` This hex string is encoded as strlen(data) + data. It encodes two pieces of information: 1. A list of suffixes for the encrypted channel names. 2. Optional additional randomness for key derivation. The suffixes can be used at runtime to re-assemble the channel names. Given a list of channel names, one can additionally derive a shared secret for the message that all channels should be able to decrypt. The algorithm it uses is HMAC-SHA256 with an input that consists of lengths of segments followed by the segments' raw data. This design was inspired by PAE from PASETO and TupleHash from the NIST standard. https://www.nist.gov/publications/sha-3-derived-functions-cshake-kmac-tuplehash-and-parallelhash Each segment in calculating the shared secret is that channel's shared secret. This commit does not update the documentation or guidance around limitations. A congruent change would need to land in every other Pusher implementation before it could be updated. --- src/Pusher.php | 6 +- src/PusherCrypto.php | 174 +++++++++++++++++++++++++++++++++++++- tests/unit/CryptoTest.php | 34 ++++++++ 3 files changed, 209 insertions(+), 5 deletions(-) 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';