From 7fc5ab732425df1974ba60ebfb939ff83e2cd8dc Mon Sep 17 00:00:00 2001 From: Micilini Roll Date: Sat, 9 May 2026 15:59:56 -0300 Subject: [PATCH] phase 02: implement websocket protocol core --- README.zip | Bin 0 -> 43178 bytes src/Protocol/CloseCode.php | 20 ++++ src/Protocol/Frame.php | 50 ++++++++ src/Protocol/FrameCodec.php | 154 +++++++++++++++++++++++++ src/Protocol/Handshake.php | 85 ++++++++++++++ src/Protocol/Opcode.php | 20 ++++ src/Protocol/ProtocolException.php | 11 ++ tests/Unit/Protocol/FrameCodecTest.php | 115 ++++++++++++++++++ tests/Unit/Protocol/HandshakeTest.php | 49 ++++++++ 9 files changed, 504 insertions(+) create mode 100644 README.zip create mode 100644 src/Protocol/CloseCode.php create mode 100644 src/Protocol/Frame.php create mode 100644 src/Protocol/FrameCodec.php create mode 100644 src/Protocol/Handshake.php create mode 100644 src/Protocol/Opcode.php create mode 100644 src/Protocol/ProtocolException.php create mode 100644 tests/Unit/Protocol/FrameCodecTest.php create mode 100644 tests/Unit/Protocol/HandshakeTest.php diff --git a/README.zip b/README.zip new file mode 100644 index 0000000000000000000000000000000000000000..b53126c617d8536a2f62d875c5ebb72182fdc813 GIT binary patch literal 43178 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 Kr*UOse)|t=z*k)W literal 0 HcmV?d00001 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"; + } +}