react-websocket/src/WebSocketProtocol.php

194 lines
6.1 KiB
PHP

<?php
namespace NoccyLabs\React\WebSocket;
use Evenement\EventEmitterTrait;
use NoccyLabs\React\WebSocket\Group\ConnectionGroup;
use NoccyLabs\React\WebSocket\Group\GroupManager;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
use React\Socket\ConnectionInterface;
use React\Stream\CompositeStream;
use React\Stream\DuplexStreamInterface;
use React\Stream\ReadableStreamInterface;
use React\Stream\ThroughStream;
use React\Stream\WritableStreamInterface;
class WebSocketProtocol
{
/**
* Encode a frame. Required keys are opcode and payload. Keys that can be passed are:
*
* opcode (int) - the opcode
* final (bool) - final frame
* rsv1-3 (bool) - reserved bits
* masked (bool) - if the frame was masked
* mask (string) - the mask bytes, if masked
* length (int) - length of payload
* payload (string) - the payload
*
* @param array $frame The frame
* @return string The encoded frame
*/
public function encode(array $frame): string
{
// Encoded frame
$encoded = null;
// Re-unpack frame with defaults
$frame = [
...[
'final' => true,
'opcode' => null,
'masked' => false,
'mask' => null,
'rsv1' => false,
'rsv2' => false,
'rsv3' => false,
'payload' => null
],
...$frame
];
$len = strlen($frame['payload']);
if ($len > 65535) {
$size0 = 127;
$size1 = ($len >> 24) & 0xFF;
$size2 = ($len >> 16) & 0xFF;
$size3 = ($len >> 8) & 0xFF;
$size4 = $len & 0xFF;
} elseif ($len > 126) {
$size0 = 126;
$size1 = ($len >> 8) & 0xFF;
$size2 = $len & 0xFF;
$size3 = null;
$size4 = null;
} else {
$size0 = $len;
$size1 = null;
$size2 = null;
$size3 = null;
$size4 = null;
}
$encoded .= chr(($frame['final']?0x80:0x00)
| ($frame['rsv1']?0x40:0x00)
| ($frame['rsv2']?0x20:0x00)
| ($frame['rsv3']?0x10:0x00)
| ($frame['opcode'] & 0xF));
$encoded .= chr(($frame['masked']?0x80:0x00)
| ($size0 & 0x7F));
if ($size1 !== null) {
$encoded .= chr($size1) . chr($size2);
}
if ($size3 !== null) {
$encoded .= chr($size3) . chr($size4);
}
if ($frame['masked'] === true) {
if ($frame['mask'] === null || strlen($frame['mask']) !== 4) {
$frame['mask'] = chr(mt_rand(0,255)).chr(mt_rand(0,255)).chr(mt_rand(0,255)).chr(mt_rand(0,255));
}
$encoded .= $frame['mask'];
$encoded .= $this->mask($frame['payload'], $frame['mask']);
} else {
$encoded .= $frame['payload'];
}
return $encoded;
}
/**
* Decode a websocket frame and return an array with the keys:
*
* opcode (int) - the opcode
* final (bool) - final frame
* rsv1-3 (bool) - reserved bits
* masked (bool) - if the frame was masked
* mask (string) - the mask bytes, if masked
* length (int) - length of payload
* payload (string) - the payload
*
* @param string $frame The frame data to decode
* @return array<string,mixed> The decoded frame
*/
public function decode(string $frame): array
{
// Decoded frame
$decoded = [];
// Keep track of the number of bytes in the header
$header = 2;
// Peek at the first byte, holding flags and opcode
$byte0 = ord($frame[0]);
$decoded['final'] = !!($byte0 & 0x80);
$decoded['opcode'] = $byte0 & 0x0F;
// Peek at the second byte, holding mask bit and len
$byte1 = ord($frame[1]);
$decoded['masked'] = $masked = !!($byte1 & 0x80);
$decoded['rsv1'] = !!($byte1 & 0x40);
$decoded['rsv2'] = !!($byte1 & 0x20);
$decoded['rsv3'] = !!($byte1 & 0x10);
$len = $byte1 & 0x7F;
// Read extended length if present
if ($len == 126) {
$len = (ord($frame[$header+0]) << 8)
| (ord($frame[$header+1]));
$header += 2;
} elseif ($len == 127) {
$len = (ord($frame[$header+0]) << 24)
| (ord($frame[$header+1]) << 16)
| (ord($frame[$header+2]) << 8)
| (ord($frame[$header+3]));
$header += 4;
}
// Now for the masking, if present.
if ($masked) {
$mask = substr($frame, $header, 4);
$header += 4;
}
// Extract the payload, and unmask it if needed. The mask() function handles
// both masking and unmasing as the algorithm uses xor.
$payload = substr($frame, $header, $len);
// TODO check that extracted payload len equals expected len
if ($masked) {
$payload = $this->mask($payload, $mask);
$decoded['mask'] = $mask;
}
$decoded['length'] = $len;
$decoded['payload'] = $payload;
return $decoded;
}
/**
* Masking is reversible, and simply xors a repeated 4-byte key with the data.
*
* @param string $payload The unmasked (or masked) input
* @param string $mask The mask to use (4 bytes)
* @return string The masked (or unmasked) output
*/
private function mask(string $payload, string $mask): string
{
// Unpack the payload and mask into byte values
$payloadData = array_map("ord", str_split($payload,1));
$maskData = array_map("ord", str_split($mask,1));
// TODO check that mask len==4
$unmasked = [];
for ($n = 0; $n < count($payloadData); $n++) {
$unmasked[] = $payloadData[$n] ^ $maskData[$n % 4];
}
// Return the masked byte values packed into a string
return join("", array_map("chr", $unmasked));
}
}