Skip to content

Commit e30b771

Browse files
committed
feat(setup): properly support encryption options for databases
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 04aa4e8 commit e30b771

9 files changed

Lines changed: 402 additions & 5 deletions

File tree

lib/private/DB/ConnectionFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ public function createConnectionParams(string $configPrefix = '', array $additio
210210
//additional driver options, eg. for mysql ssl
211211
$driverOptions = $this->config->getValue($configPrefix . 'dbdriveroptions', $this->config->getValue('dbdriveroptions', null));
212212
if ($driverOptions) {
213-
$connectionParams['driverOptions'] = $driverOptions;
213+
$connectionParams['driverOptions'] = array_merge($connectionParams['driverOptions'], $driverOptions);
214214
}
215215

216216
// set default table creation options

lib/private/Setup/AbstractDatabase.php

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ abstract class AbstractDatabase {
2525
*/
2626
protected const array CONNECTION_ENCRYPTION_OPTIONS = ['dbdriveroptions'];
2727

28+
/**
29+
* Installer options describing an encrypted database connection independently of the
30+
* database in use, as provided by the web installer and `occ maintenance:install`.
31+
* @var string[]
32+
*/
33+
protected const array ENCRYPTION_OPTIONS = ['dbsslmode', 'dbsslca', 'dbsslcert', 'dbsslkey', 'dbsslcrl', 'dbsslnoverify'];
34+
35+
/**
36+
* The subset of {@see static::ENCRYPTION_OPTIONS} this database supports.
37+
* @var string[]
38+
*/
39+
protected const array SUPPORTED_ENCRYPTION_OPTIONS = [];
40+
2841
protected string $dbprettyname = 'abstract';
2942

3043
protected string $dbUser;
@@ -55,16 +68,46 @@ public function validate(array $config): array {
5568
if (substr_count($config['dbname'], '.') >= 1) {
5669
$errors[] = $this->trans->t('You cannot use dots in the database name %s', [$this->dbprettyname]);
5770
}
71+
return array_merge($errors, $this->validateEncryptionOptions($config));
72+
}
73+
74+
/**
75+
* Validate the installer options configuring an encrypted database connection.
76+
*
77+
* @param array $config The options passed to the installer
78+
* @return string[]
79+
*/
80+
protected function validateEncryptionOptions(array $config): array {
81+
$errors = [];
5882
foreach (static::CONNECTION_ENCRYPTION_OPTIONS as $option) {
5983
if (isset($config[$option]) && !is_array($config[$option])) {
60-
// Fail instead of ignoring the option, otherwise the instance would be
61-
// installed with an unencrypted connection without the admin noticing.
6284
$errors[] = $this->trans->t('The database option "%1$s" for %2$s has to be a list of values', [$option, $this->dbprettyname]);
6385
}
6486
}
87+
foreach (static::ENCRYPTION_OPTIONS as $option) {
88+
if (!empty($config[$option]) && !in_array($option, static::SUPPORTED_ENCRYPTION_OPTIONS, true)) {
89+
$errors[] = $this->trans->t('The database option "%1$s" is not supported by %2$s', [$option, $this->dbprettyname]);
90+
}
91+
}
92+
// A client certificate is useless without its private key and vice versa
93+
if (in_array('dbsslcert', static::SUPPORTED_ENCRYPTION_OPTIONS, true)
94+
&& empty($config['dbsslcert']) !== empty($config['dbsslkey'])) {
95+
$errors[] = $this->trans->t('The database options "dbsslcert" and "dbsslkey" have to be provided together');
96+
}
6597
return $errors;
6698
}
6799

100+
/**
101+
* Translate the `ENCRYPTION_OPTIONS` into the system config values that
102+
* configure an encrypted connection for this database.
103+
*
104+
* @param array $config The options passed to the installer
105+
* @return array<string, array> System config values, empty if no option was provided
106+
*/
107+
protected function getEncryptionConfig(array $config): array {
108+
return [];
109+
}
110+
68111
public function initialize(array $config): void {
69112
$dbUser = $config['dbuser'];
70113
$dbPass = $config['dbpass'];
@@ -97,6 +140,13 @@ public function initialize(array $config): void {
97140
$configValues[$option] = $config[$option];
98141
}
99142

143+
// The database independent options end up in the same config values, so they are
144+
// applied on top of any raw value provided, e.g. through an autoconfig file.
145+
// array_replace() instead of array_merge() to keep the numeric PDO attribute keys.
146+
foreach ($this->getEncryptionConfig($config) as $option => $value) {
147+
$configValues[$option] = array_replace($configValues[$option] ?? [], $value);
148+
}
149+
100150
$this->config->setValues($configValues);
101151

102152
$this->dbUser = $dbUser;

lib/private/Setup/MySQL.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
class MySQL extends AbstractDatabase {
1818
public string $dbprettyname = 'MySQL/MariaDB';
1919

20+
/**
21+
* There is no equivalent to the PostgreSQL `sslmode`, the connection is encrypted by
22+
* providing a CA certificate. A revocation list cannot be passed through PDO either.
23+
*/
24+
protected const array SUPPORTED_ENCRYPTION_OPTIONS = ['dbsslca', 'dbsslcert', 'dbsslkey', 'dbsslnoverify'];
25+
2026
#[\Override]
2127
public function setupDatabase(): void {
2228
//check if the database user has admin right
@@ -80,6 +86,47 @@ public function setupDatabase(): void {
8086
}
8187
}
8288

89+
#[\Override]
90+
protected function getEncryptionConfig(array $config): array {
91+
$attributes = $this->getSslAttributes();
92+
93+
$driverOptions = [];
94+
foreach (['dbsslca' => 'ca', 'dbsslcert' => 'cert', 'dbsslkey' => 'key'] as $option => $attribute) {
95+
if (!empty($config[$option])) {
96+
$driverOptions[$attributes[$attribute]] = (string)$config[$option];
97+
}
98+
}
99+
if (!empty($config['dbsslnoverify'])) {
100+
$driverOptions[$attributes['verify']] = false;
101+
}
102+
103+
return $driverOptions === [] ? [] : ['dbdriveroptions' => $driverOptions];
104+
}
105+
106+
/**
107+
* PDO attributes configuring an encrypted connection.
108+
*
109+
* @return array{ca: int, cert: int, key: int, verify: int}
110+
*/
111+
private function getSslAttributes(): array {
112+
// TODO: simplify once we only support PHP 8.5+.
113+
if (PHP_VERSION_ID >= 80500 && class_exists(\Pdo\Mysql::class)) {
114+
/** @psalm-suppress UndefinedClass */
115+
return [
116+
'ca' => \Pdo\Mysql::ATTR_SSL_CA,
117+
'cert' => \Pdo\Mysql::ATTR_SSL_CERT,
118+
'key' => \Pdo\Mysql::ATTR_SSL_KEY,
119+
'verify' => \Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT,
120+
];
121+
}
122+
return [
123+
'ca' => \PDO::MYSQL_ATTR_SSL_CA,
124+
'cert' => \PDO::MYSQL_ATTR_SSL_CERT,
125+
'key' => \PDO::MYSQL_ATTR_SSL_KEY,
126+
'verify' => \PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT,
127+
];
128+
}
129+
83130
private function createDatabase(\OC\DB\Connection $connection): void {
84131
try {
85132
$name = $this->dbName;

lib/private/Setup/OCI.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ public function validate(array $config): array {
4242
} elseif (empty($config['dbname'])) {
4343
$errors[] = $this->trans->t('Enter the database name for %s', [$this->dbprettyname]);
4444
}
45-
return $errors;
45+
// Oracle is configured through the connect string and `sqlnet.ora`, not by the installer
46+
return array_merge($errors, $this->validateEncryptionOptions($config));
4647
}
4748

4849
#[\Override]

lib/private/Setup/PostgreSQL.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,33 @@ class PostgreSQL extends AbstractDatabase {
1919
// #[\Override] TODO: Uncomment this when we only support PHP 8.5+ support
2020
protected const array CONNECTION_ENCRYPTION_OPTIONS = [...parent::CONNECTION_ENCRYPTION_OPTIONS, 'pgsql_ssl'];
2121

22+
// #[\Override] TODO: Uncomment this when we only support PHP 8.5+ support
23+
protected const array SUPPORTED_ENCRYPTION_OPTIONS = ['dbsslmode', 'dbsslca', 'dbsslcert', 'dbsslkey', 'dbsslcrl'];
24+
25+
/**
26+
* Installer options mapped onto the `pgsql_ssl` connection parameters, as read by
27+
* {@see \OC\DB\ConnectionFactory::createConnectionParams()}.
28+
*/
29+
private const array SSL_PARAMETERS = [
30+
'dbsslmode' => 'mode',
31+
'dbsslca' => 'rootcert',
32+
'dbsslcert' => 'cert',
33+
'dbsslkey' => 'key',
34+
'dbsslcrl' => 'crl',
35+
];
36+
37+
#[\Override]
38+
protected function getEncryptionConfig(array $config): array {
39+
$pgsqlSsl = [];
40+
foreach (self::SSL_PARAMETERS as $option => $parameter) {
41+
if (!empty($config[$option])) {
42+
$pgsqlSsl[$parameter] = (string)$config[$option];
43+
}
44+
}
45+
46+
return $pgsqlSsl === [] ? [] : ['pgsql_ssl' => $pgsqlSsl];
47+
}
48+
2249
/**
2350
* @throws DatabaseSetupException
2451
*/

lib/private/Setup/Sqlite.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ class Sqlite extends AbstractDatabase {
1515

1616
#[\Override]
1717
public function validate(array $config): array {
18-
return [];
18+
// SQLite needs no credentials, but an encrypted connection is not a thing either
19+
return $this->validateEncryptionOptions($config);
1920
}
2021

2122
#[\Override]

tests/lib/Setup/AbstractDatabaseTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,41 @@ public function testValidateRejectsMalformedEncryptionOptions(): void {
171171
], $errors);
172172
}
173173

174+
public static function encryptionOptions(): array {
175+
return [
176+
'dbsslmode' => ['dbsslmode', 'verify-full'],
177+
'dbsslca' => ['dbsslca', '/ca.pem'],
178+
'dbsslcert' => ['dbsslcert', '/client.crt'],
179+
'dbsslkey' => ['dbsslkey', '/client.key'],
180+
'dbsslcrl' => ['dbsslcrl', '/crl.pem'],
181+
'dbsslnoverify' => ['dbsslnoverify', true],
182+
];
183+
}
184+
185+
/**
186+
* A database that cannot be configured to use an encrypted connection has to reject
187+
* every such option instead of installing an unencrypted instance silently.
188+
*/
189+
#[\PHPUnit\Framework\Attributes\DataProvider('encryptionOptions')]
190+
public function testValidateRejectsUnsupportedEncryptionOptions(string $option, string|bool $value): void {
191+
$errors = $this->database->validate($this->options([$option => $value]));
192+
193+
$this->assertContains("The database option \"$option\" is not supported by Test", $errors);
194+
}
195+
196+
#[\PHPUnit\Framework\Attributes\DataProvider('encryptionOptions')]
197+
public function testInitializeSkipsUnsupportedEncryptionOptions(string $option, string|bool $value): void {
198+
$this->config->expects($this->once())
199+
->method('setValues')
200+
->with([
201+
'dbname' => 'nextcloud',
202+
'dbhost' => 'db.example.org',
203+
'dbtableprefix' => 'oc_',
204+
]);
205+
206+
$this->database->initialize($this->options([$option => $value]));
207+
}
208+
174209
public function testValidateAcceptsEncryptionOptions(): void {
175210
$errors = $this->database->validate($this->options([
176211
'dbdriveroptions' => [self::MYSQL_ATTR_SSL_CA => '/ca.pem'],

0 commit comments

Comments
 (0)