Files
YwxAppThink/ywxapp/service/PackageVerifier.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

324 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}