diff --git a/.gitignore b/.gitignore index e2d8da0ba8..ccdf67ab22 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ node_modules/ .phpunit.result.cache *.phar /src/__test__/coverage +/appinfo/install-*.json diff --git a/Makefile b/Makefile index ef39f31655..51bd8998a4 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,11 @@ appstore_sign_dir=$(appstore_build_directory)/sign cert_dir=$(build_tools_directory)/certificates npm=$(shell which npm 2> /dev/null) composer=$(shell which composer 2> /dev/null) +ifeq (,$(shell type occ)) + occ="php ../../occ" +else + occ="occ" +endif all: dev-setup build-js-production serve: dev-setup watch-js @@ -82,49 +87,7 @@ updateocp: # Builds the source package for the app store, ignores php and js tests .PHONY: appstore -appstore: - rm -rf $(appstore_build_directory) - mkdir -p $(appstore_sign_dir)/$(app_name) - cp -r \ - appinfo \ - composer \ - img \ - js \ - l10n \ - lib \ - templates \ - vendor \ - CHANGELOG.md \ - LICENSE \ - $(appstore_sign_dir)/$(app_name) - - rm $(appstore_sign_dir)/$(app_name)/vendor/endroid/qr-code/assets/* - mkdir -p $(appstore_sign_dir)/$(app_name)/tests/fixtures - cp tests/fixtures/small_valid.pdf $(appstore_sign_dir)/$(app_name)/tests/fixtures - - # Remove stray .htaccess files since they are filtered by Nextcloud - find $(appstore_sign_dir) -name .htaccess -exec rm {} \; - - @if [ -f $(cert_dir)/$(app_name).key ]; then \ - echo "Signing app files…"; \ - php ../../occ integrity:sign-app \ - --privateKey=$(cert_dir)/$(app_name).key\ - --certificate=$(cert_dir)/$(app_name).crt\ - --path=$(appstore_sign_dir)/$(app_name); \ - fi - tar -czf $(appstore_package_name).tar.gz \ - -C $(appstore_sign_dir) $(app_name) - - @if [ -f $(cert_dir)/$(app_name).key ]; then \ - echo "Signing package…"; \ - openssl dgst -sha512 -sign $(cert_dir)/$(app_name).key $(build_dir)/$(app_name).tar.gz | openssl base64; \ - fi - -# Earlier version of appstore command that builds the app and has some custom -# support for local signing. Left here in case it's needed by some developer -# used to it. -.PHONY: appstore-local -appstore-local: clean +appstore: clean mkdir -p $(appstore_sign_dir)/$(app_name) composer install --no-dev npm ci @@ -141,12 +104,19 @@ appstore-local: clean CHANGELOG.md \ LICENSE \ $(appstore_sign_dir)/$(app_name) + rm $(appstore_sign_dir)/$(app_name)/vendor/endroid/qr-code/assets/* find $(appstore_sign_dir)/$(app_name)/vendor/mpdf/mpdf/ttfonts -type f -not -name 'DejaVuSerifCondensed.ttf' -delete find $(appstore_sign_dir)/$(app_name)/vendor/mpdf/mpdf/data/ -type f -delete rm -rf $(appstore_sign_dir)/$(app_name)/img/screenshot/ mkdir -p $(appstore_sign_dir)/$(app_name)/tests/fixtures - cp tests/fixtures/small_valid.pdf $(appstore_sign_dir)/$(app_name)/tests/fixtures \ + cp tests/fixtures/small_valid.pdf $(appstore_sign_dir)/$(app_name)/tests/fixtures + + $(occ) config:app:set libresign certificate_engine --value cfssl + $(occ) libresign:install --all + $(occ) libresign:install --all --architecture aarch64 + $(occ) libresign:developer:sign-setup --privateKey=$(cert_dir)/$(app_name).key \ + --certificate=$(cert_dir)/$(app_name).crt @if [ -z "$$GITHUB_ACTION" ]; then \ chown -R www-data:www-data $(appstore_sign_dir)/$(app_name) ; \ @@ -157,18 +127,17 @@ appstore-local: clean curl -o $(cert_dir)/$(app_name).crt \ "https://github.com/nextcloud/app-certificate-requests/raw/master/$(app_name)/$(app_name).crt"; \ fi - @if [ -n "$$APP_PRIVATE_KEY" ]; then \ - echo "$$APP_PRIVATE_KEY" > $(cert_dir)/$(app_name).key; \ + @if [ -f $(cert_dir)/$(app_name).key ]; then \ echo "Signing app files…"; \ - runuser -u www-data -- \ - php ../../occ integrity:sign-app \ + $(occ) integrity:sign-app \ --privateKey=$(cert_dir)/$(app_name).key\ --certificate=$(cert_dir)/$(app_name).crt\ --path=$(appstore_sign_dir)/$(app_name); \ - echo "Signing app files ... done"; \ fi - tar -czf $(appstore_package_name).tar.gz -C $(appstore_sign_dir) $(app_name) - @if [ -n "$$APP_PRIVATE_KEY" ]; then \ + tar -czf $(appstore_package_name).tar.gz \ + -C $(appstore_sign_dir) $(app_name) + + @if [ -f $(cert_dir)/$(app_name).key ]; then \ echo "Signing package…"; \ openssl dgst -sha512 -sign $(cert_dir)/$(app_name).key $(appstore_package_name).tar.gz | openssl base64; \ fi diff --git a/appinfo/info.xml b/appinfo/info.xml index 99e697f238..82bf0796a8 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -47,11 +47,17 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform x86_64 aarch64 + + + OCA\Libresign\Migration\DeleteOldBinaries + + OCA\Libresign\Command\Configure\Check OCA\Libresign\Command\Configure\Cfssl OCA\Libresign\Command\Configure\OpenSsl OCA\Libresign\Command\Developer\Reset + OCA\Libresign\Command\Developer\SignSetup OCA\Libresign\Command\Install OCA\Libresign\Command\Uninstall diff --git a/lib/Command/Developer/SignSetup.php b/lib/Command/Developer/SignSetup.php new file mode 100644 index 0000000000..31606b870e --- /dev/null +++ b/lib/Command/Developer/SignSetup.php @@ -0,0 +1,80 @@ +config->getSystemValue('debug', false) === true; + } + + protected function configure(): void { + $this + ->setName('libresign:developer:sign-setup') + ->setDescription('Clean all LibreSign data') + ->addOption('privateKey', null, InputOption::VALUE_REQUIRED, 'Path to private key to use for signing') + ->addOption('certificate', null, InputOption::VALUE_REQUIRED, 'Path to certificate to use for signing') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $privateKeyPath = $input->getOption('privateKey'); + $keyBundlePath = $input->getOption('certificate'); + if (is_null($privateKeyPath) || is_null($keyBundlePath)) { + $output->writeln('This command requires the --path, --privateKey and --certificate.'); + $output->writeln('Example: ./occ libresign:developer:sign-setup --privateKey="/libresign/private/myapp.key" --certificate="/libresign/public/mycert.crt"'); + return 1; + } + + $privateKey = $this->fileAccessHelper->file_get_contents((string) $privateKeyPath); + $keyBundle = $this->fileAccessHelper->file_get_contents((string) $keyBundlePath); + if ($privateKey === false) { + $output->writeln(sprintf('Private key "%s" does not exists.', $privateKeyPath)); + return 1; + } + + if ($keyBundle === false) { + $output->writeln(sprintf('Certificate "%s" does not exists.', $keyBundlePath)); + return 1; + } + + $rsa = new RSA(); + $rsa->loadKey($privateKey); + $x509 = new X509(); + $x509->loadX509($keyBundle); + $x509->setPrivateKey($rsa); + try { + foreach ($this->signSetupService->getArchitectures() as $architecture) { + $this->signSetupService->writeAppSignature($x509, $rsa, $architecture); + } + $output->writeln('Successfully signed'); + } catch (\Exception $e) { + $output->writeln('Error: ' . $e->getMessage()); + return 1; + } + return 0; + } +} diff --git a/lib/Command/Install.php b/lib/Command/Install.php index 00901807be..0a8cc47f54 100644 --- a/lib/Command/Install.php +++ b/lib/Command/Install.php @@ -62,6 +62,12 @@ protected function configure(): void { shortcut: null, mode: InputOption::VALUE_NONE, description: 'Java' + ) + ->addOption( + name: 'architecture', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'x86_64 or aarch64' ); } @@ -70,6 +76,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->installService->setOutput($output); try { + $architecture = (string) $input->getOption('architecture'); + if (in_array($architecture, ['x86_64', 'aarch64'])) { + $this->installService->setArchitecture($architecture); + } $all = $input->getOption('all'); if ($input->getOption('java') || $all) { $this->installService->installJava(); diff --git a/lib/Command/Uninstall.php b/lib/Command/Uninstall.php index e3368db651..e4a6330a68 100644 --- a/lib/Command/Uninstall.php +++ b/lib/Command/Uninstall.php @@ -62,6 +62,12 @@ protected function configure(): void { shortcut: null, mode: InputOption::VALUE_NONE, description: 'Java' + ) + ->addOption( + name: 'architecture', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'x86_64 or aarch64' ); } @@ -69,6 +75,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $ok = false; try { + $architecture = (string) $input->getOption('architecture'); + if (in_array($architecture, ['x86_64', 'aarch64'])) { + $this->installService->setArchitecture($architecture); + } $all = $input->getOption('all'); if ($input->getOption('java') || $all) { $this->installService->uninstallJava(); diff --git a/lib/Exception/InvalidSignatureException.php b/lib/Exception/InvalidSignatureException.php new file mode 100644 index 0000000000..cb51f6ada2 --- /dev/null +++ b/lib/Exception/InvalidSignatureException.php @@ -0,0 +1,12 @@ +setResource('cfssl'), ]; } - $cfsslInstalled = $this->appConfig->getAppValue('cfssl_bin'); - if (!$cfsslInstalled) { + $binary = $this->appConfig->getAppValue('cfssl_bin'); + if (!$binary) { return [ (new ConfigureCheckHelper()) ->setErrorMessage('CFSSL not installed.') @@ -384,11 +383,6 @@ private function checkBinaries(): array { ]; } - $instanceId = $this->systemConfig->getValue('instanceid', null); - $binary = $this->systemConfig->getValue('datadirectory', \OC::$SERVERROOT . '/data/') . DIRECTORY_SEPARATOR . - 'appdata_' . $instanceId . DIRECTORY_SEPARATOR . - Application::APP_ID . DIRECTORY_SEPARATOR . - 'cfssl'; if (!file_exists($binary)) { return [ (new ConfigureCheckHelper()) diff --git a/lib/Migration/DeleteOldBinaries.php b/lib/Migration/DeleteOldBinaries.php new file mode 100644 index 0000000000..4d1ec668a1 --- /dev/null +++ b/lib/Migration/DeleteOldBinaries.php @@ -0,0 +1,58 @@ +appData = $appDataFactory->get('libresign'); + } + + public function getName(): string { + return 'Delete old binaries.'; + } + + public function run(IOutput $output): void { + $output->warning('Run the follow command first: files:scan-app-data libresign'); + $this->output = $output; + $folder = $this->appData->getFolder('/'); + + $list = $this->getDirectoryListing($folder); + foreach ($list as $file) { + if (!in_array($file->getName(), $this->allowedFiles)) { + $file->delete(); + } + } + } + + private function getDirectoryListing(ISimpleFolder $node): array { + $reflection = new \ReflectionClass($node); + $reflectionProperty = $reflection->getProperty('folder'); + $reflectionProperty->setAccessible(true); + $folder = $reflectionProperty->getValue($node); + $list = $folder->getDirectoryListing(); + return $list; + } +} diff --git a/lib/Service/Install/ConfigureCheckService.php b/lib/Service/Install/ConfigureCheckService.php index 7c26f0f081..d8b0452204 100644 --- a/lib/Service/Install/ConfigureCheckService.php +++ b/lib/Service/Install/ConfigureCheckService.php @@ -31,12 +31,15 @@ use OCP\AppFramework\Services\IAppConfig; class ConfigureCheckService { + private string $architecture; public function __construct( private IAppConfig $appConfig, private SystemConfig $systemConfig, private JSignPdfHandler $jSignPdfHandler, - private CertificateEngine $certificateEngine + private CertificateEngine $certificateEngine, + private SignSetupService $signSetupService, ) { + $this->architecture = php_uname('m'); } /** @@ -72,6 +75,16 @@ public function checkSign(): array { public function checkJSignPdf(): array { $jsignpdJarPath = $this->appConfig->getAppValue('jsignpdf_jar_path'); if ($jsignpdJarPath) { + if (count($this->signSetupService->verify($this->architecture, 'jsignpdf'))) { + return [ + (new ConfigureCheckHelper()) + ->setErrorMessage( + 'Invalid hash of binaries files.' + ) + ->setResource('jsignpdf') + ->setTip('Run occ libresign:install --all'), + ]; + } if (file_exists($jsignpdJarPath)) { if (!$this->isJavaOk()) { return [ @@ -132,18 +145,36 @@ public function checkJSignPdf(): array { public function checkPdftk(): array { $pdftkPath = $this->appConfig->getAppValue('pdftk_path'); if ($pdftkPath) { + if (count($this->signSetupService->verify($this->architecture, 'pdftk'))) { + return [ + (new ConfigureCheckHelper()) + ->setErrorMessage( + 'Invalid hash of binaries files.' + ) + ->setResource('pdftk') + ->setTip('Run occ libresign:install --all'), + ]; + } if (file_exists($pdftkPath)) { - $javaPath = $this->appConfig->getAppValue('java_path'); - if (empty($javaPath)) { + if (!$this->isJavaOk()) { return [ (new ConfigureCheckHelper()) - ->setErrorMessage('Java is necessary to run pdftk') - ->setResource('pdftk') + ->setErrorMessage('Necessary Java to run PDFtk') + ->setResource('jsignpdf') ->setTip('Run occ libresign:install --java'), ]; } + $javaPath = $this->appConfig->getAppValue('java_path'); $version = []; - \exec($javaPath . ' -jar ' . $pdftkPath . " --version 2>&1", $version); + \exec($javaPath . ' -jar ' . $pdftkPath . " --version 2>&1", $version, $resultCode); + if ($resultCode !== 0) { + return [ + (new ConfigureCheckHelper()) + ->setErrorMessage('Failure to check PDFtk version.') + ->setResource('java') + ->setTip('Run occ libresign:install --pdftk'), + ]; + } if (isset($version[0])) { preg_match('/pdftk port to java (?.*) a Handy Tool/', $version[0], $matches); if (isset($matches['version'])) { @@ -193,8 +224,18 @@ public function checkPdftk(): array { private function checkJava(): array { $javaPath = $this->appConfig->getAppValue('java_path'); if ($javaPath) { + if (count($this->signSetupService->verify($this->architecture, 'java'))) { + return [ + (new ConfigureCheckHelper()) + ->setErrorMessage( + 'Invalid hash of binaries files.' + ) + ->setResource('java') + ->setTip('Run occ libresign:install --all'), + ]; + } if (file_exists($javaPath)) { - \exec($javaPath . " -version 2>&1", $javaVersion); + \exec($javaPath . " -version 2>&1", $javaVersion, $resultCode); if (empty($javaVersion)) { return [ (new ConfigureCheckHelper()) @@ -205,6 +246,14 @@ private function checkJava(): array { ->setTip('https://github.com/LibreSign/libresign/issues/2327#issuecomment-1961988790'), ]; } + if ($resultCode !== 0) { + return [ + (new ConfigureCheckHelper()) + ->setErrorMessage('Failure to check Java version.') + ->setResource('java') + ->setTip('Run occ libresign:install --java'), + ]; + } $javaVersion = current($javaVersion); if ($javaVersion !== InstallService::JAVA_VERSION) { return [ @@ -236,17 +285,6 @@ private function checkJava(): array { ->setTip('Run occ libresign:install --java'), ]; } - \exec("java -version 2>&1", $javaVersion); - $javaVersion = current($javaVersion); - $hasJavaVersion = strpos($javaVersion, 'not found') === false; - if ($hasJavaVersion) { - return [ - (new ConfigureCheckHelper()) - ->setSuccessMessage('Using java from operational system. Version: ' . $javaVersion) - ->setResource('java') - ->setTip('Run occ libresign:install --java'), - ]; - } return [ (new ConfigureCheckHelper()) ->setErrorMessage('Java not installed') diff --git a/lib/Service/Install/InstallService.php b/lib/Service/Install/InstallService.php index 7979e2f9fa..35e621a4bc 100644 --- a/lib/Service/Install/InstallService.php +++ b/lib/Service/Install/InstallService.php @@ -30,6 +30,7 @@ use OC\Archive\ZIP; use OC\Memcache\NullCache; use OCA\Libresign\AppInfo\Application; +use OCA\Libresign\Exception\InvalidSignatureException; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\AEngineHandler; use OCA\Libresign\Handler\CertificateEngine\CfsslHandler; @@ -57,6 +58,7 @@ class InstallService { public const JAVA_VERSION = 'openjdk version "21.0.2" 2024-01-16 LTS'; private const JAVA_PARTIAL_VERSION = '21.0.2_13'; + private const JAVA_URL_PATH_NAME = '21.0.2+13'; public const PDFTK_VERSION = '3.3.3'; /** * When update, verify the hash of all architectures @@ -75,6 +77,7 @@ class InstallService { 'pdftk', 'cfssl' ]; + private string $architecture; public function __construct( ICacheFactory $cacheFactory, @@ -84,37 +87,61 @@ public function __construct( private IAppConfig $appConfig, private IRootFolder $rootFolder, private LoggerInterface $logger, + private SignSetupService $signSetupService, protected IAppDataFactory $appDataFactory, ) { $this->cache = $cacheFactory->createDistributed('libresign-setup'); $this->appData = $appDataFactory->get('libresign'); + $this->setArchitecture(php_uname('m')); } public function setOutput(OutputInterface $output): void { $this->output = $output; } - private function getFolder(string $path = ''): ISimpleFolder { - $folder = $this->appData->getFolder('/'); - if ($path) { + public function setArchitecture(string $architecture): void { + $this->architecture = $architecture; + } + + private function getFolder(string $path = '', ?ISimpleFolder $folder = null, $needToBeEmpty = false): ISimpleFolder { + if (!$folder) { + $folder = $this->appData->getFolder('/'); + if (!$path) { + $path = $this->architecture; + } else { + $path = $this->architecture . '/' . $path; + } + $path = explode('/', $path); + foreach ($path as $snippet) { + $folder = $this->getFolder($snippet, $folder); + } + return $folder; + } + try { + $folder = $folder->getFolder($path, $folder); + if ($needToBeEmpty) { + $folder->delete(); + throw new \Exception('Need to be empty'); + } + } catch (\Throwable $th) { try { - $folder = $folder->getFolder($path); - } catch (\Throwable $th) { - try { - $folder = $folder->newFolder($path); - } catch (NotPermittedException $e) { - $user = posix_getpwuid(posix_getuid()); - throw new LibresignException( - $e->getMessage() . '. ' . - 'Permission problems. ' . - 'Maybe this could fix: chown -R ' . $user['name'] . ' ' . $this->getDataDir() - ); - } + $folder = $folder->newFolder($path); + } catch (NotPermittedException $e) { + $user = posix_getpwuid(posix_getuid()); + throw new LibresignException( + $e->getMessage() . '. ' . + 'Permission problems. ' . + 'Maybe this could fix: chown -R ' . $user['name'] . ' ' . $this->getDataDir() + ); } } return $folder; } + private function getEmptyFolder(string $path): ISimpleFolder { + return $this->getFolder($path, null, true); + } + /** * @todo check a best solution to don't use reflection */ @@ -136,7 +163,7 @@ private function getInternalPathOfFile(ISimpleFile $node): string { $reflectionProperty = $reflection->getProperty('parentFolder'); $reflectionProperty->setAccessible(true); $folder = $reflectionProperty->getValue($node); - $path = $folder->getInternalPath() . DIRECTORY_SEPARATOR . $node->getName(); + $path = $folder->getInternalPath() . '/' . $node->getName(); } elseif ($reflection->hasProperty('file')) { $reflectionProperty = $reflection->getProperty('file'); $reflectionProperty->setAccessible(true); @@ -151,7 +178,7 @@ private function getDataDir(): string { return $dataDir; } - public function getFullPath(): string { + private function getFullPath(): string { $folder = $this->getFolder(); return $this->getDataDir() . '/' . $this->getInternalPathOfFolder($folder); } @@ -326,14 +353,25 @@ public function setResource(string $resource): self { return $this; } + public function isDownloadedFilesOk(): bool { + try { + return count($this->signSetupService->verify($this->architecture, $this->resource)) === 0; + } catch (InvalidSignatureException $e) { + return false; + } + } + public function installJava(?bool $async = false): void { $this->setResource('java'); + if ($this->isDownloadedFilesOk()) { + return; + } if ($async) { $this->runAsync(); return; } - $extractDir = $this->getFullPath() . DIRECTORY_SEPARATOR . 'java'; - $javaFolder = $this->getFolder('java'); + $folder = $this->getEmptyFolder($this->resource); + $extractDir = $this->getFullPath() . '/' . $this->resource; /** * Steps to update: @@ -345,34 +383,33 @@ public function installJava(?bool $async = false): void { */ if (PHP_OS_FAMILY === 'Linux') { $linuxDistribution = $this->getLinuxDistributionToDownloadJava(); - $architecture = php_uname('m'); - if ($architecture === 'x86_64') { + if ($this->architecture === 'x86_64') { $compressedFileName = 'OpenJDK21U-jre_x64_' . $linuxDistribution . '_hotspot_' . self::JAVA_PARTIAL_VERSION . '.tar.gz'; - $url = 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.2+13/' . $compressedFileName; - } elseif ($architecture === 'aarch64') { + $url = 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-' . self::JAVA_URL_PATH_NAME . '/' . $compressedFileName; + } elseif ($this->architecture === 'aarch64') { $compressedFileName = 'OpenJDK21U-jre_aarch64_' . $linuxDistribution . '_hotspot_' . self::JAVA_PARTIAL_VERSION . '.tar.gz'; - $url = 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.2+13/' . $compressedFileName; + $url = 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-' . self::JAVA_URL_PATH_NAME . '/' . $compressedFileName; } $class = TAR::class; } else { throw new RuntimeException(sprintf('OS_FAMILY %s is incompatible with LibreSign.', PHP_OS_FAMILY)); } - $folder = $this->getFolder(); $checksumUrl = $url . '.sha256.txt'; - $hash = $this->getHash($folder, 'java_' . $linuxDistribution . '_' . $architecture, $compressedFileName, self::JAVA_PARTIAL_VERSION, $checksumUrl); + $hash = $this->getHash($compressedFileName, $checksumUrl); try { - $compressedFile = $javaFolder->getFile($compressedFileName); + $compressedFile = $folder->getFile($compressedFileName); } catch (NotFoundException $th) { - $compressedFile = $javaFolder->newFile($compressedFileName); + $compressedFile = $folder->newFile($compressedFileName); } - $comporessedInternalFileName = $this->getDataDir() . DIRECTORY_SEPARATOR . $this->getInternalPathOfFile($compressedFile); + $comporessedInternalFileName = $this->getDataDir() . '/' . $this->getInternalPathOfFile($compressedFile); $this->download($url, 'java', $comporessedInternalFileName, $hash, 'sha256'); $extractor = new $class($comporessedInternalFileName); $extractor->extract($extractDir); + unlink($comporessedInternalFileName); - $this->appConfig->setAppValue('java_path', $extractDir . '/jdk-21.0.2+13-jre/bin/java'); + $this->appConfig->setAppValue('java_path', $extractDir . '/jdk-' . self::JAVA_URL_PATH_NAME . '-jre/bin/java'); $this->removeDownloadProgress(); } @@ -393,17 +430,10 @@ public function uninstallJava(): void { if (!$javaPath) { return; } - $appFolder = $this->getFolder('/'); - $name = $appFolder->getName(); - if (!strpos($javaPath, $name)) { - return; - } - if (PHP_OS_FAMILY !== 'Windows') { - exec('rm -rf ' . $this->getDataDir() . '/' . $this->getInternalPathOfFolder($this->getFolder()) . '/java'); - } + $this->setResource('java'); + $folder = $this->getFolder($this->resource); try { - $javaFolder = $appFolder->getFolder('/libresign/java'); - $javaFolder->delete(); + $folder->delete(); } catch (NotFoundException $th) { } $this->appConfig->deleteAppValue('java_path'); @@ -414,29 +444,34 @@ public function installJSignPdf(?bool $async = false): void { throw new RuntimeException('Zip extension is not available'); } $this->setResource('jsignpdf'); + if ($this->isDownloadedFilesOk()) { + return; + } if ($async) { $this->runAsync(); return; } - $extractDir = $this->getFullPath(); + $folder = $this->getEmptyFolder($this->resource); + $extractDir = $this->getFullPath() . '/' . $this->resource; $compressedFileName = 'jsignpdf-' . JSignPdfHandler::VERSION . '.zip'; try { - $compressedFile = $this->getFolder()->getFile($compressedFileName); + $compressedFile = $folder->getFile($compressedFileName); } catch (\Throwable $th) { - $compressedFile = $this->getFolder()->newFile($compressedFileName); + $compressedFile = $folder->newFile($compressedFileName); } - $comporessedInternalFileName = $this->getDataDir() . DIRECTORY_SEPARATOR . $this->getInternalPathOfFile($compressedFile); + $comporessedInternalFileName = $this->getDataDir() . '/' . $this->getInternalPathOfFile($compressedFile); $url = 'https://github.com/intoolswetrust/jsignpdf/releases/download/JSignPdf_' . str_replace('.', '_', JSignPdfHandler::VERSION) . '/jsignpdf-' . JSignPdfHandler::VERSION . '.zip'; /** WHEN UPDATE version: generate this hash handmade and update here */ $hash = '7c66f5a9f5e7e35b601725414491a867'; $this->download($url, 'JSignPdf', $comporessedInternalFileName, $hash); - $zip = new ZIP($extractDir . DIRECTORY_SEPARATOR . $compressedFileName); + $zip = new ZIP($extractDir . '/' . $compressedFileName); $zip->extract($extractDir); + unlink($extractDir . '/' . $compressedFileName); - $fullPath = $extractDir . DIRECTORY_SEPARATOR . 'jsignpdf-' . JSignPdfHandler::VERSION . DIRECTORY_SEPARATOR . 'JSignPdf.jar'; + $fullPath = $extractDir . '/jsignpdf-' . JSignPdfHandler::VERSION . '/JSignPdf.jar'; $this->appConfig->setAppValue('jsignpdf_jar_path', $fullPath); $this->removeDownloadProgress(); } @@ -446,14 +481,9 @@ public function uninstallJSignPdf(): void { if (!$jsignpdJarPath) { return; } - $appFolder = $this->appData->getFolder('/'); - $name = $appFolder->getName(); - // Remove prefix - $path = explode($name, $jsignpdJarPath)[1]; - // Remove sufix - $path = trim($path, DIRECTORY_SEPARATOR . 'JSignPdf.jar'); + $this->setResource('jsignpdf'); + $folder = $this->getFolder($this->resource); try { - $folder = $appFolder->getFolder($path); $folder->delete(); } catch (NotFoundException $e) { } @@ -462,17 +492,20 @@ public function uninstallJSignPdf(): void { public function installPdftk(?bool $async = false): void { $this->setResource('pdftk'); + if ($this->isDownloadedFilesOk()) { + return; + } if ($async) { $this->runAsync(); return; } - + $folder = $this->getEmptyFolder($this->resource); try { - $file = $this->getFolder()->getFile('pdftk.jar'); + $file = $folder->getFile('pdftk.jar'); } catch (\Throwable $th) { - $file = $this->getFolder()->newFile('pdftk.jar'); + $file = $folder->newFile('pdftk.jar'); } - $fullPath = $this->getDataDir() . DIRECTORY_SEPARATOR . $this->getInternalPathOfFile($file); + $fullPath = $this->getDataDir() . '/' . $this->getInternalPathOfFile($file); $url = 'https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/v' . self::PDFTK_VERSION . '/pdftk-all.jar'; /** WHEN UPDATE version: generate this hash handmade and update here */ $hash = '59a28bed53b428595d165d52988bf4cf'; @@ -488,13 +521,10 @@ public function uninstallPdftk(): void { if (!$jsignpdJarPath) { return; } - $appFolder = $this->appData->getFolder('/'); - $name = $appFolder->getName(); - // Remove prefix - $path = explode($name, $jsignpdJarPath)[1]; + $this->setResource('pdftk'); + $folder = $this->getFolder($this->resource); try { - $file = $appFolder->getFile($path); - $file->delete(); + $folder->delete(); } catch (NotFoundException $e) { } $this->appConfig->deleteAppValue('pdftk_path'); @@ -508,6 +538,9 @@ public function installCfssl(?bool $async = false): void { return; } $this->setResource('cfssl'); + if ($this->isDownloadedFilesOk()) { + return; + } if ($async) { $this->runAsync(); return; @@ -515,93 +548,56 @@ public function installCfssl(?bool $async = false): void { if (PHP_OS_FAMILY !== 'Linux') { throw new RuntimeException(sprintf('OS_FAMILY %s is incompatible with LibreSign.', PHP_OS_FAMILY)); } - $architecture = php_uname('m'); - if ($architecture === 'x86_64') { - $this->installCfssl64(); - } elseif ($architecture === 'aarch64') { - $this->installCfsslArm(); + if ($this->architecture === 'x86_64') { + $this->installCfsslByArchitecture('amd64'); + } elseif ($this->architecture === 'aarch64') { + $this->installCfsslByArchitecture('arm64'); + } else { + throw new InvalidArgumentException('Invalid architecture to download cfssl'); } $this->removeDownloadProgress(); } - private function installCfssl64(): void { - $folder = $this->getFolder(); + private function installCfsslByArchitecture(string $arcitecture): void { + $folder = $this->getEmptyFolder($this->resource); $downloads = [ [ - 'file' => 'cfssl_' . self::CFSSL_VERSION . '_linux_amd64', + 'file' => 'cfssl_' . self::CFSSL_VERSION . '_linux_' . $arcitecture, 'destination' => 'cfssl', ], [ - 'file' => 'cfssljson_' . self::CFSSL_VERSION . '_linux_amd64', + 'file' => 'cfssljson_' . self::CFSSL_VERSION . '_linux_' . $arcitecture, 'destination' => 'cfssljson', ], ]; $baseUrl = 'https://github.com/cloudflare/cfssl/releases/download/v' . self::CFSSL_VERSION . '/'; $checksumUrl = 'https://github.com/cloudflare/cfssl/releases/download/v' . self::CFSSL_VERSION . '/cfssl_' . self::CFSSL_VERSION . '_checksums.txt'; foreach ($downloads as $download) { - $hash = $this->getHash($folder, 'cfssl', $download['file'], self::CFSSL_VERSION, $checksumUrl); + $hash = $this->getHash($download['file'], $checksumUrl); $file = $folder->newFile($download['destination']); - $fullPath = $this->getDataDir() . DIRECTORY_SEPARATOR . $this->getInternalPathOfFile($file); + $fullPath = $this->getDataDir() . '/' . $this->getInternalPathOfFile($file); $this->download($baseUrl . $download['file'], $download['destination'], $fullPath, $hash, 'sha256'); chmod($fullPath, 0700); } - $cfsslBinPath = $this->getDataDir() . DIRECTORY_SEPARATOR . - $this->getInternalPathOfFolder($this->getFolder()) . DIRECTORY_SEPARATOR . + $cfsslBinPath = $this->getDataDir() . '/' . + $this->getInternalPathOfFolder($folder) . '/' . $downloads[0]['destination']; $this->appConfig->setAppValue('cfssl_bin', $cfsslBinPath); } - private function installCfsslArm(): void { - $appFolder = $this->getFolder(); - try { - $cfsslFolder = $appFolder->getFolder('cfssl'); - } catch (NotFoundException $th) { - $cfsslFolder = $appFolder->newFolder('cfssl'); - } - $compressedFileName = 'cfssl-' . self::CFSSL_VERSION . '-1-aarch64.pkg.tar.xz'; - $url = 'http://mirror.archlinuxarm.org/aarch64/community/' . $compressedFileName; - // Generated handmade with command sha256sum - $hash = '944a6c54e53b0e2ef04c9b22477eb5f637715271c74ccea9bb91d7ac0473b855'; - try { - $compressedFile = $cfsslFolder->getFile($compressedFileName); - } catch (NotFoundException $th) { - $compressedFile = $cfsslFolder->newFile($compressedFileName); - } - - $comporessedInternalFileName = $this->getDataDir() . DIRECTORY_SEPARATOR . $this->getInternalPathOfFile($compressedFile); - - $this->download($url, 'cfssl', $comporessedInternalFileName, $hash, 'sha256'); - - $this->appConfig->deleteAppValue('cfssl_bin'); - $extractor = new TAR($comporessedInternalFileName); - - $extractDir = $this->getFullPath() . DIRECTORY_SEPARATOR . 'cfssl'; - $result = $extractor->extract($extractDir); - if (!$result) { - throw new \RuntimeException('Error to extract xz file. Install xz. Read more: https://github.com/codemasher/php-ext-xz'); - } - $cfsslBinPath = $this->getDataDir() . DIRECTORY_SEPARATOR . - $this->getInternalPathOfFolder($this->getFolder()) . DIRECTORY_SEPARATOR . - 'cfssl/usr/bin/cfssl'; - $this->appConfig->setAppValue('cfssl_bin', $cfsslBinPath); - } - public function uninstallCfssl(): void { $cfsslPath = $this->appConfig->getAppValue('cfssl_bin'); if (!$cfsslPath) { return; } - $appFolder = $this->appData->getFolder('/'); - $name = $appFolder->getName(); - // Remove prefix - $path = explode($name, $cfsslPath)[1]; + $this->setResource('cfssl'); + $folder = $this->getFolder($this->resource); try { - $folder = $appFolder->getFolder($path); $folder->delete(); } catch (NotFoundException $e) { } @@ -665,7 +661,6 @@ protected function downloadCli(string $url, string $filename, string $path, ?str } $progressBar->finish(); $this->output->writeln(''); - $progressBar->finish(); if ($hash && file_exists($path) && hash_file($hash_algo, $path) !== $hash) { $this->output->writeln('Failure on download ' . $filename . ' try again'); $this->output->writeln('Invalid ' . $hash_algo . ''); @@ -677,33 +672,10 @@ protected function downloadCli(string $url, string $filename, string $path, ?str } } - private function getHash(ISimpleFolder $folder, string $type, string $file, string $version, string $checksumUrl): string { - $hashFileName = 'checksums_' . $type . '_' . $version . '.txt'; - try { - $fileObject = $folder->getFile($hashFileName); - } catch (NotFoundException $th) { - $hashes = file_get_contents($checksumUrl); - if (!$hashes) { - throw new LibresignException('Failute to download hash file. URL: ' . $checksumUrl); - } - try { - $fileObject = $folder->newFile($hashFileName, $hashes); - } catch (\Throwable $th) { - throw new LibresignException( - 'Failute to create hash file: ' . $hashFileName . '. ' . - 'File corrupted or not found. Run "occ files:scan-app-data libresign".' - ); - } - } - try { - $hashes = $fileObject->getContent(); - } catch (\Throwable $th) { - } - if (empty($hashes)) { - throw new LibresignException( - 'Failute to load content of hash file: ' . $hashFileName . '. ' . - 'File corrupted or not found. Run "occ files:scan-app-data libresign".' - ); + private function getHash(string $file, string $checksumUrl): string { + $hashes = file_get_contents($checksumUrl); + if (!$hashes) { + throw new LibresignException('Failute to download hash file. URL: ' . $checksumUrl); } preg_match('/(?\w*) +' . $file . '/', $hashes, $matches); return $matches['hash']; diff --git a/lib/Service/Install/SignSetupService.php b/lib/Service/Install/SignSetupService.php new file mode 100644 index 0000000000..228c3529cd --- /dev/null +++ b/lib/Service/Install/SignSetupService.php @@ -0,0 +1,344 @@ +appData = $appDataFactory->get('libresign'); + } + + public function getArchitectures(): array { + $appInfo = $this->appManager->getAppInfo(Application::APP_ID); + if (empty($appInfo['dependencies']['architecture'])) { + throw new \Exception('dependencies>architecture not found at info.xml'); + } + return $appInfo['dependencies']['architecture']; + } + + /** + * Write the signature of the app in the specified folder + * + * @param string $path + * @param X509 $certificate + * @param RSA $privateKey + * @throws \Exception + */ + public function writeAppSignature( + X509 $certificate, + RSA $privateKey, + string $architecture, + ) { + $this->architecture = $architecture; + $appInfoDir = $this->getAppInfoDirectory(); + try { + $iterator = $this->getFolderIterator($this->getInstallPath()); + $hashes = $this->generateHashes($iterator); + $signature = $this->createSignatureData($hashes, $certificate, $privateKey); + $this->fileAccessHelper->file_put_contents( + $appInfoDir . '/install-' . $this->architecture . '.json', + json_encode($signature, JSON_PRETTY_PRINT) + ); + } catch (NotFoundException $e) { + throw new \Exception(sprintf("Folder %s not found.\nIs necessary to run this command first: occ libresign:install --all --architecture %s", $e->getMessage(), $this->architecture)); + } catch (\Exception $e) { + if (!$this->fileAccessHelper->is_writable($appInfoDir)) { + throw new \Exception($appInfoDir . ' is not writable'); + } + throw $e; + } + } + + protected function getAppInfoDirectory(): string { + $appInfoDir = realpath(__DIR__ . '/../../../appinfo'); + $this->fileAccessHelper->assertDirectoryExists($appInfoDir); + return $appInfoDir; + } + + /** + * Split the certificate file in individual certs + * + * @param string $cert + * @return string[] + */ + private function splitCerts(string $cert): array { + preg_match_all('([\-]{3,}[\S\ ]+?[\-]{3,}[\S\s]+?[\-]{3,}[\S\ ]+?[\-]{3,})', $cert, $matches); + + return $matches[0]; + } + + private function getSignatureData(): array { + if (!empty($this->signatureData)) { + return $this->signatureData; + } + $appInfoDir = $this->getAppInfoDirectory(); + $signaturePath = $appInfoDir . '/install-' . $this->architecture . '.json'; + $content = $this->fileAccessHelper->file_get_contents($signaturePath); + $signatureData = null; + + if (\is_string($content)) { + $signatureData = json_decode($content, true); + } + if (!\is_array($signatureData)) { + throw new InvalidSignatureException('Signature data not found.'); + } + $this->signatureData = $signatureData; + + $this->validateIfIssignedByLibresignAppCertificate($signatureData['hashes']); + + return $this->signatureData; + } + + private function getHashesOfResource(): array { + $signatureData = $this->getSignatureData(); + $expectedHashes = $signatureData['hashes']; + $filtered = array_filter($expectedHashes, function (string $key) { + return str_starts_with($key, $this->resource); + }, ARRAY_FILTER_USE_KEY); + if (!$filtered) { + throw new InvalidSignatureException('No signature files to ' . $this->resource); + } + return $filtered; + } + + private function getLibresignAppCertificate(): X509 { + if ($this->x509 instanceof X509) { + return $this->x509; + } + $signatureData = $this->getSignatureData(); + $certificate = $signatureData['certificate']; + + // Check if certificate is signed by Nextcloud Root Authority + $this->x509 = new X509(); + $rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/root.crt'); + + $rootCerts = $this->splitCerts($rootCertificatePublicKey); + foreach ($rootCerts as $rootCert) { + $this->x509->loadCA($rootCert); + } + $this->x509->loadX509($certificate); + if (!$this->x509->validateSignature()) { + throw new InvalidSignatureException('Certificate is not valid.'); + } + + // Verify if certificate has proper CN. "core" CN is always trusted. + if ($this->x509->getDN(X509::DN_OPENSSL)['CN'] !== Application::APP_ID && $this->x509->getDN(X509::DN_OPENSSL)['CN'] !== 'core') { + throw new InvalidSignatureException( + sprintf('Certificate is not valid for required scope. (Requested: %s, current: CN=%s)', Application::APP_ID, $this->x509->getDN(true)['CN']) + ); + } + + return $this->x509; + } + + private function validateIfIssignedByLibresignAppCertificate(array $expectedHashes): void { + $x509 = $this->getLibresignAppCertificate(); + + // Check if the signature of the files is valid + $rsa = new RSA(); + $rsa->loadKey($x509->currentCert['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey']); + $rsa->setSignatureMode(RSA::SIGNATURE_PSS); + $rsa->setMGFHash('sha512'); + // See https://tools.ietf.org/html/rfc3447#page-38 + $rsa->setSaltLength(0); + + $signatureData = $this->getSignatureData(); + $signature = base64_decode($signatureData['signature']); + if (!$rsa->verify(json_encode($expectedHashes), $signature)) { + throw new InvalidSignatureException('Signature could not get verified.'); + } + } + + public function verify(string $architecture, $resource): array { + $this->architecture = $architecture; + $this->resource = $resource; + + $expectedHashes = $this->getHashesOfResource(); + + // Compare the list of files which are not identical + $installPath = $this->getInstallPath() . '/' . $this->resource; + $currentInstanceHashes = $this->generateHashes($this->getFolderIterator($installPath), $installPath); + $differencesA = array_diff($expectedHashes, $currentInstanceHashes); + $differencesB = array_diff($currentInstanceHashes, $expectedHashes); + $differences = array_merge($differencesA, $differencesB); + $differenceArray = []; + foreach ($differences as $filename => $hash) { + // Check if file should not exist in the new signature table + if (!array_key_exists($filename, $expectedHashes)) { + $differenceArray['EXTRA_FILE'][$filename]['expected'] = ''; + $differenceArray['EXTRA_FILE'][$filename]['current'] = $hash; + continue; + } + + // Check if file is missing + if (!array_key_exists($filename, $currentInstanceHashes)) { + $differenceArray['FILE_MISSING'][$filename]['expected'] = $expectedHashes[$filename]; + $differenceArray['FILE_MISSING'][$filename]['current'] = ''; + continue; + } + + // Check if hash does mismatch + if ($expectedHashes[$filename] !== $currentInstanceHashes[$filename]) { + $differenceArray['INVALID_HASH'][$filename]['expected'] = $expectedHashes[$filename]; + $differenceArray['INVALID_HASH'][$filename]['current'] = $currentInstanceHashes[$filename]; + continue; + } + + // Should never happen. + throw new \Exception('Invalid behaviour in file hash comparison experienced. Please report this error to the developers.'); + } + + return $differenceArray; + } + + private function getDataDir(): string { + $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/'); + return $dataDir; + } + + /** + * @todo check a best solution to don't use reflection + */ + protected function getInternalPathOfFolder(ISimpleFolder $node): string { + $reflection = new \ReflectionClass($node); + $reflectionProperty = $reflection->getProperty('folder'); + $reflectionProperty->setAccessible(true); + $folder = $reflectionProperty->getValue($node); + $path = $folder->getInternalPath(); + return $path; + } + + private function getInstallPath(): string { + try { + $folder = $this->getDataDir() . '/' . + $this->getInternalPathOfFolder($this->appData->getFolder($this->architecture)); + } catch (NotFoundException $e) { + throw new InvalidSignatureException('Invalid architecture ' . $this->architecture); + } + return $folder; + } + + + /** + * Enumerates all files belonging to the folder. Sensible defaults are excluded. + * + * @param string $folderToIterate + * @param string $root + * @return \RecursiveIteratorIterator + * @throws \Exception + */ + private function getFolderIterator(string $folderToIterate): \RecursiveIteratorIterator { + if (!is_dir($folderToIterate)) { + throw new InvalidSignatureException('No such directory ' . $folderToIterate); + } + $dirItr = new \RecursiveDirectoryIterator( + $folderToIterate, + \RecursiveDirectoryIterator::SKIP_DOTS + ); + + return new \RecursiveIteratorIterator( + $dirItr, + \RecursiveIteratorIterator::SELF_FIRST + ); + } + + /** + * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found + * in the iterator. + * + * @param \RecursiveIteratorIterator $iterator + * @param string $path + * @return array Array of hashes. + */ + private function generateHashes(\RecursiveIteratorIterator $iterator): array { + $hashes = []; + + $baseDirectoryLength = \strlen($this->getInstallPath()); + foreach ($iterator as $filename => $data) { + /** @var \DirectoryIterator $data */ + if ($data->isDir()) { + continue; + } + + $relativeFileName = substr($filename, $baseDirectoryLength); + $relativeFileName = ltrim($relativeFileName, '/'); + + if ($this->isExcluded($relativeFileName)) { + continue; + } + + $hashes[$relativeFileName] = hash_file('sha512', $filename); + } + + return $hashes; + } + + private function isExcluded(string $filename): bool { + foreach ($this->exclude as $prefix) { + if (str_starts_with($filename, $prefix)) { + return true; + } + } + return false; + } + + /** + * Creates the signature data + * + * @param array $hashes + * @param X509 $certificate + * @param RSA $privateKey + * @return array + */ + private function createSignatureData(array $hashes, + X509 $certificate, + RSA $privateKey): array { + ksort($hashes); + + $privateKey->setSignatureMode(RSA::SIGNATURE_PSS); + $privateKey->setMGFHash('sha512'); + // See https://tools.ietf.org/html/rfc3447#page-38 + $privateKey->setSaltLength(0); + $signature = $privateKey->sign(json_encode($hashes)); + + return [ + 'hashes' => $hashes, + 'signature' => base64_encode($signature), + 'certificate' => $certificate->saveX509($certificate->currentCert), + ]; + } +} diff --git a/tests/Unit/Service/Install/SignSetupServiceTest.php b/tests/Unit/Service/Install/SignSetupServiceTest.php new file mode 100644 index 0000000000..c34f0e16dc --- /dev/null +++ b/tests/Unit/Service/Install/SignSetupServiceTest.php @@ -0,0 +1,209 @@ +environmentHelper = $this->createMock(EnvironmentHelper::class); + $this->fileAccessHelper = new FileAccessHelper(); + $this->config = $this->createMock(IConfig::class); + $this->appDataFactory = $this->createMock(IAppDataFactory::class); + $this->appManager = $this->createMock(IAppManager::class); + } + + /** + * @return SignSetupService|MockObject + */ + private function getInstance(array $methods = []) { + return $this->getMockBuilder(SignSetupService::class) + ->setConstructorArgs([ + $this->environmentHelper, + $this->fileAccessHelper, + $this->config, + $this->appDataFactory, + $this->appManager, + ]) + ->onlyMethods($methods) + ->getMock(); + } + + private function getNewCert(): array { + $privateKey = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + $csrNames = ['commonName' => 'libresign']; + + $csr = openssl_csr_new($csrNames, $privateKey, ['digest_alg' => 'sha256']); + $x509 = openssl_csr_sign($csr, null, $privateKey, $days = 365, ['digest_alg' => 'sha256']); + + openssl_x509_export($x509, $rootCertificate); + openssl_pkey_export($privateKey, $publicKey); + + $privateKey = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + return [ + 'privateKey' => $privateKey, + 'rootCertificate' => $rootCertificate, + 'publicKey' => $publicKey, + ]; + } + + /** + * @dataProvider dataGetArchitectures + */ + public function testGetArchitectures(array $appInfo, bool $throwException, $expected):void { + $this->appManager->method('getAppInfo') + ->willReturn($appInfo); + if ($throwException) { + $this->expectExceptionMessage('dependencies>architecture not found at info.xml'); + } + $actual = $this->getInstance()->getArchitectures(); + if ($throwException) { + return; + } + $this->assertEquals($expected, $actual); + } + + public static function dataGetArchitectures(): array { + return [ + [[], true, []], + [['dependencies' => ['architecture' => []]], true, []], + [['dependencies' => ['architecture' => ['x86_64']]], false, ['x86_64']], + [['dependencies' => ['architecture' => ['x86_64', 'aarch64']]], false, ['x86_64', 'aarch64']], + ]; + } + + private function writeAppSignature(string $architecture): SignSetupService { + $this->appManager->method('getAppInfo') + ->willReturn(['dependencies' => ['architecture' => [$architecture]]]); + + $certificate = $this->getNewCert('123456'); + $rsa = new RSA(); + $rsa->loadKey($certificate['privateKey']); + $rsa->loadKey($certificate['publicKey']); + $x509 = new X509(); + $x509->loadX509($certificate['rootCertificate']); + $x509->setPrivateKey($rsa); + + $structure = [ + 'data' => [ + 'libresign' => [ + 'java' => [ + 'fakeFile01' => 'content', + 'fakeFile02' => 'content', + ], + ], + ], + 'resources' => [ + 'codesigning' => [ + 'root.crt' => $certificate['rootCertificate'], + ], + ], + 'appinfo' => [], + ]; + $root = vfsStream::setup('home', null, $structure); + + $this->config->method('getSystemValue') + ->willReturn(vfsStream::url('home/data')); + + $this->environmentHelper->method('getServerRoot') + ->willReturn('vfs://home'); + + $signSetupService = $this->getInstance([ + 'getInternalPathOfFolder', + 'getAppInfoDirectory', + ]); + $signSetupService->expects($this->any()) + ->method('getInternalPathOfFolder') + ->willReturn('libresign'); + $signSetupService->expects($this->any()) + ->method('getAppInfoDirectory') + ->willReturn('vfs://home/appinfo'); + + $signSetupService->writeAppSignature($x509, $rsa, $architecture, 'vfs://home/appinfo'); + $this->assertFileExists('vfs://home/appinfo/install-' . $architecture . '.json'); + $json = file_get_contents('vfs://home/appinfo/install-' . $architecture . '.json'); + $signatureContent = json_decode($json, true); + $this->assertArrayHasKey('hashes', $signatureContent); + $this->assertCount(2, $signatureContent['hashes']); + $expected = hash('sha512', $structure['data']['libresign']['java']['fakeFile01']); + $actual = $signatureContent['hashes']['java/fakeFile01']; + $this->assertEquals($expected, $actual); + return $signSetupService; + } + + /** + * @dataProvider dataWriteAppSignature + */ + public function testWriteAppSignature(string $architecture): void { + $signSetupService = $this->writeAppSignature($architecture); + $architecture = 'x86_64'; + $resource = 'java'; + $actual = $signSetupService->verify($architecture, $resource); + $this->assertCount(0, $actual); + } + + public static function dataWriteAppSignature(): array { + return [ + ['x86_64', 'aarch64'], + ]; + } + + public function testVerify(): void { + $architecture = 'x86_64'; + $signSetupService = $this->writeAppSignature($architecture); + unlink('vfs://home/data/libresign/java/fakeFile01'); + file_put_contents('vfs://home/data/libresign/java/fakeFile02', 'invalidContent'); + file_put_contents('vfs://home/data/libresign/java/fakeFile03', 'invalidContent'); + $expected = json_encode([ + 'FILE_MISSING' => [ + 'java/fakeFile01' => [ + 'expected' => 'b2d1d285b5199c85f988d03649c37e44fd3dde01e5d69c50fef90651962f48110e9340b60d49a479c4c0b53f5f07d690686dd87d2481937a512e8b85ee7c617f', + 'current' => '', + ], + ], + 'INVALID_HASH' => [ + 'java/fakeFile02' => [ + 'expected' => 'b2d1d285b5199c85f988d03649c37e44fd3dde01e5d69c50fef90651962f48110e9340b60d49a479c4c0b53f5f07d690686dd87d2481937a512e8b85ee7c617f', + 'current' => '827a4e298c978e1eeffebdf09f0fa5a1e1d8b608c8071144f3fffb31f9ed21f6d27f88a63f7409583df7438105f713ff58d55e68e61e01a285125d763045c726', + ], + ], + 'EXTRA_FILE' => [ + 'java/fakeFile03' => [ + 'expected' => '', + 'current' => '827a4e298c978e1eeffebdf09f0fa5a1e1d8b608c8071144f3fffb31f9ed21f6d27f88a63f7409583df7438105f713ff58d55e68e61e01a285125d763045c726', + ], + ], + ]); + $actual = $signSetupService->verify($architecture, 'java'); + $actual = json_encode($actual); + $this->assertJsonStringEqualsJsonString($expected, $actual); + } +} diff --git a/tests/Unit/Service/InstallServiceTest.php b/tests/Unit/Service/InstallServiceTest.php index 4bf06e4883..c198d4a166 100644 --- a/tests/Unit/Service/InstallServiceTest.php +++ b/tests/Unit/Service/InstallServiceTest.php @@ -27,6 +27,7 @@ use OCA\Libresign\Handler\CertificateEngine\Handler as CertificateEngineHandler; use OCA\Libresign\Service\Install\InstallService; +use OCA\Libresign\Service\Install\SignSetupService; use OCP\AppFramework\Services\IAppConfig; use OCP\Files\AppData\IAppDataFactory; use OCP\Files\IRootFolder; @@ -46,6 +47,7 @@ final class InstallServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { private IAppConfig|MockObject $appConfig; private IRootFolder|MockObject $rootFolder; private LoggerInterface|MockObject $logger; + private SignSetupService|MockObject $ignSetupService; private IAppDataFactory|MockObject $appDataFactory; public function setUp(): void { @@ -60,6 +62,7 @@ protected function getInstallService(): InstallService { $this->appConfig = $this->createMock(IAppConfig::class); $this->rootFolder = $this->createMock(IRootFolder::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->ignSetupService = $this->createMock(SignSetupService::class); $this->appDataFactory = $this->createMock(IAppDataFactory::class); return new InstallService( $this->cacheFactory, @@ -69,6 +72,7 @@ protected function getInstallService(): InstallService { $this->appConfig, $this->rootFolder, $this->logger, + $this->ignSetupService, $this->appDataFactory ); } @@ -151,4 +155,22 @@ public function providerDownloadCli(): array { ], ]; } + + /** + * @dataProvider providerGetFolder + */ + public function testGetFolder(string $path): void { + $install = \OCP\Server::get(\OCA\Libresign\Service\Install\InstallService::class); + self::invokePrivate($install, 'getFolder', [$path]); + $this->expectNotToPerformAssertions(); + } + + public static function providerGetFolder(): array { + return [ + [''], + ['test'], + ['test/folder1'], + ['test/folder1/folder2'], + ]; + } } diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 219101e219..4d7be2b6b9 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -119,6 +119,13 @@ public function tearDown(): void { $this->cleanDatabase(); } + public static function tearDownAfterClass(): void { + try { + parent::tearDownAfterClass(); + } catch (\Throwable $th) { + } + } + private function cleanDatabase(): void { $db = \OCP\Server::get(\OCP\IDBConnection::class); if (!$db) { @@ -188,9 +195,10 @@ public function deleteUserIfExists($username): void { } private function getBinariesFromCache(): void { - /** @var \OCA\Libresign\Service\Install\InstallService */ - $install = \OCP\Server::get(\OCA\Libresign\Service\Install\InstallService::class); - $appPath = $install->getFullPath(); + $appPath = $this->getFullLiresignAppFolder(); + if (!$appPath) { + return; + } $cachePath = preg_replace('/\/.*\/appdata_[a-z0-9]*/', \OC::$server->getTempManager()->getTempBaseDir(), $appPath); if (!file_exists($cachePath)) { return; @@ -201,10 +209,16 @@ private function getBinariesFromCache(): void { $this->recursiveCopy($cachePath, $appPath); } + private function getFullLiresignAppFolder(): string { + $libresignPath = glob('../../data/appdata_*/libresign'); + if (empty($libresignPath)) { + return ''; + } + return realpath(current($libresignPath)); + } + private function backupBinaries(): void { - /** @var \OCA\Libresign\Service\Install\InstallService */ - $install = \OCP\Server::get(\OCA\Libresign\Service\Install\InstallService::class); - $appPath = $install->getFullPath(); + $appPath = $this->getFullLiresignAppFolder(); if (!is_readable($appPath)) { return; } diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index ea9182bf53..ce5b4bf9d1 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -126,6 +126,20 @@ + + + currentCert]]> + + + + + + + + + + +