安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
324 lines
13 KiB
PHP
324 lines
13 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
|
||
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 <admin@ywxapp.cn>
|
||
*/
|
||
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;
|
||
}
|
||
}
|