diff --git a/README.zip b/README.zip new file mode 100644 index 0000000..b53126c Binary files /dev/null and b/README.zip differ diff --git a/src/Protocol/CloseCode.php b/src/Protocol/CloseCode.php new file mode 100644 index 0000000..c182ea2 --- /dev/null +++ b/src/Protocol/CloseCode.php @@ -0,0 +1,20 @@ +payload); + + if ($this->opcode->isControl() && !$this->fin) { + throw new ProtocolException('Control frames must not be fragmented.'); + } + + if ($this->opcode->isControl() && $payloadLength > 125) { + throw new ProtocolException('Control frame payload cannot be larger than 125 bytes.'); + } + } + + public static function text(string $payload): self + { + return new self(true, Opcode::TEXT, $payload); + } + + public static function binary(string $payload): self + { + return new self(true, Opcode::BINARY, $payload); + } + + public static function close(string $payload = ''): self + { + return new self(true, Opcode::CLOSE, $payload); + } + + public static function ping(string $payload = ''): self + { + return new self(true, Opcode::PING, $payload); + } + + public static function pong(string $payload = ''): self + { + return new self(true, Opcode::PONG, $payload); + } +} diff --git a/src/Protocol/FrameCodec.php b/src/Protocol/FrameCodec.php new file mode 100644 index 0000000..61d7846 --- /dev/null +++ b/src/Protocol/FrameCodec.php @@ -0,0 +1,154 @@ +maxPayloadBytes < 1) { + throw new InvalidArgumentException('Maximum payload size must be greater than zero.'); + } + } + + public function decode(string $data, bool $fromClient = true): Frame + { + if (strlen($data) < 2) { + throw new ProtocolException('Incomplete WebSocket frame header.'); + } + + $firstByte = ord($data[0]); + $secondByte = ord($data[1]); + $fin = ($firstByte & 0x80) === 0x80; + $reservedBits = $firstByte & 0x70; + + if ($reservedBits !== 0) { + throw new ProtocolException('Reserved WebSocket frame bits are not supported.'); + } + + $opcode = Opcode::tryFrom($firstByte & 0x0F); + + if (!$opcode instanceof Opcode) { + throw new ProtocolException('Unsupported WebSocket opcode.'); + } + + $masked = ($secondByte & 0x80) === 0x80; + + if ($fromClient && !$masked) { + throw new ProtocolException('Client WebSocket frames must be masked.'); + } + + $payloadLength = $secondByte & 0x7F; + $offset = 2; + + if ($payloadLength === 126) { + $this->assertAvailableBytes($data, $offset, 2); + $lengthParts = unpack('nlength', substr($data, $offset, 2)); + + if ($lengthParts === false) { + throw new ProtocolException('Invalid WebSocket payload length.'); + } + + $payloadLength = (int) $lengthParts['length']; + $offset += 2; + } elseif ($payloadLength === 127) { + $this->assertAvailableBytes($data, $offset, 8); + $parts = unpack('Nhigh/Nlow', substr($data, $offset, 8)); + + if ($parts === false) { + throw new ProtocolException('Invalid WebSocket payload length.'); + } + + if ((int) $parts['high'] !== 0) { + throw new ProtocolException('WebSocket payload length is too large.'); + } + + $payloadLength = (int) $parts['low']; + $offset += 8; + } + + if ($payloadLength > $this->maxPayloadBytes) { + throw new ProtocolException('WebSocket payload exceeds the configured maximum size.'); + } + + if ($opcode->isControl()) { + if (!$fin) { + throw new ProtocolException('Control frames must not be fragmented.'); + } + + if ($payloadLength > 125) { + throw new ProtocolException('Control frame payload cannot be larger than 125 bytes.'); + } + } + + $maskingKey = ''; + + if ($masked) { + $this->assertAvailableBytes($data, $offset, 4); + $maskingKey = substr($data, $offset, 4); + $offset += 4; + } + + $this->assertAvailableBytes($data, $offset, $payloadLength); + $payload = substr($data, $offset, $payloadLength); + + if ($masked) { + $payload = self::applyMask($payload, $maskingKey); + } + + return new Frame($fin, $opcode, $payload, $masked); + } + + public function encode(Frame $frame, bool $mask = false): string + { + $payload = $frame->payload; + $payloadLength = strlen($payload); + + if ($payloadLength > $this->maxPayloadBytes) { + throw new ProtocolException('WebSocket payload exceeds the configured maximum size.'); + } + + $firstByte = ($frame->fin ? 0x80 : 0x00) | $frame->opcode->value; + $header = chr($firstByte); + $maskBit = $mask ? 0x80 : 0x00; + + if ($payloadLength <= 125) { + $header .= chr($maskBit | $payloadLength); + } elseif ($payloadLength <= 65535) { + $header .= chr($maskBit | 126) . pack('n', $payloadLength); + } else { + $header .= chr($maskBit | 127) . pack('NN', 0, $payloadLength); + } + + if (!$mask) { + return $header . $payload; + } + + $maskingKey = random_bytes(4); + + return $header . $maskingKey . self::applyMask($payload, $maskingKey); + } + + private function assertAvailableBytes(string $data, int $offset, int $neededBytes): void + { + if (strlen($data) < $offset + $neededBytes) { + throw new ProtocolException('Incomplete WebSocket frame payload.'); + } + } + + private static function applyMask(string $payload, string $maskingKey): string + { + $result = ''; + $payloadLength = strlen($payload); + + for ($index = 0; $index < $payloadLength; $index++) { + $result .= $payload[$index] ^ $maskingKey[$index % 4]; + } + + return $result; + } +} diff --git a/src/Protocol/Handshake.php b/src/Protocol/Handshake.php new file mode 100644 index 0000000..fc6d382 --- /dev/null +++ b/src/Protocol/Handshake.php @@ -0,0 +1,85 @@ + + */ + public static function parseRequestHeaders(string $request): array + { + $headers = []; + $lines = preg_split('/\r\n|\n|\r/', $request) ?: []; + + foreach ($lines as $line) { + if (!str_contains($line, ':')) { + continue; + } + + [$name, $value] = explode(':', $line, 2); + $headers[strtolower(trim($name))] = trim($value); + } + + return $headers; + } + + /** + * @return array + */ + public static function validateRequest(string $request): array + { + $headers = self::parseRequestHeaders($request); + + if (strtolower($headers['upgrade'] ?? '') !== 'websocket') { + throw new ProtocolException('Invalid WebSocket upgrade header.'); + } + + if (!str_contains(strtolower($headers['connection'] ?? ''), 'upgrade')) { + throw new ProtocolException('Invalid WebSocket connection header.'); + } + + $key = $headers['sec-websocket-key'] ?? ''; + + if (!self::isValidClientKey($key)) { + throw new ProtocolException('Invalid WebSocket client key.'); + } + + if (($headers['sec-websocket-version'] ?? '13') !== '13') { + throw new ProtocolException('Unsupported WebSocket version.'); + } + + return $headers; + } + + public static function response(string $request): string + { + $headers = self::validateRequest($request); + $accept = self::acceptKey($headers['sec-websocket-key']); + + return "HTTP/1.1 101 Switching Protocols\r\n" + . "Upgrade: websocket\r\n" + . "Connection: Upgrade\r\n" + . "Sec-WebSocket-Accept: {$accept}\r\n\r\n"; + } + + private static function isValidClientKey(string $key): bool + { + if ($key === '') { + return false; + } + + $decoded = base64_decode($key, true); + + return is_string($decoded) && strlen($decoded) === 16; + } +} diff --git a/src/Protocol/Opcode.php b/src/Protocol/Opcode.php new file mode 100644 index 0000000..65be77b --- /dev/null +++ b/src/Protocol/Opcode.php @@ -0,0 +1,20 @@ +decode($this->maskedFrame(Opcode::TEXT, 'Hello')); + + self::assertTrue($frame->fin); + self::assertSame(Opcode::TEXT, $frame->opcode); + self::assertSame('Hello', $frame->payload); + self::assertTrue($frame->masked); + } + + public function testClientTextFrameWithEmojiIsDecoded(): void + { + $codec = new FrameCodec(); + $frame = $codec->decode($this->maskedFrame(Opcode::TEXT, 'Hello 🚀')); + + self::assertSame('Hello 🚀', $frame->payload); + } + + public function testClientTextFrameWithExtendedPayloadLengthIsDecoded(): void + { + $payload = str_repeat('A', 126); + $codec = new FrameCodec(); + $frame = $codec->decode($this->maskedFrame(Opcode::TEXT, $payload)); + + self::assertSame($payload, $frame->payload); + } + + public function testServerTextFrameIsEncodedWithoutMask(): void + { + $codec = new FrameCodec(); + + self::assertSame("\x81\x05Hello", $codec->encode(Frame::text('Hello'))); + } + + public function testPayloadAboveConfiguredLimitIsRejected(): void + { + $codec = new FrameCodec(maxPayloadBytes: 5); + + $this->expectException(ProtocolException::class); + $this->expectExceptionMessage('WebSocket payload exceeds the configured maximum size.'); + + $codec->decode($this->maskedFrame(Opcode::TEXT, 'Too big')); + } + + public function testPingAndPongFramesAreRecognized(): void + { + $codec = new FrameCodec(); + + self::assertSame(Opcode::PING, $codec->decode($this->maskedFrame(Opcode::PING, 'ping'))->opcode); + self::assertSame(Opcode::PONG, $codec->decode($this->maskedFrame(Opcode::PONG, 'pong'))->opcode); + } + + public function testCloseFrameIsRecognized(): void + { + $codec = new FrameCodec(); + $frame = $codec->decode($this->maskedFrame(Opcode::CLOSE, '')); + + self::assertSame(Opcode::CLOSE, $frame->opcode); + self::assertSame('', $frame->payload); + } + + public function testUnmaskedClientFrameIsRejected(): void + { + $codec = new FrameCodec(); + + $this->expectException(ProtocolException::class); + $this->expectExceptionMessage('Client WebSocket frames must be masked.'); + + $codec->decode("\x81\x05Hello"); + } + + private function maskedFrame(Opcode $opcode, string $payload): string + { + $firstByte = chr(0x80 | $opcode->value); + $payloadLength = strlen($payload); + $maskingKey = "\x37\xfa\x21\x3d"; + + if ($payloadLength <= 125) { + $header = $firstByte . chr(0x80 | $payloadLength); + } elseif ($payloadLength <= 65535) { + $header = $firstByte . chr(0x80 | 126) . pack('n', $payloadLength); + } else { + $header = $firstByte . chr(0x80 | 127) . pack('NN', 0, $payloadLength); + } + + return $header . $maskingKey . $this->maskPayload($payload, $maskingKey); + } + + private function maskPayload(string $payload, string $maskingKey): string + { + $result = ''; + $payloadLength = strlen($payload); + + for ($index = 0; $index < $payloadLength; $index++) { + $result .= $payload[$index] ^ $maskingKey[$index % 4]; + } + + return $result; + } +} diff --git a/tests/Unit/Protocol/HandshakeTest.php b/tests/Unit/Protocol/HandshakeTest.php new file mode 100644 index 0000000..b38c26a --- /dev/null +++ b/tests/Unit/Protocol/HandshakeTest.php @@ -0,0 +1,49 @@ +validRequest()); + + self::assertStringContainsString('HTTP/1.1 101 Switching Protocols', $response); + self::assertStringContainsString('Upgrade: websocket', $response); + self::assertStringContainsString('Connection: Upgrade', $response); + self::assertStringContainsString('Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=', $response); + self::assertStringEndsWith("\r\n\r\n", $response); + } + + public function testRequestWithoutValidClientKeyIsRejected(): void + { + $this->expectException(ProtocolException::class); + $this->expectExceptionMessage('Invalid WebSocket client key.'); + + Handshake::response(str_replace('Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', '', $this->validRequest())); + } + + private function validRequest(): string + { + return "GET /chat HTTP/1.1\r\n" + . "Host: example.com:8000\r\n" + . "Upgrade: websocket\r\n" + . "Connection: Upgrade\r\n" + . "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + . "Sec-WebSocket-Version: 13\r\n\r\n"; + } +}