Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multiple encrypted channels for a single event #386

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/Pusher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
174 changes: 171 additions & 3 deletions src/PusherCrypto.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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.
*
Expand All @@ -47,7 +61,7 @@ public static function has_mixed_channels(array $channels): bool
}
}
}

return false;
}

Expand Down Expand Up @@ -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?');
Expand All @@ -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;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to throw here instead. The intent for this method is to only allow encrypted channels.

}
// 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.
*
Expand All @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/CryptoTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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';
Expand Down