194 lines
6.1 KiB
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));
|
|
}
|
|
|
|
} |