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 */ private function generateHashes(\RecursiveIteratorIterator $folderFilesIterator, string $basePath): array { $entries = []; $basePathLength = \strlen($basePath); /** @var \RecursiveIteratorIterator $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 $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|array,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|array $hashesRaw * * - array : New format (format_version = 2). * Example: [ * ['file' => 'lib/Some.php', 'hash' => '0123...'], * ['file' => 'index.php', 'hash' => 'abcd...'], * ] * * - array : 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 $decodedSig Associative array as returned by json_decode($content, true) * @return array 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 * @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); } } } }