CertificateManager.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  6. * SPDX-License-Identifier: AGPL-3.0-only
  7. */
  8. namespace OC\Security;
  9. use OC\Files\Filesystem;
  10. use OC\Files\View;
  11. use OCP\ICertificate;
  12. use OCP\ICertificateManager;
  13. use OCP\IConfig;
  14. use OCP\Security\ISecureRandom;
  15. use Psr\Log\LoggerInterface;
  16. /**
  17. * Manage trusted certificates for users
  18. */
  19. class CertificateManager implements ICertificateManager {
  20. private ?string $bundlePath = null;
  21. public function __construct(
  22. protected View $view,
  23. protected IConfig $config,
  24. protected LoggerInterface $logger,
  25. protected ISecureRandom $random,
  26. ) {
  27. }
  28. /**
  29. * Returns all certificates trusted by the user
  30. *
  31. * @return \OCP\ICertificate[]
  32. */
  33. public function listCertificates(): array {
  34. if (!$this->config->getSystemValueBool('installed', false)) {
  35. return [];
  36. }
  37. $path = $this->getPathToCertificates() . 'uploads/';
  38. if (!$this->view->is_dir($path)) {
  39. return [];
  40. }
  41. $result = [];
  42. $handle = $this->view->opendir($path);
  43. if (!is_resource($handle)) {
  44. return [];
  45. }
  46. while (false !== ($file = readdir($handle))) {
  47. if ($file != '.' && $file != '..') {
  48. try {
  49. $content = $this->view->file_get_contents($path . $file);
  50. if ($content !== false) {
  51. $result[] = new Certificate($content, $file);
  52. } else {
  53. $this->logger->error("Failed to read certificate from $path");
  54. }
  55. } catch (\Exception $e) {
  56. $this->logger->error("Failed to read certificate from $path", ['exception' => $e]);
  57. }
  58. }
  59. }
  60. closedir($handle);
  61. return $result;
  62. }
  63. private function hasCertificates(): bool {
  64. if (!$this->config->getSystemValueBool('installed', false)) {
  65. return false;
  66. }
  67. $path = $this->getPathToCertificates() . 'uploads/';
  68. if (!$this->view->is_dir($path)) {
  69. return false;
  70. }
  71. $result = [];
  72. $handle = $this->view->opendir($path);
  73. if (!is_resource($handle)) {
  74. return false;
  75. }
  76. while (false !== ($file = readdir($handle))) {
  77. if ($file !== '.' && $file !== '..') {
  78. return true;
  79. }
  80. }
  81. closedir($handle);
  82. return false;
  83. }
  84. /**
  85. * create the certificate bundle of all trusted certificated
  86. */
  87. public function createCertificateBundle(): void {
  88. $path = $this->getPathToCertificates();
  89. $certs = $this->listCertificates();
  90. if (!$this->view->file_exists($path)) {
  91. $this->view->mkdir($path);
  92. }
  93. $defaultCertificates = file_get_contents(\OC::$SERVERROOT . '/resources/config/ca-bundle.crt');
  94. if (strlen($defaultCertificates) < 1024) { // sanity check to verify that we have some content for our bundle
  95. // log as exception so we have a stacktrace
  96. $e = new \Exception('Shipped ca-bundle is empty, refusing to create certificate bundle');
  97. $this->logger->error($e->getMessage(), ['exception' => $e]);
  98. return;
  99. }
  100. $certPath = $path . 'rootcerts.crt';
  101. $tmpPath = $certPath . '.tmp' . $this->random->generate(10, ISecureRandom::CHAR_DIGITS);
  102. $fhCerts = $this->view->fopen($tmpPath, 'w');
  103. if (!is_resource($fhCerts)) {
  104. throw new \RuntimeException('Unable to open file handler to create certificate bundle "' . $tmpPath . '".');
  105. }
  106. // Write user certificates
  107. foreach ($certs as $cert) {
  108. $file = $path . '/uploads/' . $cert->getName();
  109. $data = $this->view->file_get_contents($file);
  110. if (strpos($data, 'BEGIN CERTIFICATE')) {
  111. fwrite($fhCerts, $data);
  112. fwrite($fhCerts, "\r\n");
  113. }
  114. }
  115. // Append the default certificates
  116. fwrite($fhCerts, $defaultCertificates);
  117. // Append the system certificate bundle
  118. $systemBundle = $this->getCertificateBundle();
  119. if ($systemBundle !== $certPath && $this->view->file_exists($systemBundle)) {
  120. $systemCertificates = $this->view->file_get_contents($systemBundle);
  121. fwrite($fhCerts, $systemCertificates);
  122. }
  123. fclose($fhCerts);
  124. $this->view->rename($tmpPath, $certPath);
  125. }
  126. /**
  127. * Save the certificate and re-generate the certificate bundle
  128. *
  129. * @param string $certificate the certificate data
  130. * @param string $name the filename for the certificate
  131. * @throws \Exception If the certificate could not get added
  132. */
  133. public function addCertificate(string $certificate, string $name): ICertificate {
  134. if (!Filesystem::isValidPath($name) or Filesystem::isFileBlacklisted($name)) {
  135. throw new \Exception('Filename is not valid');
  136. }
  137. $this->bundlePath = null;
  138. $dir = $this->getPathToCertificates() . 'uploads/';
  139. if (!$this->view->file_exists($dir)) {
  140. $this->view->mkdir($dir);
  141. }
  142. try {
  143. $file = $dir . $name;
  144. $certificateObject = new Certificate($certificate, $name);
  145. $this->view->file_put_contents($file, $certificate);
  146. $this->createCertificateBundle();
  147. return $certificateObject;
  148. } catch (\Exception $e) {
  149. throw $e;
  150. }
  151. }
  152. /**
  153. * Remove the certificate and re-generate the certificate bundle
  154. */
  155. public function removeCertificate(string $name): bool {
  156. if (!Filesystem::isValidPath($name)) {
  157. return false;
  158. }
  159. $this->bundlePath = null;
  160. $path = $this->getPathToCertificates() . 'uploads/';
  161. if ($this->view->file_exists($path . $name)) {
  162. $this->view->unlink($path . $name);
  163. $this->createCertificateBundle();
  164. }
  165. return true;
  166. }
  167. /**
  168. * Get the path to the certificate bundle
  169. */
  170. public function getCertificateBundle(): string {
  171. return $this->getPathToCertificates() . 'rootcerts.crt';
  172. }
  173. /**
  174. * Get the full local path to the certificate bundle
  175. * @throws \Exception when getting bundle path fails
  176. */
  177. public function getAbsoluteBundlePath(): string {
  178. try {
  179. if ($this->bundlePath === null) {
  180. if (!$this->hasCertificates()) {
  181. $this->bundlePath = \OC::$SERVERROOT . '/resources/config/ca-bundle.crt';
  182. }
  183. if ($this->needsRebundling()) {
  184. $this->createCertificateBundle();
  185. }
  186. $certificateBundle = $this->getCertificateBundle();
  187. $this->bundlePath = $this->view->getLocalFile($certificateBundle) ?: null;
  188. if ($this->bundlePath === null) {
  189. throw new \RuntimeException('Unable to get certificate bundle "' . $certificateBundle . '".');
  190. }
  191. }
  192. return $this->bundlePath;
  193. } catch (\Exception $e) {
  194. $this->logger->error('Failed to get absolute bundle path. Fallback to default ca-bundle.crt', ['exception' => $e]);
  195. return \OC::$SERVERROOT . '/resources/config/ca-bundle.crt';
  196. }
  197. }
  198. private function getPathToCertificates(): string {
  199. return '/files_external/';
  200. }
  201. /**
  202. * Check if we need to re-bundle the certificates because one of the sources has updated
  203. */
  204. private function needsRebundling(): bool {
  205. $targetBundle = $this->getCertificateBundle();
  206. if (!$this->view->file_exists($targetBundle)) {
  207. return true;
  208. }
  209. $sourceMTime = $this->getFilemtimeOfCaBundle();
  210. return $sourceMTime > $this->view->filemtime($targetBundle);
  211. }
  212. /**
  213. * get mtime of ca-bundle shipped by Nextcloud
  214. */
  215. protected function getFilemtimeOfCaBundle(): int {
  216. return filemtime(\OC::$SERVERROOT . '/resources/config/ca-bundle.crt');
  217. }
  218. }