// +---------------------------------------------------------------------- declare(strict_types=1); namespace ywxapp\service; use think\facade\Config; use think\facade\Log; /** * 发布包(框架升级包 / 整站包 / 安装包)的完整性 + 来源验签 * * 为什么必须有: * `FrameworkService::apply()` 会把下载来的 zip 直接解压覆盖整个站点。 * 只靠 HTTPS 只能防链路窃听,防不住「官网/CDN/DNS 被攻破」或「内部人员上传恶意包」 * —— 那等于给全部客户机一条远程代码执行的公共通道。 * 因此每个发布包都必须带 `signature.json`:对我方私钥做 RSA-SHA256 签名, * 客户端用内置的公钥先验签、再比对清单哈希,任何一项不符都拒绝安装。 * * 签名对象为什么是「清单哈希」而不是「zip 文件哈希」: * 签名要写进同一个 zip 里,写进去本身就会改变 zip 的字节, * 故只能对「除签名文件外的所有条目的内容」做哈希: * manifest = sha256( 逐行拼接 "sha256(内容) 条目名" ) (条目按名排序,保证可复现) * 并额外把 version 放进被签数据,防止「用旧版本的有签名包」回滚降级。 * * 公钥的分发与轮换: * 1) 优先读 ywxapp/data/package_public.pem(随版本发布,可整包轮换); * 2) 该文件缺失时回退本类内置常量 BUILTIN_PUBLIC_KEY(由 scripts/package_keygen.php 写入); * 3) 两者都为空 ⇒ 视为未部署 ├── **拒绝安装**(而不是静默放行,否则等于没有防护)。 * * 私钥永远不入库、不进包:只存在打包机上,见 scripts/package_keygen.php。 * * @author ywxapp */ class PackageVerifier { /** 包内签名文件名(顶层) */ public const SIG_ENTRY = 'signature.json'; /** 签名算法标识 */ public const ALG = 'RSA-SHA256'; /** * 内置应急公钥(PEM),由 scripts/package_keygen.php 生成密钥对后自动写回此处。 * 与 ywxapp/data/package_public.pem 二者取其一即可;文件缺失时以本常量为准。 */ private const BUILTIN_PUBLIC_KEY = <<<'PEM' -----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAmHGzcqk2QQyY7ClR5exc w+6Usp9rONDhHBSetD3HZaHMr3pB6VeyydfpVyrlcvuAH3drmJWeNlpEFNU5HnZ6 zgMp9wetPWI9p2na4s7z89J5ubEajAi+benCdNswRg7PYlMcWp2ufJgtivDlUd+G at5dc9cKtA5Sw/0OxR7gZBSg9JD254XBDuL72Ut4pyqWxYfJm0X7Y4n8FVXBAG77 nZVvH2+Q1aHFyV2/1PMQr85KO2aGNaxPtb5PsE16tFo16/TiS8ekMFatKMcaK32l qHeJl+dq9NU6FG9EgocL74kH7HvFGDd5ksahGXLwp4FVOw78+GpjTvGSndZmi5tO UHNsc8uYe0od8qu92c8brayR264r0w0quaytVzrrvfhEHxjV6YYAzqR+vTE7txhf /8nYU8krMefnDKtEJ/F+RsSn/3EWqtVtcgaLhr8peHnPyaSciOPt01WeqQlbTFCm S7bdhtOKCd+dovf/9DPXgHpvqjURseYNCZ0Jsmkg+/alz+cVPdM6vB2qiJD9ejbY CL8dQ7qXG5X/cT/mpNVCT9ozYVveMgshu5gJNY5s3OuH6NBvUyOLgDB4FLgZlv4V 18ag8NQ97Km0ysVm6rma3/YjGKUqdqS5W16sGs4NfB3ty00gud0J7pr3BGQL4lBl ar+7CwwOhdGenK0CjIOxEgUCAwEAAQ== -----END PUBLIC KEY----- PEM; /** * 写日志(不可用时静默) * * 为什么要兜底:验签发生在「更新产物督查/运行」早期,Facade 可能尚未初始化; * 记日志失败不能反过来中断安装流程。 */ private static function safeLog(string $level, string $message, array $context = []): void { try { Log::{$level}($message, $context); } catch (\Throwable $e) { // ignore } } /** * 公钥文件路径(可用 ywxapp.package_public_key_path 覆盖) */ public static function publicKeyFile(): string { $file = Config::get('ywxapp.package_public_key_path', ''); return $file !== '' ? (string) $file : root_path() . 'ywxapp' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'package_public.pem'; } /** * 是否强制验签(ywxapp.framework_package_verify,默认 true) * * 仅在内网自托管、自己签名的调试场景才建议关闭:FRAMEWORK_PACKAGE_VERIFY=false */ public static function enabled(): bool { return (bool) Config::get('ywxapp.framework_package_verify', true); } /** * 统一入口:按框架配置取公钥并验签(供 FrameworkService::apply() 调用) * * @param string $zipFile 本地包路径 * @param string $expectVersion 期望版本号(防降级;留空不校验版本号,但仍校验签名与内容) * @return array 签名信息;['skipped'=>true] 表示本次未启用验签 * @throws \Exception 验签失败 / 未配置公钥 —— 调用方必须中断安装 */ public static function guard(string $zipFile, string $expectVersion = ''): array { if (! self::enabled()) { self::safeLog('warning', '[PackageVerifier] 升级包验签已关闭(framework_package_verify=false),跳过签名校验', [ 'file' => $zipFile, 'version' => $expectVersion, ]); return ['skipped' => true, 'version' => $expectVersion]; } $file = self::publicKeyFile(); $pub = is_file($file) ? (string) file_get_contents($file) : ''; if (trim($pub) === '') { $pub = trim(self::BUILTIN_PUBLIC_KEY); } if ($pub === '') { // 静默放行会让整条防护形同虚设,没配公钥时一律拒绝施工: // 部署方需执行 php scripts/package_keygen.php 生成并写入公钥。 throw new \Exception('升级包验签未部署公钥,已拒绝安装。请先在发布端执行 php scripts/package_keygen.php,' . '并将生成的公钥写入 ' . $file . '(或 ywxapp\\service\\PackageVerifier::BUILTIN_PUBLIC_KEY)'); } return self::verifyFile($zipFile, $pub, $expectVersion); } /** * 计算包内容清单哈希(不含签名文件自身) * * @param string $zipFile zip 路径 * @param string $version 参与签名的版本号(防降级) * @return array ['hash'=>string, 'files'=>int] */ public static function manifest(string $zipFile, string $version = ''): array { $zip = new \ZipArchive(); if ($zip->open($zipFile) !== true) { throw new \Exception('无法打开发布包:' . $zipFile); } try { return self::manifestOf($zip, $version); } finally { $zip->close(); } } /** * 对一个已打开的 zip 计算清单哈希 */ protected static function manifestOf(\ZipArchive $zip, string $version): array { $lines = ['version:' . $version]; $count = 0; $names = []; for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if ($name === false) { continue; } $name = ltrim($name, '/'); if ($name === self::SIG_ENTRY || substr($name, -1) === '/') { continue; } $names[$name] = $i; } ksort($names, SORT_STRING); foreach ($names as $name => $i) { $content = $zip->getFromIndex($i); $lines[] = hash('sha256', (string) $content) . ' ' . $name; $count++; } return ['hash' => hash('sha256', implode("\n", $lines)), 'files' => $count]; } /** * 用给定公钥验签 * * @param string $zipFile * @param string $publicKeyPem 公钥 PEM * @param string $expectVersion * @return array 签名元数据 ['version'=>..,'signed_at'=>..,'files'=>..,'alg'=>..] * @throws \Exception */ public static function verifyFile(string $zipFile, string $publicKeyPem, string $expectVersion = ''): array { if (! is_file($zipFile)) { throw new \Exception('发布包不存在:' . $zipFile); } $zip = new \ZipArchive(); if ($zip->open($zipFile) !== true) { throw new \Exception('无法打开发布包(文件损坏或不是 zip):' . $zipFile); } $alg = self::ALG; try { $raw = $zip->getFromName(self::SIG_ENTRY); if ($raw === false) { throw new \Exception('发布包缺少签名文件 ' . self::SIG_ENTRY . ':无法确认来源,已拒绝安装(该包可能不是官方发布包)'); } $sig = json_decode($raw, true); if (! is_array($sig) || empty($sig['signature']) || empty($sig['sha256'])) { throw new \Exception('发布包签名格式非法'); } $alg = (string) ($sig['alg'] ?? self::ALG); if ($alg !== self::ALG) { throw new \Exception('不支持的签名算法:' . $alg); } // 1) 内容完整性:清单哈希必须与签名记录的一致 $manifest = self::manifestOf($zip, (string) ($sig['version'] ?? '')); if (! hash_equals((string) $sig['sha256'], $manifest['hash'])) { throw new \Exception('发布包内容与签名不一致(文件被篡改或下载损坏),已拒绝安装'); } // 2) 来源真实性:RSA 验签 $binary = base64_decode((string) $sig['signature'], true); if ($binary === false || $binary === '') { throw new \Exception('发布包签名为空或非法 base64'); } $ok = openssl_verify($manifest['hash'], $binary, $publicKeyPem, OPENSSL_ALGO_SHA256); if ($ok !== 1) { throw new \Exception('发布包签名校验失败:不是官方私钥签发,已拒绝安装'); } // 3) 防降级:包版本必须与本次目标版本一致 if ($expectVersion !== '' && (string) ($sig['version'] ?? '') !== $expectVersion) { throw new \Exception('发布包签名版本与升级目标不一致(包内 ' . ($sig['version'] ?? '未知') . ' / 目标 ' . $expectVersion . '),已拒绝安装'); } } finally { $zip->close(); } $info = [ 'version' => (string) ($sig['version'] ?? ''), 'signed_at' => (int) ($sig['signed_at'] ?? 0), 'files' => $manifest['files'] ?? 0, 'alg' => $alg, 'skipped' => false, ]; // 日志文件可能不可用(装包场景低于应用初始化完成、或独立 CLI 调用), // 记录失败绝不能反过来中断安装/校验本身。 try { Log::info('[PackageVerifier] 发布包验签通过', $info); } catch (\Throwable $e) { // ignore } return $info; } /** * 对已打包完成的 zip 计算清单哈希并写回包内 signature.json(打包端专用) * * @param string $zipFile 已打包完成的 zip(会被追加/替换 signature.json) * @param string $privateKey 私钥 PEM 或私钥文件路径 * @param string $version 版本号 * @return array 签名信息 */ public static function sign(string $zipFile, string $privateKey, string $version): array { if (! extension_loaded('openssl')) { throw new \Exception('缺少 openssl 扩展,无法对发布包签名'); } $pem = is_file($privateKey) ? (string) file_get_contents($privateKey) : $privateKey; $key = openssl_pkey_get_private($pem); if ($key === false) { throw new \Exception('私钥不可用:' . (is_file($privateKey) ? $privateKey : '(inline pem)')); } // 先算清单(此时不得包含签名文件,故先删掉旧签名再算) $zip = new \ZipArchive(); if ($zip->open($zipFile) !== true) { throw new \Exception('无法打开发布包:' . $zipFile); } if ($zip->locateName(self::SIG_ENTRY) !== false) { $zip->deleteName(self::SIG_ENTRY); } $zip->close(); $manifest = self::manifest($zipFile, $version); $binary = ''; if (! openssl_sign($manifest['hash'], $binary, $key, OPENSSL_ALGO_SHA256)) { throw new \Exception('签名失败:' . openssl_error_string()); } $payload = [ 'alg' => self::ALG, 'version' => $version, 'sha256' => $manifest['hash'], 'files' => $manifest['files'], 'signed_at' => time(), 'signature' => base64_encode($binary), ]; $zip = new \ZipArchive(); if ($zip->open($zipFile) !== true) { throw new \Exception('无法打开发布包写入签名:' . $zipFile); } $ok = $zip->addFromString(self::SIG_ENTRY, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); $zip->close(); if (! $ok) { throw new \Exception('写入签名文件失败:' . $zipFile); } return $payload; } }