diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7164c76..f6a99f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: image: postgres:${{ matrix.postgres }} env: POSTGRES_PASSWORD: postgres + POSTGRES_INITDB_ARGS: --auth-host=md5 # Set health checks to wait until postgres has started options: >- --health-cmd pg_isready @@ -34,7 +35,7 @@ jobs: fail-fast: false matrix: php: ${{ fromJson(needs.supported-versions-matrix.outputs.version) }} - postgres: [12, 13] + postgres: [12, 13, 14, 15] composer: [lowest, locked, highest] needs: - supported-versions-matrix @@ -46,6 +47,8 @@ jobs: PGPASSWORD=postgres psql -h localhost -U postgres -c "ALTER ROLE pgasync PASSWORD 'pgasync'" PGPASSWORD=postgres psql -h localhost -U postgres -c "CREATE USER pgasyncpw" PGPASSWORD=postgres psql -h localhost -U postgres -c "ALTER ROLE pgasyncpw PASSWORD 'example_password'" + PGPASSWORD=postgres psql -h localhost -U postgres -c "CREATE USER scram_user" + PGPASSWORD=postgres psql -h localhost -U postgres -c "SET password_encryption='scram-sha-256';ALTER ROLE scram_user PASSWORD 'scram_password'" PGPASSWORD=postgres psql -h localhost -U postgres -c "CREATE DATABASE pgasync_test OWNER pgasync" PGPASSWORD=pgasync psql -h localhost -U pgasync -f tests/test_db.sql pgasync_test # PGPASSWORD=postgres cat tests/test_db.sql | xargs -I % psql -h localhost -U postgres -c "%" diff --git a/composer.json b/composer.json index 309a457..7c0172a 100644 --- a/composer.json +++ b/composer.json @@ -42,5 +42,9 @@ "require-dev": { "phpunit/phpunit": ">=8.5.23 || ^6.5.5", "react/dns": "^1.0" + }, + "scripts": { + "docker-up": "cd docker && docker-compose up -d", + "docker-down": "cd docker && docker-compose down" } } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9b2d179..3031c55 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,6 +7,7 @@ services: environment: - PGDATA=/database - POSTGRES_PASSWORD=some_password + - POSTGRES_INITDB_ARGS=--auth-host=md5 - TZ=America/New_York volumes: - .:/app @@ -15,3 +16,8 @@ services: - ./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d ports: - "5432:5432" + +configs: + pg_hba: + file: pg_hba_new.conf + diff --git a/docker/docker-entrypoint-initdb.d/init.sh b/docker/docker-entrypoint-initdb.d/init.sh index fe14128..af68572 100644 --- a/docker/docker-entrypoint-initdb.d/init.sh +++ b/docker/docker-entrypoint-initdb.d/init.sh @@ -5,7 +5,9 @@ echo "Running as $USER in $PWD" createuser -U postgres --createdb pgasync createuser -U postgres --createdb pgasyncpw +createuser -U postgres --createdb scram_user psql -U postgres -c "ALTER ROLE pgasyncpw PASSWORD 'example_password'" +psql -U postgres -c "SET password_encryption='scram-sha-256'; ALTER ROLE scram_user PASSWORD 'scram_password'" cd /app cp pg_hba_new.conf database/pg_hba.conf diff --git a/src/PgAsync/Command/SaslInitialResponse.php b/src/PgAsync/Command/SaslInitialResponse.php new file mode 100644 index 0000000..83f538a --- /dev/null +++ b/src/PgAsync/Command/SaslInitialResponse.php @@ -0,0 +1,41 @@ +scramSha265 = $scramSha265; + } + + public function encodedMessage(): string + { + $mechanism = self::SCRAM_SHA_256 . "\0"; + $clientFirstMessage = $this->scramSha265->getClientFirstMessage(); + + $message = "p"; + $messageLength = strlen($mechanism) + strlen($clientFirstMessage) + 8; + $message .= pack("N", $messageLength) . $mechanism; + $message .= pack("N", strlen($clientFirstMessage)) . $clientFirstMessage; + + return $message; + } + + public function shouldWaitForComplete(): bool + { + return false; + } +} \ No newline at end of file diff --git a/src/PgAsync/Command/SaslResponse.php b/src/PgAsync/Command/SaslResponse.php new file mode 100644 index 0000000..b7532e6 --- /dev/null +++ b/src/PgAsync/Command/SaslResponse.php @@ -0,0 +1,39 @@ +scramSha265 = $scramSha265; + } + + public function encodedMessage(): string + { + $clientFinalMessage = $this->createClientFinalMessage(); + $messageLength = strlen($clientFinalMessage) + 4; + + return 'p' . pack('N', $messageLength) . $clientFinalMessage; + } + + public function shouldWaitForComplete(): bool + { + return false; + } + + private function createClientFinalMessage(): string + { + return $this->scramSha265->getClientFirstMessageWithoutProof() . ',p=' . base64_encode($this->scramSha265->getClientProof()); + } +} \ No newline at end of file diff --git a/src/PgAsync/Connection.php b/src/PgAsync/Connection.php index 1f896f7..2a06107 100644 --- a/src/PgAsync/Connection.php +++ b/src/PgAsync/Connection.php @@ -10,6 +10,8 @@ use PgAsync\Command\Execute; use PgAsync\Command\Parse; use PgAsync\Command\PasswordMessage; +use PgAsync\Command\SaslInitialResponse; +use PgAsync\Command\SaslResponse; use PgAsync\Command\Sync; use PgAsync\Command\Terminate; use PgAsync\Message\Authentication; @@ -119,6 +121,9 @@ class Connection extends EventEmitter /** @var bool */ private $cancelRequested; + /** @var ScramSha256 */ + private $scramSha256; + /** * Can be 'I' for Idle, 'T' if in transactions block * or 'E' if in failed transaction block (queries will fail until end of trans) @@ -174,6 +179,7 @@ public function __construct(array $parameters, LoopInterface $loop, ConnectorInt $this->cancelRequested = false; $this->parameters = $parameters; + $this->scramSha256 = new ScramSha256($parameters['user'], $this->password ?: ''); } private function start() @@ -268,7 +274,9 @@ private function processData($data) $type = $data[0]; - $message = Message::createMessageFromIdentifier($type); + $message = Message::createMessageFromIdentifier($type, [ + 'SCRAM_SHA_256' => $this->scramSha256 + ]); if ($message !== false) { $this->currentMessage = $message; return $data; @@ -388,6 +396,28 @@ private function handleAuthentication(Authentication $message) return; } + if ($message->getAuthCode() === $message::AUTH_SCRAM) { + $saslInitialResponse = new SaslInitialResponse($this->scramSha256); + $this->stream->write($saslInitialResponse->encodedMessage()); + + return; + } + + if ($message->getAuthCode() === $message::AUTH_SCRAM_CONTINUE) { + $saslResponse = new SaslResponse($this->scramSha256); + $this->stream->write($saslResponse->encodedMessage()); + + return; + } + + if ($message->getAuthCode() === $message::AUTH_SCRAM_FIN) { + if ($this->scramSha256->verify()) { + return; + } + + $this->lastError = 'Invalid server signature sent by server on SCRAM FIN stage'; + } + $this->connStatus = $this::CONNECTION_BAD; $this->failAllCommandsWith(new \Exception($this->lastError)); $this->emit('error', [new \Exception($this->lastError)]); diff --git a/src/PgAsync/Message/Authentication.php b/src/PgAsync/Message/Authentication.php index f3f06aa..9dbb932 100644 --- a/src/PgAsync/Message/Authentication.php +++ b/src/PgAsync/Message/Authentication.php @@ -2,6 +2,8 @@ namespace PgAsync\Message; +use PgAsync\ScramSha256; + class Authentication extends Message { const AUTH_OK = 0; // AuthenticationOk @@ -12,11 +14,26 @@ class Authentication extends Message const AUTH_GSS = 7; // AuthenticationGSS const AUTH_GSS_CONTINUE = 8; // AuthenticationGSSContinue const AUTH_SSPI = 9; // AuthenticationSSPI + const AUTH_SCRAM = 10; // AuthenticationSASL + const AUTH_SCRAM_CONTINUE = 11; // AuthenticationSASLContinue + const AUTH_SCRAM_FIN = 12; // AuthenticationSASLFinal private $authCode; private $salt; + /** @var ScramSha256 */ + private $scramSha256; + + private $iteration; + + private $nonce; + + public function __construct(ScramSha256 $scramSha265) + { + $this->scramSha256 = $scramSha265; + } + /** * @inheritDoc * @throws \InvalidArgumentException @@ -47,6 +64,23 @@ public function parseMessage(string $rawMessage) break; // AuthenticationGSSContinue case $this::AUTH_SSPI: break; // AuthenticationSSPI + case $this::AUTH_SCRAM: + $this->scramSha256->beginFirstClientMessageStage(); + break; + case $this::AUTH_SCRAM_CONTINUE: + $content = $this->getContent($rawMessage); + $parts = explode(',', $content); + $this->scramSha256->beginFinalClientMessageStage( + substr($parts[0], 2), + substr($parts[1], 2), + (int) substr($parts[2], 2) + ); + + break; + case $this::AUTH_SCRAM_FIN: + $content = $this->getContent($rawMessage); + $this->scramSha256->beginVerificationStage(substr($content, 2)); + break; } $this->authCode = $authCode; @@ -70,4 +104,10 @@ public function getSalt(): string return $this->salt; } + + private function getContent(string $rawMessage): string + { + $messageLength = unpack('N', substr($rawMessage, 1, 4))[1]; + return substr($rawMessage, 9, $messageLength - 8); + } } diff --git a/src/PgAsync/Message/Message.php b/src/PgAsync/Message/Message.php index 8f08588..e5eaf5b 100644 --- a/src/PgAsync/Message/Message.php +++ b/src/PgAsync/Message/Message.php @@ -23,11 +23,11 @@ public static function prependLengthInt32(string $s): string return Message::int32($len + 4) . $s; } - public static function createMessageFromIdentifier(string $identifier): ParserInterface + public static function createMessageFromIdentifier(string $identifier, array $dependencies = []): ParserInterface { switch ($identifier) { case 'R': - return new Authentication(); + return new Authentication($dependencies['SCRAM_SHA_256']); case 'K': return new BackendKeyData(); case 'C': diff --git a/src/PgAsync/ScramSha256.php b/src/PgAsync/ScramSha256.php new file mode 100644 index 0000000..1895dfb --- /dev/null +++ b/src/PgAsync/ScramSha256.php @@ -0,0 +1,208 @@ +user = $user; + $this->password = $password; + } + + public function beginFirstClientMessageStage() + { + $length = strlen(self::CHARACTERS); + + for ($i = 0; $i < self::CLIENT_NONCE_LENGTH; $i++) { + $this->clientNonce .= substr(self::CHARACTERS, random_int(0, $length), 1); + } + + $this->currentStage = self::STAGE_FIRST_MESSAGE; + } + + public function beginFinalClientMessageStage(string $nonce, string $salt, int $iteration) + { + $this->nonce = $nonce; + $this->salt = $salt; + $this->iteration = $iteration; + + $this->currentStage = self::STAGE_FINAL_MESSAGE; + } + + public function beginVerificationStage(string $verification) + { + $this->verification = $verification; + + $this->currentStage = self::STAGE_VERIFICATION; + } + + public function verify(): bool + { + $this->checkStage(self::STAGE_VERIFICATION); + + $serverKey = hash_hmac("sha256", "Server Key", $this->getSaltedPassword(), true); + $serverSignature = hash_hmac('sha256', $this->getAuthMessage(), $serverKey, true); + + return $serverSignature === base64_decode($this->verification); + } + + public function getClientFirstMessageWithoutProof(): string + { + if (null === $this->clientFirstMessageWithoutProof) { + $this->clientFirstMessageWithoutProof = sprintf( + 'c=%s,r=%s', + base64_encode('n,,'), + $this->nonce + ); + } + + return $this->clientFirstMessageWithoutProof; + } + + public function getSaltedPassword(): string + { + $this->checkStage(self::STAGE_FINAL_MESSAGE); + + if (null === $this->saltedPassword) { + $this->saltedPassword = hash_pbkdf2( + "sha256", + $this->password, + base64_decode($this->salt), + $this->iteration, + 32, + true + ); + } + + return $this->saltedPassword; + } + + public function getClientKey(): string + { + $this->checkStage(self::STAGE_FINAL_MESSAGE); + + if (null === $this->clientKey) { + $this->clientKey = hash_hmac("sha256", "Client Key", $this->getSaltedPassword(), true); + } + + return $this->clientKey; + } + + public function getStoredKey(): string + { + $this->checkStage(self::STAGE_FINAL_MESSAGE); + + if (null === $this->storedKey) { + $this->storedKey = hash("sha256", $this->getClientKey(), true); + } + + return $this->storedKey; + } + + public function getClientFirstMessageBare(): string + { + $this->checkStage(self::STAGE_FIRST_MESSAGE); + + return sprintf( + 'n=%s,r=%s', + $this->user, + $this->clientNonce + ); + } + + public function getClientFirstMessage(): string + { + $this->checkStage(self::STAGE_FIRST_MESSAGE); + + return sprintf('n,,%s', $this->getClientFirstMessageBare()); + } + + public function getAuthMessage(): string + { + $this->checkStage(self::STAGE_FINAL_MESSAGE); + + if (null === $this->authMessage) { + $clientFirstMessageBare = $this->getClientFirstMessageBare(); + $serverFirstMessage = sprintf( + 'r=%s,s=%s,i=%s', + $this->nonce, + $this->salt, + $this->iteration + ); + + $this->authMessage = implode(',', [ + $clientFirstMessageBare, + $serverFirstMessage, + $this->getClientFirstMessageWithoutProof() + ]); + } + + return $this->authMessage; + } + + public function getClientProof(): string + { + $this->checkStage(self::STAGE_FINAL_MESSAGE); + + $clientKey = $this->getClientKey(); + $storedKey = $this->getStoredKey(); + $authMessage = $this->getAuthMessage(); + $clientSignature = hash_hmac("sha256", $authMessage, $storedKey, true); + + return $clientKey ^ $clientSignature; + } + + private function checkStage(int $stage) + { + if ($this->currentStage < $stage) { + throw new \LogicException('Invalid Stage of SCRAM authorization'); + } + } +} \ No newline at end of file diff --git a/tests/Integration/ScramSha256PasswordTest.php b/tests/Integration/ScramSha256PasswordTest.php new file mode 100644 index 0000000..14f11f1 --- /dev/null +++ b/tests/Integration/ScramSha256PasswordTest.php @@ -0,0 +1,42 @@ + 'scram_user', + "database" => $this->getDbName(), + "auto_disconnect" => true, + "password" => "scram_password" + ], $this->getLoop()); + + $hello = null; + + $client->query("SELECT 'Hello' AS hello") + ->subscribe(new CallbackObserver( + function ($x) use (&$hello) { + $this->assertNull($hello); + $hello = $x['hello']; + }, + function ($e) { + $this->fail('Unexpected error ' . $e); + }, + function () { + $this->getLoop()->addTimer(0.1, function () { + $this->stopLoop(); + }); + } + )); + + $this->runLoopWithTimeout(2); + + $this->assertEquals('Hello', $hello); + } +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index f47bddf..6ab29c0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,7 +3,6 @@ namespace PgAsync\Tests; use EventLoop\EventLoop; -use React\EventLoop\Factory; use React\EventLoop\LoopInterface; use React\EventLoop\Timer\Timer; use PHPUnit\Framework\TestCase as BaseTestCase; diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index 957abff..606b695 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -50,9 +50,7 @@ function (Exception $e) use (&$exception) { public function testFailedDNSLookupEarlyRejection() { - $executor = $this->getMockBuilder(ExecutorInterface::class) - ->setMethods(['query']) - ->getMock(); + $executor = $this->createMock(ExecutorInterface::class); $executor ->method('query') diff --git a/tests/Unit/Message/MessageTest.php b/tests/Unit/Message/MessageTest.php index df13505..9eebf53 100644 --- a/tests/Unit/Message/MessageTest.php +++ b/tests/Unit/Message/MessageTest.php @@ -15,7 +15,7 @@ public function testNotificationResponse() $rawNotificationMessage = hex2bin('41000000190000040c686572650048656c6c6f20746865726500'); - $notificationResponse = \PgAsync\Message\Message::createMessageFromIdentifier($rawNotificationMessage[0]); + $notificationResponse = \PgAsync\Message\Message::createMessageFromIdentifier($rawNotificationMessage[0], []); $this->assertInstanceOf(\PgAsync\Message\NotificationResponse::class, $notificationResponse); /** @var \PgAsync\Message\NotificationResponse */ $notificationResponse->parseData($rawNotificationMessage);