From b5e3b966a863fd6df3395656c5b5ee4e8709a1e8 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 13 May 2026 13:15:55 -0400 Subject: [PATCH 1/7] docs(security): clarify CertificateManager bundle lifecycle Signed-off-by: Josh --- lib/private/Security/CertificateManager.php | 65 ++++++++++++++++----- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/lib/private/Security/CertificateManager.php b/lib/private/Security/CertificateManager.php index ee9332e550fac..c68448c450a7a 100644 --- a/lib/private/Security/CertificateManager.php +++ b/lib/private/Security/CertificateManager.php @@ -16,7 +16,15 @@ use Psr\Log\LoggerInterface; /** - * Manage trusted certificates for users + * Manage trusted certificates and the effective CA bundle used by Nextcloud. + * + * Uploaded PEM certificates are merged with the shipped default CA bundle to + * produce the effective bundle consumed by HTTP clients and external storage + * integrations. + * + * The uploaded certificates and generated bundle are stored under the + * files_external path for historical reasons, maintaining compatibility + * with pre-existing deployments. */ class CertificateManager implements ICertificateManager { private ?string $bundlePath = null; @@ -30,7 +38,7 @@ public function __construct( } /** - * Returns all certificates trusted by the user + * Return the certificates stored in the internal upload area. * * @return ICertificate[] */ @@ -67,6 +75,9 @@ public function listCertificates(): array { return $result; } + /** + * Check whether any uploaded certificates are present. + */ private function hasCertificates(): bool { if (!$this->config->getSystemValueBool('installed', false)) { return false; @@ -91,9 +102,14 @@ private function hasCertificates(): bool { } /** - * create the certificate bundle of all trusted certificated + * Rebuild the generated effected certificate bundle from: + * - uploaded certificates + * - the shipped default CA bundle + * - the current system CA bundle, if present and different from the target + * + * The bundle is written atomically to /files_external/rootcerts.crt. */ - public function createCertificateBundle(): void { + private function createCertificateBundle(): void { $path = $this->getPathToCertificates(); $certs = $this->listCertificates(); @@ -143,11 +159,12 @@ public function createCertificateBundle(): void { } /** - * Save the certificate and re-generate the certificate bundle + * Store a certificate and regenerate the effective bundle. * - * @param string $certificate the certificate data - * @param string $name the filename for the certificate - * @throws \Exception If the certificate could not get added + * @param string $certificate Certificate data in PEM format + * @param string $name File name to store the certificate under + * @return ICertificate + * @throws \Exception If the certificate cannot be stored or the bundle cannot be rebuilt */ #[\Override] public function addCertificate(string $certificate, string $name): ICertificate { @@ -172,7 +189,10 @@ public function addCertificate(string $certificate, string $name): ICertificate } /** - * Remove the certificate and re-generate the certificate bundle + * Remove a stored certificate and regenerate the effective bundle. + * + * @param string $name File name of the certificate to remove + * @return bool False if the path is invalid, true otherwise */ #[\Override] public function removeCertificate(string $name): bool { @@ -193,7 +213,7 @@ public function removeCertificate(string $name): bool { } /** - * Get the path to the certificate bundle + * Get the relative path to the generated certificate bundle. */ #[\Override] public function getCertificateBundle(): string { @@ -201,8 +221,15 @@ public function getCertificateBundle(): string { } /** - * Get the full local path to the certificate bundle - * @throws \Exception when getting bundle path fails + * Get the local filesystem path to the effective certificate bundle. + * + * Returns the generated bundle when uploaded certificates exist, otherwise + * falls back to the shipped default CA bundle. + * + * If resolving the generated bundle fails, the default bundle is returned as + * a safe fallback. + * + * @throws \Exception If unable to retrieve/confirm the bundle path for any reason. */ #[\Override] public function getAbsoluteBundlePath(): string { @@ -230,12 +257,19 @@ public function getAbsoluteBundlePath(): string { } } + /** + * Get the base path used to store uploaded certificates and the generated bundle. + * + * Kept under the files_external namespace for compatibility with existing + * deployments. + */ private function getPathToCertificates(): string { return '/files_external/'; } /** - * Check if we need to re-bundle the certificates because one of the sources has updated + * Determine whether the generated bundle must be rebuilt because the source + * CA bundle has changed or the target bundle is missing. */ private function needsRebundling(): bool { $targetBundle = $this->getCertificateBundle(); @@ -248,12 +282,15 @@ private function needsRebundling(): bool { } /** - * get mtime of ca-bundle shipped by Nextcloud + * Return the modification time of the shipped default CA bundle. */ protected function getFilemtimeOfCaBundle(): int { return filemtime($this->getDefaultCertificatesBundlePath()); } + /** + * Return the configured path to the shipped default CA bundle. + */ #[\Override] public function getDefaultCertificatesBundlePath(): string { return $this->config->getSystemValueString('default_certificates_bundle_path', \OC::$SERVERROOT . '/resources/config/ca-bundle.crt'); From 27cc429bbe1e2c3d4b0c42259c2fd54a01a8f955 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 13 May 2026 13:41:26 -0400 Subject: [PATCH 2/7] docs(security): clarify ICertificateManager API and certificate handling Signed-off-by: Josh --- lib/public/ICertificateManager.php | 33 ++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/lib/public/ICertificateManager.php b/lib/public/ICertificateManager.php index be4afc799fa63..f8249b5a923e8 100644 --- a/lib/public/ICertificateManager.php +++ b/lib/public/ICertificateManager.php @@ -9,12 +9,20 @@ namespace OCP; /** - * Manage trusted certificates + * Manage trusted certificates and the effective CA bundle used by Nextcloud. + * + * Implementations provide access to uploaded trusted certificates and the + * generated bundle that is consumed by HTTP clients and external storage + * integrations. + * * @since 8.0.0 */ interface ICertificateManager { /** - * Returns all certificates trusted by the system + * Returns all uploaded trusted certificates. + * + * This does not include the shipped default CA bundle or any system CA bundle + * appended when building the effective bundle. * * @return \OCP\ICertificate[] * @since 8.0.0 @@ -22,23 +30,27 @@ interface ICertificateManager { public function listCertificates(): array; /** - * @param string $certificate the certificate data - * @param string $name the filename for the certificate + * Add a trusted certificate to the certificate store. + * + * @param string $certificate The certificate data in PEM format + * @param string $name The filename for the certificate * @return \OCP\ICertificate - * @throws \Exception If the certificate could not get added + * @throws \Exception If the certificate could not be added * @since 8.0.0 - since 8.1.0 throws exception instead of returning false */ public function addCertificate(string $certificate, string $name): \OCP\ICertificate; /** - * @param string $name + * Remove a trusted certificate from the certificate store. + * + * @param string $name The filename for the certificate * @return bool * @since 8.0.0 */ public function removeCertificate(string $name): bool; /** - * Get the path to the certificate bundle + * Get the relative path to the generated certificate bundle. * * @return string * @since 8.0.0 @@ -46,7 +58,10 @@ public function removeCertificate(string $name): bool; public function getCertificateBundle(): string; /** - * Get the full local path to the certificate bundle + * Get the full local path to the effective certificate bundle. + * + * Implementations should return the generated bundle path, but may log and fall back + * to the shipped default CA bundle if resolution fails. * * @return string * @since 9.0.0 @@ -54,7 +69,7 @@ public function getCertificateBundle(): string; public function getAbsoluteBundlePath(): string; /** - * Get the path of the default certificates bundle. + * Get the path of the shipped default certificates bundle. * * @since 33.0.0 */ From 0a192908d634434dea2b218aadbc83ebca9d4a24 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 18 May 2026 09:07:35 -0400 Subject: [PATCH 3/7] chore(CertificateManager): drop docs for methods covered in interface Signed-off-by: Josh --- lib/private/Security/CertificateManager.php | 54 ++------------------- 1 file changed, 4 insertions(+), 50 deletions(-) diff --git a/lib/private/Security/CertificateManager.php b/lib/private/Security/CertificateManager.php index c68448c450a7a..925cf956aa70e 100644 --- a/lib/private/Security/CertificateManager.php +++ b/lib/private/Security/CertificateManager.php @@ -15,17 +15,6 @@ use OCP\Security\ISecureRandom; use Psr\Log\LoggerInterface; -/** - * Manage trusted certificates and the effective CA bundle used by Nextcloud. - * - * Uploaded PEM certificates are merged with the shipped default CA bundle to - * produce the effective bundle consumed by HTTP clients and external storage - * integrations. - * - * The uploaded certificates and generated bundle are stored under the - * files_external path for historical reasons, maintaining compatibility - * with pre-existing deployments. - */ class CertificateManager implements ICertificateManager { private ?string $bundlePath = null; @@ -37,11 +26,6 @@ public function __construct( ) { } - /** - * Return the certificates stored in the internal upload area. - * - * @return ICertificate[] - */ #[\Override] public function listCertificates(): array { if (!$this->config->getSystemValueBool('installed', false)) { @@ -158,14 +142,6 @@ private function createCertificateBundle(): void { $this->view->rename($tmpPath, $certPath); } - /** - * Store a certificate and regenerate the effective bundle. - * - * @param string $certificate Certificate data in PEM format - * @param string $name File name to store the certificate under - * @return ICertificate - * @throws \Exception If the certificate cannot be stored or the bundle cannot be rebuilt - */ #[\Override] public function addCertificate(string $certificate, string $name): ICertificate { $path = $this->getPathToCertificates() . 'uploads/' . $name; @@ -188,12 +164,6 @@ public function addCertificate(string $certificate, string $name): ICertificate } } - /** - * Remove a stored certificate and regenerate the effective bundle. - * - * @param string $name File name of the certificate to remove - * @return bool False if the path is invalid, true otherwise - */ #[\Override] public function removeCertificate(string $name): bool { $path = $this->getPathToCertificates() . 'uploads/' . $name; @@ -212,25 +182,11 @@ public function removeCertificate(string $name): bool { return true; } - /** - * Get the relative path to the generated certificate bundle. - */ #[\Override] public function getCertificateBundle(): string { return $this->getPathToCertificates() . 'rootcerts.crt'; } - /** - * Get the local filesystem path to the effective certificate bundle. - * - * Returns the generated bundle when uploaded certificates exist, otherwise - * falls back to the shipped default CA bundle. - * - * If resolving the generated bundle fails, the default bundle is returned as - * a safe fallback. - * - * @throws \Exception If unable to retrieve/confirm the bundle path for any reason. - */ #[\Override] public function getAbsoluteBundlePath(): string { try { @@ -260,9 +216,10 @@ public function getAbsoluteBundlePath(): string { /** * Get the base path used to store uploaded certificates and the generated bundle. * - * Kept under the files_external namespace for compatibility with existing - * deployments. - */ + * The uploaded certificates and generated bundle are stored under the + * files_external path for historical reasons, maintaining compatibility + * with pre-existing deployments. + */ private function getPathToCertificates(): string { return '/files_external/'; } @@ -288,9 +245,6 @@ protected function getFilemtimeOfCaBundle(): int { return filemtime($this->getDefaultCertificatesBundlePath()); } - /** - * Return the configured path to the shipped default CA bundle. - */ #[\Override] public function getDefaultCertificatesBundlePath(): string { return $this->config->getSystemValueString('default_certificates_bundle_path', \OC::$SERVERROOT . '/resources/config/ca-bundle.crt'); From 6a2d39244f7b84c53d7111f6d2e102d3c3d44868 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 18 May 2026 12:45:44 -0400 Subject: [PATCH 4/7] chore(CertificateManager): fixup for lint Signed-off-by: Josh --- lib/private/Security/CertificateManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/Security/CertificateManager.php b/lib/private/Security/CertificateManager.php index 925cf956aa70e..789e81ff1bc20 100644 --- a/lib/private/Security/CertificateManager.php +++ b/lib/private/Security/CertificateManager.php @@ -219,7 +219,7 @@ public function getAbsoluteBundlePath(): string { * The uploaded certificates and generated bundle are stored under the * files_external path for historical reasons, maintaining compatibility * with pre-existing deployments. - */ + */ private function getPathToCertificates(): string { return '/files_external/'; } From 64aedd4ed3b6de9569da57810a319e3f80446e0e Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 28 Jul 2026 09:52:26 -0400 Subject: [PATCH 5/7] fix(security): clean up CertificateManager bundle handling Remove the obsolete system-wide bundle append left over from the former per-user certificate-bundle model. Since #21693 consolidated certificate handling to a single Nextcloud-wide bundle, the append condition can no longer be satisfied. Clarify the implementation terminology around uploaded certificates and the configured default CA bundle, and remove redundant exception handling in addCertificate(). Also close directory handles on all paths and use str_contains() when checking uploaded certificate contents so a PEM header at offset zero is handled correctly. Signed-off-by: Josh --- lib/private/Security/CertificateManager.php | 44 ++++++++------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/lib/private/Security/CertificateManager.php b/lib/private/Security/CertificateManager.php index 789e81ff1bc20..84054d9045bb1 100644 --- a/lib/private/Security/CertificateManager.php +++ b/lib/private/Security/CertificateManager.php @@ -71,13 +71,14 @@ private function hasCertificates(): bool { if (!$this->view->is_dir($path)) { return false; } - $result = []; + $handle = $this->view->opendir($path); if (!is_resource($handle)) { return false; } while (false !== ($file = readdir($handle))) { if ($file !== '.' && $file !== '..') { + closedir($handle); return true; } } @@ -86,10 +87,9 @@ private function hasCertificates(): bool { } /** - * Rebuild the generated effected certificate bundle from: + * Rebuild the generated effective certificate bundle from: * - uploaded certificates - * - the shipped default CA bundle - * - the current system CA bundle, if present and different from the target + * - the configured default CA bundle (defaults to Nextcloud's shipped CA bundle) * * The bundle is written atomically to /files_external/rootcerts.crt. */ @@ -102,14 +102,13 @@ private function createCertificateBundle(): void { } $defaultCertificates = file_get_contents($this->getDefaultCertificatesBundlePath()); - if (strlen($defaultCertificates) < 1024) { // sanity check to verify that we have some content for our bundle - // log as exception so we have a stacktrace - $e = new \Exception('Shipped ca-bundle is empty, refusing to create certificate bundle'); + if (!is_string($defaultCertificates) || strlen($defaultCertificates) < 1024) { + $e = new \Exception('Configured CA bundle is empty or unreadable, refusing to create certificate bundle'); $this->logger->error($e->getMessage(), ['exception' => $e]); return; } - $certPath = $path . 'rootcerts.crt'; + $certPath = $this->getCertificateBundle(); $tmpPath = $certPath . '.tmp' . $this->random->generate(10, ISecureRandom::CHAR_DIGITS); $fhCerts = $this->view->fopen($tmpPath, 'w'); @@ -117,26 +116,19 @@ private function createCertificateBundle(): void { throw new \RuntimeException('Unable to open file handler to create certificate bundle "' . $tmpPath . '".'); } - // Write user certificates + // Write uploaded certificates. foreach ($certs as $cert) { - $file = $path . '/uploads/' . $cert->getName(); + $file = $path . 'uploads/' . $cert->getName(); $data = $this->view->file_get_contents($file); - if (strpos($data, 'BEGIN CERTIFICATE')) { + if (is_string($data) && str_contains($data, 'BEGIN CERTIFICATE')) { fwrite($fhCerts, $data); fwrite($fhCerts, "\r\n"); } } - // Append the default certificates + // Append the configured default CA bundle. fwrite($fhCerts, $defaultCertificates); - // Append the system certificate bundle - $systemBundle = $this->getCertificateBundle(); - if ($systemBundle !== $certPath && $this->view->file_exists($systemBundle)) { - $systemCertificates = $this->view->file_get_contents($systemBundle); - fwrite($fhCerts, $systemCertificates); - } - fclose($fhCerts); $this->view->rename($tmpPath, $certPath); @@ -154,14 +146,10 @@ public function addCertificate(string $certificate, string $name): ICertificate $this->view->mkdir($directory); } - try { - $certificateObject = new Certificate($certificate, $name); - $this->view->file_put_contents($path, $certificate); - $this->createCertificateBundle(); - return $certificateObject; - } catch (\Exception $e) { - throw $e; - } + $certificateObject = new Certificate($certificate, $name); + $this->view->file_put_contents($path, $certificate); + $this->createCertificateBundle(); + return $certificateObject; } #[\Override] @@ -239,7 +227,7 @@ private function needsRebundling(): bool { } /** - * Return the modification time of the shipped default CA bundle. + * Return the modification time of the configured default CA bundle. */ protected function getFilemtimeOfCaBundle(): int { return filemtime($this->getDefaultCertificatesBundlePath()); From bc6d0316109c51b22593ebdcdf3a4e22a30e081c Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 28 Jul 2026 10:04:14 -0400 Subject: [PATCH 6/7] docs(ocp): clarify ICertificateManager contract Document the distinction between uploaded trusted certificates, the generated certificate bundle, and the configured default CA bundle. Clarify that listCertificates() returns only uploaded certificates, getCertificateBundle() returns a virtual filesystem path, and getAbsoluteBundlePath() may fall back to the configured default CA bundle. Remove the obsolete reference to a separate system bundle being appended. Signed-off-by: Josh --- lib/public/ICertificateManager.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/public/ICertificateManager.php b/lib/public/ICertificateManager.php index f8249b5a923e8..c90e2f434656f 100644 --- a/lib/public/ICertificateManager.php +++ b/lib/public/ICertificateManager.php @@ -9,11 +9,10 @@ namespace OCP; /** - * Manage trusted certificates and the effective CA bundle used by Nextcloud. + * Manage uploaded trusted certificates and the CA bundle used by Nextcloud. * * Implementations provide access to uploaded trusted certificates and the - * generated bundle that is consumed by HTTP clients and external storage - * integrations. + * certificate bundle consumed by HTTP clients and external storage integrations. * * @since 8.0.0 */ @@ -21,8 +20,7 @@ interface ICertificateManager { /** * Returns all uploaded trusted certificates. * - * This does not include the shipped default CA bundle or any system CA bundle - * appended when building the effective bundle. + * This does not include certificates in the configured default CA bundle. * * @return \OCP\ICertificate[] * @since 8.0.0 @@ -32,7 +30,7 @@ public function listCertificates(): array; /** * Add a trusted certificate to the certificate store. * - * @param string $certificate The certificate data in PEM format + * @param string $certificate The certificate data * @param string $name The filename for the certificate * @return \OCP\ICertificate * @throws \Exception If the certificate could not be added @@ -50,7 +48,7 @@ public function addCertificate(string $certificate, string $name): \OCP\ICertifi public function removeCertificate(string $name): bool; /** - * Get the relative path to the generated certificate bundle. + * Get the virtual filesystem path to the generated certificate bundle. * * @return string * @since 8.0.0 @@ -58,10 +56,10 @@ public function removeCertificate(string $name): bool; public function getCertificateBundle(): string; /** - * Get the full local path to the effective certificate bundle. + * Get the full local path to the certificate bundle used by Nextcloud. * - * Implementations should return the generated bundle path, but may log and fall back - * to the shipped default CA bundle if resolution fails. + * If no uploaded certificates are configured, or if resolving the generated + * bundle fails, this falls back to returning the configured default CA bundle path. * * @return string * @since 9.0.0 @@ -69,7 +67,9 @@ public function getCertificateBundle(): string; public function getAbsoluteBundlePath(): string; /** - * Get the path of the shipped default certificates bundle. + * Get the path to the configured default CA bundle. + * + * By default, this is Nextcloud's shipped CA bundle. * * @since 33.0.0 */ From 372ae2d90fd85a97860ded5d5159adc64b4a48bc Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 28 Jul 2026 10:31:31 -0400 Subject: [PATCH 7/7] docs(config): clarify default CA bundle override Document that default_certificates_bundle_path overrides Nextcloud's shipped CA bundle used to verify TLS certificates. Clarify that the configured value must reference a readable local CA bundle, is used directly when no certificates are uploaded, and is included when Nextcloud generates the bundle containing uploaded certificates. Simplify the default value and use the Debian-derived system CA bundle path as the configuration-sample example for further clarity. Signed-off-by: Josh --- config/config.sample.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/config/config.sample.php b/config/config.sample.php index b645121a59a4f..bdc4759a6f2c5 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -2980,13 +2980,19 @@ 'enable_lazy_objects' => true, /** - * Change the default certificates bundle used for trusting certificates. + * Override the default CA bundle used by Nextcloud to verify TLS certificates. * - * Nextcloud ships its own up-to-date certificates bundle, but in certain cases admins may wish to specify a different bundle, for example the one shipped by their distro. + * By default, Nextcloud uses its shipped CA bundle. You may instead configure a + * bundle provided by your operating-system distribution or organization, such as + * ``/etc/ssl/certs/ca-certificates.crt`` on Debian-derived systems. * - * Defaults to `\OC::$SERVERROOT . '/resources/config/ca-bundle.crt'`. + * The value must be the path to a readable local CA-bundle file. Nextcloud uses + * this bundle directly when no certificates have been uploaded, and includes it + * when generating a bundle containing uploaded certificates. + * + * Defaults to ``resources/config/ca-bundle.crt`` inside the Nextcloud installation directory. */ - 'default_certificates_bundle_path' => \OC::$SERVERROOT . '/resources/config/ca-bundle.crt', + 'default_certificates_bundle_path' => '/etc/ssl/certs/ca-certificates.crt', /** * OpenMetrics skipped exporters