diff --git a/lib/private/Authentication/Token/PublicKeyTokenProvider.php b/lib/private/Authentication/Token/PublicKeyTokenProvider.php index 597bae331126d..15088619409c2 100644 --- a/lib/private/Authentication/Token/PublicKeyTokenProvider.php +++ b/lib/private/Authentication/Token/PublicKeyTokenProvider.php @@ -30,9 +30,16 @@ class PublicKeyTokenProvider implements IProvider { public const TOKEN_MIN_LENGTH = 22; + /** Token cache TTL in seconds */ private const int TOKEN_CACHE_TTL = 10; + /** + * Maximum plaintext size for a 2048-bit RSA key using OAEP with SHA-1: + * 256 - (2 * 20) - 2 = 214 bytes. + */ + private const int RSA_2048_OAEP_MAX_PLAINTEXT_LENGTH = 214; + use TTransactional; /** @var ICache */ @@ -413,10 +420,13 @@ private function decrypt(string $cipherText, string $token): string { } private function encryptPassword(string $password, string $publicKey): string { - openssl_public_encrypt($password, $encryptedPassword, $publicKey, OPENSSL_PKCS1_OAEP_PADDING); - $encryptedPassword = base64_encode($encryptedPassword); + if (!openssl_public_encrypt($password, $encryptedPassword, $publicKey, OPENSSL_PKCS1_OAEP_PADDING)) { + // Never store missing or invalid ciphertext when password encryption fails. + $this->logOpensslError(); + throw new \RuntimeException('OpenSSL reported a problem'); + } - return $encryptedPassword; + return base64_encode($encryptedPassword); } private function decryptPassword(string $encryptedPassword, string $privateKey): string { @@ -439,12 +449,13 @@ private function hashTokenWithEmptySecret(string $token): string { } /** - * @throws \RuntimeException when OpenSSL reports a problem + * @throws \RuntimeException when the password cannot be stored or OpenSSL reports a problem */ - private function newToken(string $token, + private function newToken( + string $token, string $uid, string $loginName, - $password, + ?string $password, string $name, int $type, int $remember, @@ -454,9 +465,25 @@ private function newToken(string $token, $dbToken->setUid($uid); $dbToken->setLoginName($loginName); + $storeCryptedPassword = $password !== null + && $this->config->getSystemValueBool('auth.storeCryptedPassword', true); + $passwordLength = $storeCryptedPassword ? strlen($password) : 0; + + if ($storeCryptedPassword && $passwordLength > IUserManager::MAX_PASSWORD_LENGTH) { + throw new \RuntimeException(sprintf( + 'Storing an encrypted password longer than %d bytes in an authentication token is not supported.', + IUserManager::MAX_PASSWORD_LENGTH, + )); + } + + $requiredKeySize = $storeCryptedPassword + && $passwordLength > self::RSA_2048_OAEP_MAX_PLAINTEXT_LENGTH + ? 4096 + : 2048; + $config = array_merge([ 'digest_alg' => 'sha512', - 'private_key_bits' => $password !== null && strlen($password) > 250 ? 4096 : 2048, + 'private_key_bits' => $requiredKeySize, ], $this->config->getSystemValue('openssl', [])); // Generate new key @@ -478,10 +505,7 @@ private function newToken(string $token, $dbToken->setPublicKey($publicKey); $dbToken->setPrivateKey($this->encrypt($privateKey, $token)); - if (!is_null($password) && $this->config->getSystemValueBool('auth.storeCryptedPassword', true)) { - if (strlen($password) > IUserManager::MAX_PASSWORD_LENGTH) { - throw new \RuntimeException('Trying to save a password with more than 469 characters is not supported. If you want to use big passwords, disable the auth.storeCryptedPassword option in config.php'); - } + if ($storeCryptedPassword) { $dbToken->setPassword($this->encryptPassword($password, $publicKey)); $dbToken->setPasswordHash($this->hashPassword($password)); } diff --git a/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php b/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php index 1f6eaf8db599d..167c42521cbe8 100644 --- a/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php +++ b/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php @@ -22,6 +22,7 @@ use OCP\ICacheFactory; use OCP\IConfig; use OCP\IDBConnection; +use OCP\IUserManager; use OCP\Security\ICrypto; use OCP\Security\IHasher; use OCP\Server; @@ -128,23 +129,118 @@ public function testGenerateTokenNoPassword(): void { $this->tokenProvider->getPassword($actual, $token); } - public function testGenerateTokenLongPassword(): void { - $token = 'tokentokentokentokentoken'; - $uid = 'user'; - $user = 'User'; - $password = ''; - for ($i = 0; $i < 500; $i++) { - $password .= 'e'; - } + public function testGenerateTokenRejectsPasswordAboveStorageLimit(): void { + $password = str_repeat('e', IUserManager::MAX_PASSWORD_LENGTH + 1); $name = 'User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.2.12) Gecko/20101026 Firefox/3.6.12'; - $type = IToken::PERMANENT_TOKEN; + $this->config->method('getSystemValueBool') ->willReturnMap([ ['auth.storeCryptedPassword', true, true], ]); + + $this->mapper->expects($this->never()) + ->method('insert'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(sprintf( + 'Storing an encrypted password longer than %d bytes in an authentication token is not supported.', + IUserManager::MAX_PASSWORD_LENGTH, + )); + + $this->tokenProvider->generateToken( + 'tokentokentokentokentoken', + 'user', + 'User', + $password, + $name, + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER, + ); + } - $actual = $this->tokenProvider->generateToken($token, $uid, $user, $password, $name, $type, IToken::DO_NOT_REMEMBER); + public function testGenerateTokenDoesNotStoreLongPasswordWhenStorageIsDisabled(): void { + $password = str_repeat('a', IUserManager::MAX_PASSWORD_LENGTH + 1); + + $this->config->method('getSystemValueBool') + ->willReturnMap([ + ['auth.storeCryptedPassword', true, false], + ]); + + $actual = $this->tokenProvider->generateToken( + 'tokentokentokentokentoken', + 'user', + 'User', + $password, + 'Test token', + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER, + ); + + $this->assertSame(2048, $this->getPublicKeyBits($actual)); + $this->assertNull($actual->getPassword()); + $this->assertNull($actual->getPasswordHash()); + } + + private function getPublicKeyBits(PublicKeyToken $token): int { + $publicKey = openssl_pkey_get_public($token->getPublicKey()); + $this->assertNotFalse($publicKey); + + $details = openssl_pkey_get_details($publicKey); + $this->assertIsArray($details); + + return $details['bits']; + } + + public function testGenerateTokenUses2048BitKeyAtOaepLimit(): void { + // 214 = PublicKeyTokenProvider::RSA_2048_OAEP_MAX_PLAINTEXT_LENGTH + $password = str_repeat('a', 214); + + $this->config->method('getSystemValueBool') + ->willReturnMap([ + ['auth.storeCryptedPassword', true, true], + ]); + + $actual = $this->tokenProvider->generateToken( + 'tokentokentokentokentoken', + 'user', + 'User', + $password, + 'Test token', + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER, + ); + + $this->assertSame(2048, $this->getPublicKeyBits($actual)); + $this->assertSame( + $password, + $this->tokenProvider->getPassword($actual, 'tokentokentokentokentoken'), + ); + } + + public function testGenerateTokenUses4096BitKeyAboveOaepLimit(): void { + // 215 = PublicKeyTokenProvider::RSA_2048_OAEP_MAX_PLAINTEXT_LENGTH + 1 + $password = str_repeat('a', 215); + + $this->config->method('getSystemValueBool') + ->willReturnMap([ + ['auth.storeCryptedPassword', true, true], + ]); + + $actual = $this->tokenProvider->generateToken( + 'tokentokentokentokentoken', + 'user', + 'User', + $password, + 'Test token', + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER, + ); + + $this->assertSame(4096, $this->getPublicKeyBits($actual)); + $this->assertSame( + $password, + $this->tokenProvider->getPassword($actual, 'tokentokentokentokentoken'), + ); } public function testGenerateTokenInvalidName(): void { @@ -289,6 +385,48 @@ public function testSetPassword(): void { $this->assertSame($newpass, $this->tokenProvider->getPassword($actual, 'tokentokentokentokentoken')); } + public function testSetPasswordFailsWhenExistingKeyIsTooSmall(): void { + $tokenId = 'tokentokentokentokentoken'; + + $this->config->method('getSystemValueBool') + ->willReturnMap([ + ['auth.storeCryptedPassword', true, true], + ]); + + $token = $this->tokenProvider->generateToken( + $tokenId, + 'user', + 'User', + 'short-password', + 'Test token', + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER, + ); + + $this->assertSame(2048, $this->getPublicKeyBits($token)); + + $this->mapper->method('getTokenByUser') + ->with('user') + ->willReturn([$token]); + + $this->logger->expects($this->once()) + ->method('critical') + ->with($this->stringStartsWith('Something is wrong with your openssl setup:')); + + $this->mapper->expects($this->never()) + ->method('update'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('OpenSSL reported a problem'); + + $this->tokenProvider->setPassword( + $token, + $tokenId, + // 215 = PublicKeyTokenProvider::RSA_2048_OAEP_MAX_PLAINTEXT_LENGTH + 1 + str_repeat('a', 215), + ); + } + public function testSetPasswordInvalidToken(): void { $this->expectException(InvalidTokenException::class);