Skip to content
46 changes: 35 additions & 11 deletions lib/private/Authentication/Token/PublicKeyTokenProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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));
}
Expand Down
158 changes: 148 additions & 10 deletions tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);

Expand Down
Loading