*/ private array $directories; /** * @param string|list $directories the folder(s) to look in, most preferred first; the first is the preferred one * @param (callable(string): int|false)|null $modeReader returns a file's mode bits (tests inject one; default fileperms) * @param bool|null $posix whether file modes are meaningful on this host (tests force it; default: not Windows) * @param (callable(string): array|false)|null $statReader stat() of a path (tests inject one; default Fs::stat) * @param (callable(): array{0: int, 1: int}|null)|null $identity effective [uid, gid] of the PHP process (tests inject one; default posix_*) */ public function __construct( string|array $directories, private mixed $modeReader = null, private ?bool $posix = null, private mixed $statReader = null, private mixed $identity = null ) { $this->directories = array_values((array) $directories); } public static function forContentDir(string $contentDir): self { return new self(rtrim(str_replace('\\', '/', $contentDir), '/') . '/' . self::DIRECTORY); } public static function forLocation(KeyLocation $location): self { return new self($location->searchDirs()); } /** @return list */ public function directories(): array { return $this->directories; } /** The folder holding the key file, or (when there is none yet) the most preferred folder. */ public function directory(): string { foreach ($this->directories as $dir) { if (Fs::isFile($dir . '/' . self::FILE)) { return $dir; } } return $this->directories[0]; } public function path(): string { return $this->directory() . '/' . self::FILE; } public function exists(): bool { return Fs::isFile($this->path()); } /** * Why the key file or its folder is refused, or null: 'file_symlink', 'file_mode', 'folder_world_writable', 'folder_group_writable'. * (An existing file with loose modes is repaired to 0600 first when the PHP user owns it, see read().) */ public function problem(): ?string { $file = $this->path(); if (Fs::isLink($file)) { return 'file_symlink'; } if (!$this->isPosix()) { return null; } $st = $this->statOf($file); if (is_array($st)) { $perms = (int) $st['mode'] & 0777; $ids = $this->ids(); if (($perms & 0007) !== 0 || (($perms & 0070) !== 0 && ($ids === null || (int) $st['gid'] !== $ids[1]))) { return 'file_mode'; } } return $this->folderProblem($this->directory(), false); } /** * @return array{keys: array, current: int} base64 keys by version * @throws MissingKeyException when the file is absent, unreadable, unsafe or damaged (never reveals why in detail) */ public function read(): array { $problem = $this->problem(); if ($problem === 'file_mode' && $this->repairMode()) { $problem = $this->problem(); } if ($problem !== null) { throw new MissingKeyException('The Aiwend key file cannot be used safely.'); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reads a local file (the key file or an uploaded text file), not a URL $raw = Fs::isFile($this->path()) ? @file_get_contents($this->path()) : false; if (!is_string($raw) || !preg_match('~/\*\R(\{.*\})\R\*/~s', $raw, $m)) { throw new MissingKeyException('The Aiwend key file is missing or unreadable.'); } $data = json_decode($m[1], true); if (!is_array($data) || !is_array($data['keys'] ?? null) || !is_int($data['current'] ?? null)) { throw new MissingKeyException('The Aiwend key file is damaged.'); } $keys = []; foreach ($data['keys'] as $version => $encoded) { $raw = is_string($encoded) ? base64_decode($encoded, true) : false; if (!ctype_digit((string) $version) || $raw === false || strlen($raw) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) { throw new MissingKeyException('The Aiwend key file is damaged.'); } $keys[(int) $version] = (string) $encoded; } if (!isset($keys[$data['current']])) { throw new MissingKeyException('The Aiwend key file is damaged.'); } return ['keys' => $keys, 'current' => $data['current']]; } /** True when neither group nor others have any access (POSIX mode bits). */ public static function isPrivateMode(int $perms): bool { return ($perms & 0077) === 0; } /** * Why a key folder is not acceptable, or null (POSIX only; a folder that does not exist yet is fine, creation makes it 0700): * 'folder_world_writable' (anyone can replace the key; the sticky bit on a folder the PHP user owns is accepted), * 'folder_group_writable' (a group other than the PHP user's own can), and, when creating, 'folder_owner' (not owned by the PHP user). * Uses the real filesystem; folderProblem() is the same check with this object's injected readers. */ public static function unsafeFolder(string $dir, bool $forCreate = false): ?string { return (new self($dir))->folderProblem($dir, $forCreate); } public function folderProblem(string $dir, bool $forCreate): ?string { if (!$this->isPosix()) { return null; } $real = Fs::realpath($dir); $st = $real === false ? false : $this->statOf($real); if (!is_array($st)) { return null; } $perms = (int) $st['mode'] & 0777; $ids = $this->ids(); $sticky = ((int) $st['mode'] & 01000) !== 0; $mine = $ids !== null && (int) $st['uid'] === $ids[0]; if (($perms & 0002) !== 0 && !($sticky && $mine)) { return 'folder_world_writable'; } if (($perms & 0020) !== 0 && ($ids === null || (int) $st['gid'] !== $ids[1])) { return 'folder_group_writable'; } if ($forCreate && $ids !== null && !$mine) { return 'folder_owner'; } return null; } /** Tightens a key file the PHP user owns to 0600; false when that is not possible. */ private function repairMode(): bool { $ids = $this->ids(); $st = $this->statOf($this->path()); if (!is_array($st) || $ids === null || (int) $st['uid'] !== $ids[0] || Fs::isLink($this->path())) { return false; } return Fs::chmod($this->path(), 0600); } private function isPosix(): bool { return $this->posix ?? (DIRECTORY_SEPARATOR === '/'); } /** @return array{0: int, 1: int}|null effective uid and gid of this PHP process; null when unknown */ private function ids(): ?array { if ($this->identity !== null) { return ($this->identity)(); } return function_exists('posix_geteuid') && function_exists('posix_getegid') ? [posix_geteuid(), posix_getegid()] : null; } /** @return array|false */ private function statOf(string $path): array|false { if ($this->statReader !== null) { return ($this->statReader)($path); } if ($this->modeReader !== null) { $perms = ($this->modeReader)($path); $ids = $this->ids() ?? [0, 0]; return $perms === false ? false : ['mode' => $perms, 'uid' => $ids[0], 'gid' => $ids[1]]; } clearstatcache(true, $path); return Fs::stat($path); } /** Removes the key file and its guard files (and each folder when empty), wherever the key file lives; only for the explicit delete-everything uninstall. */ public function delete(): bool { foreach ($this->directories as $dir) { if (!Fs::isFile($dir . '/' . self::FILE)) { continue; } foreach ([self::FILE, '.htaccess', 'index.php'] as $name) { if (Fs::isFile($dir . '/' . $name) || Fs::isLink($dir . '/' . $name)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- private key file and temporary files: exact, atomic native calls (WP_Filesystem cannot create exclusively) @unlink($dir . '/' . $name); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- removes the empty private key or probe folder @rmdir($dir); } return !$this->exists(); } }