nextcloud-server/lib/private/IntegrityCheck/Checker.php

864 lines
29 KiB
PHP

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016-2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OC\IntegrityCheck;
use OC\Core\Command\Maintenance\Mimetype\GenerateMimetypeFileBuilder;
use OC\IntegrityCheck\Exceptions\InvalidSignatureException;
use OC\IntegrityCheck\Helpers\EnvironmentHelper;
use OC\IntegrityCheck\Helpers\FileAccessHelper;
use OC\IntegrityCheck\Iterator\ExcludeFileByNameFilterIterator;
use OC\IntegrityCheck\Iterator\ExcludeFoldersByPathFilterIterator;
use OCP\App\IAppManager;
use OCP\Files\IMimeTypeDetector;
use OCP\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\ServerVersion;
use phpseclib\Crypt\RSA;
use phpseclib\File\X509;
/**
* Performs creation and verification of code integrity signatures (signature.json) for
* Nextcloud core and applications.
*
* - computing SHA512 hashes of installed files
* - signing/verifying the hash list with X.509/RSAPSS certificates
* - validating the certificate chain against the shipped root CA
* - reporting missing/extra/modified files (with repository-specific exclusions
* and cached results).
*
* Nextcloud is shipped with a root CA certificate (resources/codesigning/root.crt).
* That root certificate is used to validate leaf certificates that in turn sign
* application or core signature.json files. The certificate's Common Name (CN)
* is used to scope a certificate to a specific application (app id) or to 'core'.
* A certificate with CN 'core' is always trusted for core verification.
*
*/
class Checker {
public const CACHE_KEY = 'oc.integritycheck.checker';
private ICache $cache;
public function __construct(
private ServerVersion $serverVersion,
private EnvironmentHelper $environmentHelper,
private FileAccessHelper $fileAccessHelper,
private ?IConfig $config,
private ?IAppConfig $appConfig,
ICacheFactory $cacheFactory,
private IAppManager $appManager,
private IMimeTypeDetector $mimeTypeDetector,
) {
$this->cache = $cacheFactory->createDistributed(self::CACHE_KEY);
}
/**
* Whether code signing is enforced or not.
*/
public function isCodeCheckEnforced(): bool {
$notSignedChannels = [ '', 'git'];
if (\in_array($this->serverVersion->getChannel(), $notSignedChannels, true)) {
return false;
}
/**
* This config option is undocumented and supposed to be so, it's only
* applicable for very specific scenarios and we should not advertise it
* too prominent. So please do not add it to config.sample.php.
*/
return !($this->config?->getSystemValueBool('integrity.check.disabled', false) ?? false);
}
/**
* 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, string $root = ''): \RecursiveIteratorIterator {
$dirItr = new \RecursiveDirectoryIterator(
$folderToIterate,
\RecursiveDirectoryIterator::SKIP_DOTS
);
if ($root === '') {
$root = \OC::$SERVERROOT;
}
$root = rtrim($root, '/');
$excludeGenericFilesIterator = new ExcludeFileByNameFilterIterator($dirItr);
$excludeFoldersIterator = new ExcludeFoldersByPathFilterIterator($excludeGenericFilesIterator, $root);
return new \RecursiveIteratorIterator(
$excludeFoldersIterator,
\RecursiveIteratorIterator::SELF_FIRST
);
}
/**
* Generate SHA-512 hases for all files found by the iterator and return
* as a list of ['file' => relativePath, 'hash' => sha512] entries for all
* files.
*
* This avoids using filenames as PHP array keys while collecting hashes,
* which prevents PHP from coercing numeric-looking keys (e.g. "01" -> 1)
* and silently collapsing distinct filenames.
*
* @param \RecursiveIteratorIterator $folderFilesIterator Iterator over
* files in the folder (must iterate files under $basePath)
* @param string $basePath Absolute filesystem path used as the base/root.
* This prefix is stripped from each iterated filename to produce the
* relative keys in the returned array; it must match the root used to
* build the iterator (e.g. '/var/www/nextcloud' or an app folder like
* '/var/www/nextcloud/apps/calendar'). Trailing slash is ignored.
* @return array<int,array{file:string,hash:string}>
*/
private function generateHashes(\RecursiveIteratorIterator $folderFilesIterator, string $basePath): array {
$entries = [];
$basePathLength = \strlen($basePath);
/** @var \RecursiveIteratorIterator<string,\DirectoryIterator> $folderFilesIterator */
foreach ($folderFilesIterator as $absoluteFilePath => $dirEntry) {
/** @var \DirectoryIterator $dirEntry */
if ($dirEntry->isDir()) {
continue;
}
$relativeFilePath = ltrim(substr($absoluteFilePath, $basePathLength), '/');
// Exclude app/core signature files from the hashes
if ($relativeFilePath === 'appinfo/signature.json' || $relativeFilePath === 'core/signature.json') {
continue;
}
// Special-case: ignore installation-specific content (that can be appended
// to the root .htaccess) and only hash the stable content above the marker.
if ($absoluteFilePath === $this->environmentHelper->getServerRoot() . '/.htaccess') {
$fileContent = $this->fileAccessHelper->file_get_contents($absoluteFilePath);
if (!is_string($fileContent)) {
throw new \RuntimeException('Failed to read .htaccess at ' . $absoluteFilePath);
}
$marker = '#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####';
$markerPos = strpos($fileContent, $marker);
// If the marker is present, ignore anything appended.
if ($markerPos !== false) {
// only hash the content above the marker
$contentAboveMarker = substr($fileContent, 0, $markerPos);
$entries[] = [
'file' => $relativeFilePath,
'hash' => hash('sha512', $contentAboveMarker),
];
continue;
}
// If there's no marker, fall through and let the normal file hashing proceed.
}
// Special-case: ignore local alias additions (that lead to non-default on disk
// core/js/mimetypelist.js) and only hash a stable (canonical/default-generated) version.
if ($absoluteFilePath === $this->environmentHelper->getServerRoot() . '/core/js/mimetypelist.js') {
// While local aliases are supported, core/js/mimetypelist.js must always be generated, so
// direct modifications to it are _not_ supported. We detect direct modifications by comparing
// what a generated version should look like to what's on disk.
$mimetypeFileBuilder = new GenerateMimetypeFileBuilder();
$generatedWithAllAliases = $mimetypeFileBuilder->generateFile(
$this->mimeTypeDetector->getAllAliases(),
$this->mimeTypeDetector->getAllNamings()
);
$onDiskContent = $this->fileAccessHelper->file_get_contents($absoluteFilePath);
if (!is_string($onDiskContent)) {
throw new \RuntimeException('Failed to read mimetypelist.js at ' . $absoluteFilePath);
}
// If what's on disk matches, no unsupported direct modifications are present.
if ($generatedWithAllAliases === $onDiskContent) {
// only hash a canonical version w/o any local aliases
$generatedWithDefaultAliases = $mimetypeFileBuilder->generateFile(
$this->mimeTypeDetector->getOnlyDefaultAliases(),
$this->mimeTypeDetector->getAllNamings()
);
$entries[] = [
'file' => $relativeFilePath,
'hash' => hash('sha512', $generatedWithDefaultAliases),
];
continue;
}
// If what's on disk does not match expectations, fall through and let the normal file hashing proceed.
}
// Default (any files without special exclusions/handling above)
$hashResult = hash_file('sha512', $absoluteFilePath);
if ($hashResult === false) {
throw new \RuntimeException('Failed to hash file: ' . $absoluteFilePath);
}
$entries[] = [
'file' => $relativeFilePath,
'hash' => $hashResult,
];
}
return $entries;
}
/**
* Creates the signature data per the schema.
*
* Returned structure:
* [
* 'format_version' => 2,
* 'hashes' => [ ['file'=>'...','hash'=>'...'], ... ],
* 'signature' => 'BASE64...',
* 'certificate' => '-----BEGIN CERTIFICATE-----...'
* ]
*
* @param array<int,array{file:string,hash:string}> $entries
* @param X509 $certificate
* @param RSA $privateKey
* @return array
*/
private function createSignatureData(
array $entries,
X509 $certificate,
RSA $privateKey
): array {
// Build a map to ensure unique filenames (last-wins if duplicate entries present)
$map = [];
foreach ($entries as $entry) {
// The cast to (string) ensures all keys are treated as strings
$map[(string)$entry['file']] = (string)$entry['hash'];
}
$files = array_keys($map);
sort($files, SORT_STRING); // Explicit string sorting
$sortedEntries = [];
foreach ($files as $file) {
// $file is guaranteed to be a string key found directly in $map
$sortedEntries[] = ['file' => $file, 'hash' => $map[$file]];
}
// Sign the canonical array of entries (json-encoded)
$payloadToSign = json_encode($sortedEntries);
if ($payloadToSign === false) {
throw new \RuntimeException('Failed to JSON-encode hash list for signing.');
}
$privateKey->setSignatureMode(RSA::SIGNATURE_PSS);
$privateKey->setMGFHash('sha512');
$privateKey->setSaltLength(0); // See https://tools.ietf.org/html/rfc3447#page-38
$signature = $privateKey->sign($payloadToSign);
return [
'format_version' => 2,
'hashes' => $sortedEntries,
'signature' => base64_encode($signature),
'certificate' => $certificate->saveX509($certificate->currentCert),
];
}
/**
* Write the signature of the app in the specified folder
*
* @param string $path
* @param X509 $certificate
* @param RSA $privateKey
* @throws \Exception if signature file is not writable
*/
public function writeAppSignature(
string $path,
X509 $certificate,
RSA $privateKey
): void {
$appInfoDir = $path . '/appinfo';
$appSigPath = $appInfoDir . '/signature.json';
try {
$this->fileAccessHelper->assertDirectoryExists($appInfoDir);
$iterator = $this->getFolderIterator($path);
$entries = $this->generateHashes($iterator, $path);
$signatureData = $this->createSignatureData($entries, $certificate, $privateKey);
$this->fileAccessHelper->file_put_contents(
$appSigPath,
json_encode($signatureData, JSON_PRETTY_PRINT)
);
} catch (\Exception $e) {
if (!$this->fileAccessHelper->is_writable($appInfoDir)) {
throw new \Exception($appInfoDir . ' is not writable');
}
throw $e;
}
}
/**
* Write the signature of core
*
* @param string $path
* @param X509 $certificate
* @param RSA $privateKey
* @throws \Exception if signature file is not writable
*/
public function writeCoreSignature(
string $path,
X509 $certificate,
RSA $privateKey
): void {
$coreDir = $path . '/core';
$coreSigPath = $coreDir . '/signature.json';
try {
$this->fileAccessHelper->assertDirectoryExists($coreDir);
$iterator = $this->getFolderIterator($path, $path);
$entries = $this->generateHashes($iterator, $path);
$signatureData = $this->createSignatureData($entries, $certificate, $privateKey);
$this->fileAccessHelper->file_put_contents(
$coreSigPath,
json_encode($signatureData, JSON_PRETTY_PRINT)
);
} catch (\Exception $e) {
if (!$this->fileAccessHelper->is_writable($coreDir)) {
throw new \Exception($coreDir . ' is not writable');
}
throw $e;
}
}
/**
* 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];
}
/**
* Verify the signature for the specified path.
*
* @param string $signaturePath Path to signature.json
* @param string $basePath Filesystem root to verify (app folder or server root)
* @param string $expectedCn Expected Common Name (CN) of the signing certificate (app id or 'core')
* @param bool $force When true, forces verification even if configured to skip enforcement (defaults to false)
* @return array Array of differences organized by category (EXTRA_FILE, FILE_MISSING, INVALID_HASH)
* @throws InvalidSignatureException on signature/certificate validation failures
* @throws \RuntimeException on IO/encoding failures
*/
private function verify(
string $signaturePath,
string $basePath,
string $expectedCn,
bool $force = false
): array {
// Skip verification if not forced and code checks are either unsupported in the active release channel or disabled within the config.
if (!$force && !$this->isCodeCheckEnforced()) {
return [];
}
/**
* 1. Load the signature.json data:
* - Load the raw signature.json file from disk.
* - Decode it and perform basic validation of the contents of the signature.json file.
*/
// Load the raw signature.json file
$signatureContent = $this->fileAccessHelper->file_get_contents($signaturePath);
if ($signatureContent === false || !\is_string($signatureContent)) {
throw new \RuntimeException('Could not read signature.json at ' . $signaturePath);
}
/** @var array{format_version?:int,hashes:array<int,array{file:string,hash:string}>|array<string,string>,signature:string,certificate:string} $signatureData */
$signatureData = json_decode($signatureContent, true);
if (!\is_array($signatureData) || !isset($signatureData['hashes'], $signatureData['signature'], $signatureData['certificate'])) {
throw new InvalidSignatureException('Signature data is malformed.');
}
/**
* @var array<int,array{file:string,hash:string}>|array<string,string> $hashesRaw
*
* - array<int,array{file:string,hash:string}> : New format (format_version = 2).
* Example: [
* ['file' => 'lib/Some.php', 'hash' => '0123...'],
* ['file' => 'index.php', 'hash' => 'abcd...'],
* ]
*
* - array<string,string> : Legacy format (filename => hash).
* Example: [
* 'lib/Some.php' => '0123...',
* 'index.php' => 'abcd...',
* ]
*
* $hashesRaw is either the canonical list-of-entries (signed for format_version=2) or the legacy
* associative map (kept for backwards compatibility).
*/
$hashesRaw = $signatureData['hashes'];
$signatureB64 = $signatureData['signature'];
$certificatePem = $signatureData['certificate'];
/**
* 2. Establish a CA store from the shipped root bundle:
* - Load the raw PEM shipped root CA bundle from disk.
* - Split the raw PEM shipped root CA bundle into individual PEM-encoded certificate
* blocks.
* - Parse and load each certificate block and designate each as a trusted CA (for
* subsequent certificate validations).
*/
$rootBundlePath = $this->environmentHelper->getServerRoot() . '/resources/codesigning/root.crt';
$rootBundlePem = $this->fileAccessHelper->file_get_contents($rootBundlePath);
if ($rootBundlePem === false) {
throw new \RuntimeException('Could not read root CA bundle at ' . $rootBundlePath);
}
$rootCaCerts = $this->splitCerts($rootBundlePem);
if (empty($rootCaCerts)) {
throw new \RuntimeException('Root CA bundle contains no certificates.');
}
$caStoreX509 = new X509();
foreach ($rootCaCerts as $rootPem) {
if ($caStoreX509->loadCA($rootPem) === false) {
throw new \RuntimeException('Failed to parse a certificate from the root CA bundle.');
}
}
/**
* 3. Load and validate signing certificate (from signature.json)
* - Parse and load the signing certificate PEM into the class instance.
* - Validate the provided signing cert against the CA chain.
*/
if ($caStoreX509->loadX509($certificatePem) === false) {
throw new InvalidSignatureException('Failed to parse signing certificate.');
}
if (!$caStoreX509->validateSignature()) {
throw new InvalidSignatureException('Signing certificate did not validate against the trusted CA bundle.');
}
/**
* 4. Check certificate CN (scope).
* - Extract CN
* - If certificate CN is "core", it is considered valid for any scope (i.e. not just
* the "core" tree and can legitimately sign core + shipped apps).
*/
$signerDn = $caStoreX509->getDN(X509::DN_OPENSSL);
$signerCn = (is_array($signerDn) && isset($signerDn['CN'])) ? $signerDn['CN'] : null;
if ($signerCn === null) {
throw new InvalidSignatureException('Signing certificate contains no Common Name (CN).');
}
if ($signerCn !== $expectedCn && $signerCn !== 'core') {
// getDN(true) returns a string representation; use it for the error message (if available)
$signerDnString = $caStoreX509->getDN(true) ?: '';
throw new InvalidSignatureException(
sprintf('Certificate is not valid for required scope. (Requested: %s, current: CN=%s, DN=%s)',
$expectedCn, $signerCn, $signerDnString)
);
}
/**
* 5. Verify the signature over the hash blob using the signing cert's public key.
* - Parse and load the signing certificates public key.
* - Prepare RSA verifier from the signing certificate's public key.
* - Set RSA-PSS signature parameters: PSS mode, MGF1 with SHA-512, saltLength = 0
* - Decode and validate signature and hashes payload
* - Sort the hashes array by key (using implied SORT_REGULAR sorting behavior).
*/
if (!isset($caStoreX509->currentCert['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey'])) {
throw new InvalidSignatureException('Signing certificate public key not available.');
}
$signingPublicKeyPem = $caStoreX509->currentCert['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey'];
$signingRsaVerifier = new RSA();
if ($signingRsaVerifier->loadKey($signingPublicKeyPem) === false) {
throw new InvalidSignatureException('Failed to load signing public key.');
}
$signingRsaVerifier->setSignatureMode(RSA::SIGNATURE_PSS);
$signingRsaVerifier->setMGFHash('sha512');
$signingRsaVerifier->setSaltLength(0); // See https://tools.ietf.org/html/rfc3447#page-38
$signatureBinary = base64_decode($signatureB64, true);
if ($signatureBinary === false) {
throw new InvalidSignatureException('Signature is not valid base64.');
}
// Recreate exactly the bytes that were signed:
// - For new format (format_version=2) the signer encoded the hashes array-of-entries.
// - For legacy format the signer encoded the associative map (filename => hash).
$payloadForVerification = json_encode($hashesRaw);
if ($payloadForVerification === false) {
throw new \RuntimeException('Failed to JSON-encode hash list for verification.');
}
if (!$signingRsaVerifier->verify($payloadForVerification, $signatureBinary)) {
throw new InvalidSignatureException('Signature verification failed.');
}
// Normalize decoded hashes into string-keyed map for comparisons
$expectedHashes = $this->normalizeDecodedHashesForComparison($signatureData);
/**
* 6. Ignore "updater/" folder when verifying core (core CN or verifying the server root):
* - since updater is replaced later.
* - also relied upon by install methods that remove the "updater/" folder outright
* (e.g. Community Docker, Snap, RPM, etc.).
*/
if ($expectedCn === 'core' || $basePath === $this->environmentHelper->getServerRoot()) {
foreach ($expectedHashes as $fileName => $hash) {
if (str_starts_with($fileName, 'updater/')) {
unset($expectedHashes[$fileName]);
}
}
}
/**
* 7. Determine if any on-disk (core or app) files do not match their expected hashes.
* - Compute hashes of on-disk files (within the target path)
* - Compare and itemize entries that differ in either direction
* - Organize difference entries by category (EXTRA_FILE, FILE_MISSING, INVALID_HASH)
*/
$currentEntries = $this->generateHashes($this->getFolderIterator($basePath), $basePath);
$currentInstanceHashes = [];
foreach ($currentEntries as $entry) {
$currentInstanceHashes[(string)$entry['file']] = strtolower((string)$entry['hash']);
}
ksort($expectedHashes, SORT_STRING);
ksort($currentInstanceHashes, SORT_STRING);
// Validate expected hash format and ensure lowercase
foreach ($expectedHashes as $file => $hash) {
if (!is_string($hash)) {
throw new InvalidSignatureException('Malformed hash value for ' . (string)$file);
}
if (!preg_match('/^[0-9a-f]{128}$/', $hash)) {
throw new InvalidSignatureException('Malformed hash value for ' . (string)$file);
}
$expectedHashes[$file] = $hash;
}
// Compute diffs
$expectedMissingOrDifferent = array_diff_assoc($expectedHashes, $currentInstanceHashes);
$actualExtraOrDifferent = array_diff_assoc($currentInstanceHashes, $expectedHashes);
// Union by keys (preserve one of the hash values for reporting)
$allDiffs = $expectedMissingOrDifferent + $actualExtraOrDifferent;
$result = [];
foreach ($allDiffs as $filename => $hash) {
// extra on-disk file
if (!array_key_exists($filename, $expectedHashes)) {
$result['EXTRA_FILE'][$filename]['expected'] = '';
$result['EXTRA_FILE'][$filename]['current'] = $hash;
continue;
}
// expected file missing on disk
if (!array_key_exists($filename, $currentInstanceHashes)) {
$result['FILE_MISSING'][$filename]['expected'] = $expectedHashes[$filename];
$result['FILE_MISSING'][$filename]['current'] = '';
continue;
}
// present but hash mismatch
if ($expectedHashes[$filename] !== $currentInstanceHashes[$filename]) {
$result['INVALID_HASH'][$filename]['expected'] = $expectedHashes[$filename];
$result['INVALID_HASH'][$filename]['current'] = $currentInstanceHashes[$filename];
continue;
}
// Should never happen.
throw new \LogicException(sprintf(
'Unexpected behavior while comparing file hashes for "%s": expected=%s current=%s',
$filename, var_export($expectedHashes[$filename], true), var_export($currentInstanceHashes[$filename], true)
));
}
return $result;
}
/**
* Normalize decoded signature.json into a string-keyed map for in-memory comparisons.
*
* Supported shapes (associative-array outer):
* - New format (format_version=2): 'hashes' is an array of ['file'=>'...','hash'=>'...'] entries.
* - Legacy format: 'hashes' is an associative map filename => hash.
*
* @param array<string,mixed> $decodedSig Associative array as returned by json_decode($content, true)
* @return array<string,string> filename => lowercase-hash
* @throws InvalidSignatureException|\RuntimeException
*/
private function normalizeDecodedHashesForComparison(array $decodedSig): array {
$formatVersion = $decodedSig['format_version'] ?? null;
$hashes = $decodedSig['hashes'];
// New format: array-of-entries
if ($formatVersion === 2) {
if (!is_array($hashes)) {
throw new InvalidSignatureException('Malformed signature.json: hashes must be an array for format_version 2');
}
$result = [];
foreach ($hashes as $entry) {
if (!is_array($entry)) {
throw new InvalidSignatureException('Malformed hash entry in signature.json');
}
$file = $entry['file'] ?? null;
$hash = $entry['hash'] ?? null;
if (!is_string($file) || !is_string($hash)) {
throw new InvalidSignatureException('Malformed hash entry in signature.json');
}
if (array_key_exists($file, $result)) {
throw new \RuntimeException('Duplicate filename in signature.json: ' . $file);
}
$result[$file] = strtolower($hash);
}
return $result;
}
// Legacy associative map keyed by filename (keep around for older apps)
if (is_array($hashes)) {
return $this->normalizeKeysToStringWithCollisionCheck($hashes);
}
throw new InvalidSignatureException('Malformed signature hashes structure.');
}
/**
* Convert array keys to strings and throw if two different original keys collapse
* to the same normalized string key (e.g. "01" and "1").
*
* NOTE: This function only re-casts existing keys to strings. It does NOT recover entries
* lost earlier when using the legacy format due to PHP coercing numeric-string keys to integers at
* the time of insertion.
*
* @param array $arr
* @return array<string,string>
* @throws \RuntimeException on collision (rare)
*/
private function normalizeKeysToStringWithCollisionCheck(array $arr): array {
$result = [];
$firstOriginalKey = [];
foreach ($arr as $origKey => $value) {
$stringKey = (string)$origKey;
if (array_key_exists($stringKey, $result)) {
$first = $firstOriginalKey[$stringKey];
// should be quite rare
throw new \RuntimeException(sprintf(
'Filename key collision after normalization: original keys %s and %s both normalize to %s',
var_export($first, true),
var_export($origKey, true),
var_export($stringKey, true)
));
}
if (!is_string($value)) {
throw new InvalidSignatureException('Malformed hash value in signature.json for key ' . (string)$origKey);
}
$result[$stringKey] = strtolower($value);
$firstOriginalKey[$stringKey] = $origKey;
}
return $result;
}
/**
* Whether the code integrity check has passed successful or not
*
* @return bool
*/
public function hasPassedCheck(): bool {
$results = $this->getResults();
if ($results !== null && empty($results)) {
return true;
}
return false;
}
/**
* @return array|null Either the results or null if no results available
*/
public function getResults(): ?array {
$cachedResults = $this->cache->get(self::CACHE_KEY);
if (!\is_null($cachedResults) && $cachedResults !== false) {
return json_decode($cachedResults, true);
}
if ($this->appConfig?->hasKey('core', self::CACHE_KEY, lazy: true)) {
return $this->appConfig->getValueArray('core', self::CACHE_KEY, lazy: true);
}
// No results available
return null;
}
/**
* Stores the results in the app config as well as cache
*
* @param string $scope
* @param array $result
*/
private function storeResults(string $scope, array $result) {
$resultArray = $this->getResults() ?? [];
unset($resultArray[$scope]);
if (!empty($result)) {
$resultArray[$scope] = $result;
}
$this->appConfig?->setValueArray('core', self::CACHE_KEY, $resultArray, lazy: true);
$this->cache->set(self::CACHE_KEY, json_encode($resultArray));
}
/**
*
* Clean previous results for a proper rescanning. Otherwise
*/
private function cleanResults() {
$this->appConfig->deleteKey('core', self::CACHE_KEY);
$this->cache->remove(self::CACHE_KEY);
}
/**
* Verify the signature of $appId. Returns an array with the following content:
* [
* 'FILE_MISSING' =>
* [
* 'filename' => [
* 'expected' => 'expectedSHA512',
* 'current' => 'currentSHA512',
* ],
* ],
* 'EXTRA_FILE' =>
* [
* 'filename' => [
* 'expected' => 'expectedSHA512',
* 'current' => 'currentSHA512',
* ],
* ],
* 'INVALID_HASH' =>
* [
* 'filename' => [
* 'expected' => 'expectedSHA512',
* 'current' => 'currentSHA512',
* ],
* ],
* ]
*
* Array may be empty in case no problems have been found.
*
* @param string $appId
* @param string $path Optional path. If none is given it will be guessed.
* @param bool $forceVerify
* @return array
*/
public function verifyAppSignature(string $appId, string $path = '', bool $forceVerify = false): array {
try {
if ($path === '') {
$path = $this->appManager->getAppPath($appId);
}
$result = $this->verify(
$path . '/appinfo/signature.json',
$path,
$appId,
$forceVerify
);
} catch (\Exception $e) {
$result = [
'EXCEPTION' => [
'class' => \get_class($e),
'message' => $e->getMessage(),
],
];
}
$this->storeResults($appId, $result);
return $result;
}
/**
* Verify the signature of core. Returns an array with the following content:
* [
* 'FILE_MISSING' =>
* [
* 'filename' => [
* 'expected' => 'expectedSHA512',
* 'current' => 'currentSHA512',
* ],
* ],
* 'EXTRA_FILE' =>
* [
* 'filename' => [
* 'expected' => 'expectedSHA512',
* 'current' => 'currentSHA512',
* ],
* ],
* 'INVALID_HASH' =>
* [
* 'filename' => [
* 'expected' => 'expectedSHA512',
* 'current' => 'currentSHA512',
* ],
* ],
* ]
*
* Array may be empty in case no problems have been found.
*
* @return array
*/
public function verifyCoreSignature(): array {
try {
$result = $this->verify(
$this->environmentHelper->getServerRoot() . '/core/signature.json',
$this->environmentHelper->getServerRoot(),
'core'
);
} catch (\Exception $e) {
$result = [
'EXCEPTION' => [
'class' => \get_class($e),
'message' => $e->getMessage(),
],
];
}
$this->storeResults('core', $result);
return $result;
}
/**
* Verify the core code of the instance as well as all applicable applications
* and store the results.
*/
public function runInstanceVerification() {
$this->cleanResults();
$this->verifyCoreSignature();
$appIds = $this->appManager->getAllAppsInAppsFolders();
foreach ($appIds as $appId) {
// If an application is shipped a valid signature is required
$isShipped = $this->appManager->isShipped($appId);
$appNeedsToBeChecked = false;
if ($isShipped) {
$appNeedsToBeChecked = true;
} elseif ($this->fileAccessHelper->file_exists($this->appManager->getAppPath($appId) . '/appinfo/signature.json')) {
// Otherwise only if the application explicitly ships a signature.json file
$appNeedsToBeChecked = true;
}
if ($appNeedsToBeChecked) {
$this->verifyAppSignature($appId);
}
}
}
}