b1cadd0cc8
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m17s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m25s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m26s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m17s
Project CI / Backend tests (pull_request) Successful in 11m30s
Project CI / Repository checks (pull_request) Successful in 11m36s
Project CI / Frontend tests (pull_request) Successful in 13m46s
Project CI / Native shell tests (pull_request) Successful in 16m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m31s
问题:mac 侧此前只有 archive-only 构建,dev-mac 渠道不产出更新包、不上传、无渠道 清单,客户端拿不到 macOS 更新(里程碑曾因签名/公证凭据未就绪而暂缓)。 改动: - build-macos-ci.mjs 改为 dev-mac 发布入口:开启 createUpdaterArtifacts 产出 .app.tar.gz + .sig,生成 universal DMG 与渠道清单 latest.json,构建后用产物内 烘焙的公钥复核签名,再按 AGC_RELEASE_DRY_RUN 决定是否上传 OSS - 新增 verify-updater-signature.mjs(minisign ED 预哈希校验)+ 单测:验签失败或 keyId 不一致立即失败关闭,绝不写 OSS - Jenkinsfile:新增 AGC_RELEASE_VERSION / AGC_RELEASE_DRY_RUN(默认开启)/ AGC_UPDATE_RELEASE_NOTES / OSSUTIL_BIN 参数;withCredentials 注入 AgcUpdaterSigningKey(+密码) 与 AliyunAccessKeyId/Secret;归档补 latest.json、 .sig 与更新摘要 - 既有单测口径更新:archive-only → 发布型,并新增「必须先验签再上传」守卫 - 文档:技术方案、里程碑、运维文档同步;Apple 签发与公证暂缺显式记录为 未验证项(--no-sign、appleSigned=false、notarized=false) 验证:单测 11/11 + 42/42;Jenkinsfile 四个 sh 块语法通过;伪造 bundle 端到端 验证清单(两平台同 URL/同签名、universal 首装包被正确选中)、真实 Tauri 签名 验签通过、错钥匙报 keyId 不一致、篡改报校验失败、dry-run 上传计划顺序正确。
178 lines
6.0 KiB
JavaScript
178 lines
6.0 KiB
JavaScript
import {
|
||
createHash,
|
||
createPublicKey,
|
||
verify as cryptoVerify,
|
||
} from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
/**
|
||
* 更新包签名门禁:用产物里烘焙的 updater 公钥校验 `.sig`,
|
||
* 防止「发布出去的更新包没人装得上」——客户端校验失败会直接拒绝安装,
|
||
* 而且公钥发布后不可更换,所以必须在构建期、上传前就失败关闭。
|
||
*
|
||
* 格式说明(与 Tauri 2 的实际产出对齐,均为实测):
|
||
* - `tauri.conf.json` 的 `plugins.updater.pubkey` 是「minisign 公钥文本」的 base64;
|
||
* - 产物旁的 `<artifact>.sig` 是「minisign 签名文本」的 base64;
|
||
* - 公钥 blob 42 字节(alg `Ed` + 8 字节 keyId + 32 字节 Ed25519 公钥);
|
||
* - 签名 blob 74 字节(alg `Ed` 或 `ED` + 8 字节 keyId + 64 字节签名);
|
||
* - Tauri 产出的是 `ED`:先对文件做 BLAKE2b-512,再对摘要做 Ed25519 签名。
|
||
*/
|
||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||
const defaultTauriConfigPath = path.join(appRoot, 'src-tauri/tauri.conf.json');
|
||
const defaultMacosConfigPath = path.join(
|
||
appRoot,
|
||
'src-tauri/tauri.macos.conf.json',
|
||
);
|
||
|
||
const PUBLIC_KEY_ALGORITHM = 'Ed';
|
||
const RAW_ALGORITHM = 'Ed';
|
||
const PREHASHED_ALGORITHM = 'ED';
|
||
|
||
function unwrapMinisignText(value, label) {
|
||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||
throw new Error(`${label} 为空`);
|
||
}
|
||
const trimmed = value.trim();
|
||
if (trimmed.startsWith('untrusted comment:')) return trimmed;
|
||
const decoded = Buffer.from(trimmed, 'base64').toString('utf8');
|
||
if (!decoded.startsWith('untrusted comment:')) {
|
||
throw new Error(`${label} 不是 minisign 内容(缺少 untrusted comment 头)`);
|
||
}
|
||
return decoded;
|
||
}
|
||
|
||
function contentLines(text) {
|
||
return text
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter((line) => line.length > 0);
|
||
}
|
||
|
||
/** 解析 updater 公钥(`tauri.conf.json` 里的 base64 值或 minisign 文本)。 */
|
||
export function decodeUpdaterPublicKey(value, label = 'updater 公钥') {
|
||
const lines = contentLines(unwrapMinisignText(value, label));
|
||
if (lines.length < 2) throw new Error(`${label} 缺少密钥内容行`);
|
||
const blob = Buffer.from(lines[1], 'base64');
|
||
if (blob.length !== 42) {
|
||
throw new Error(
|
||
`${label} 长度异常:期望 42 字节,实际 ${blob.length} 字节`,
|
||
);
|
||
}
|
||
const algorithm = blob.subarray(0, 2).toString('latin1');
|
||
if (algorithm !== PUBLIC_KEY_ALGORITHM) {
|
||
throw new Error(`${label} 算法不受支持:${algorithm}`);
|
||
}
|
||
return { algorithm, keyId: blob.subarray(2, 10), key: blob.subarray(10) };
|
||
}
|
||
|
||
/** 解析 `.sig`(base64 值或 minisign 文本)。 */
|
||
export function decodeUpdaterSignature(value, label = '更新包签名') {
|
||
const lines = contentLines(unwrapMinisignText(value, label));
|
||
if (lines.length < 2) throw new Error(`${label} 缺少签名内容行`);
|
||
const blob = Buffer.from(lines[1], 'base64');
|
||
if (blob.length !== 74) {
|
||
throw new Error(
|
||
`${label} 长度异常:期望 74 字节,实际 ${blob.length} 字节`,
|
||
);
|
||
}
|
||
const algorithm = blob.subarray(0, 2).toString('latin1');
|
||
if (algorithm !== RAW_ALGORITHM && algorithm !== PREHASHED_ALGORITHM) {
|
||
throw new Error(`${label} 算法不受支持:${algorithm}`);
|
||
}
|
||
return {
|
||
algorithm,
|
||
keyId: blob.subarray(2, 10),
|
||
signature: blob.subarray(10),
|
||
trustedComment: lines[2] ?? '',
|
||
};
|
||
}
|
||
|
||
function publicKeyObject(rawKey) {
|
||
return createPublicKey({
|
||
key: { kty: 'OKP', crv: 'Ed25519', x: rawKey.toString('base64url') },
|
||
format: 'jwk',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 校验更新包签名;任何不一致都抛错(调用方据此失败关闭)。
|
||
*/
|
||
export function verifyUpdaterSignature({
|
||
artifactPath,
|
||
signaturePath,
|
||
pubkey,
|
||
}) {
|
||
const publicKey = decodeUpdaterPublicKey(pubkey);
|
||
const signature = decodeUpdaterSignature(
|
||
fs.readFileSync(signaturePath, 'utf8'),
|
||
);
|
||
if (!publicKey.keyId.equals(signature.keyId)) {
|
||
throw new Error(
|
||
`更新包签名与内置公钥的 keyId 不一致:公钥 ${publicKey.keyId.toString('hex')},签名 ${signature.keyId.toString('hex')};` +
|
||
'签名私钥与产物内烘焙的公钥不是同一对,发布后客户端会拒绝安装',
|
||
);
|
||
}
|
||
const payload = fs.readFileSync(artifactPath);
|
||
const message =
|
||
signature.algorithm === PREHASHED_ALGORITHM
|
||
? createHash('blake2b512').update(payload).digest()
|
||
: payload;
|
||
if (
|
||
!cryptoVerify(
|
||
null,
|
||
message,
|
||
publicKeyObject(publicKey.key),
|
||
signature.signature,
|
||
)
|
||
) {
|
||
throw new Error(
|
||
`更新包签名校验失败:${path.basename(artifactPath)};该产物无法被客户端接受`,
|
||
);
|
||
}
|
||
return {
|
||
algorithm: signature.algorithm,
|
||
keyId: publicKey.keyId.toString('hex'),
|
||
trustedComment: signature.trustedComment,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 读取该平台生效的 updater 公钥:macOS 配置可覆盖基础配置,与构建期行为一致。
|
||
*/
|
||
export function readUpdaterPubkey({
|
||
configPath = defaultTauriConfigPath,
|
||
platformConfigPath = defaultMacosConfigPath,
|
||
} = {}) {
|
||
const readPubkey = (file) => {
|
||
if (!fs.existsSync(file)) return null;
|
||
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||
return config?.plugins?.updater?.pubkey ?? null;
|
||
};
|
||
const pubkey = readPubkey(platformConfigPath) ?? readPubkey(configPath);
|
||
if (!pubkey) throw new Error('未在 Tauri 配置中找到 plugins.updater.pubkey');
|
||
return pubkey;
|
||
}
|
||
|
||
if (
|
||
process.argv[1] &&
|
||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||
) {
|
||
const [artifactPath, signaturePath = `${artifactPath}.sig`] =
|
||
process.argv.slice(2);
|
||
if (!artifactPath) {
|
||
throw new Error(
|
||
'用法:node verify-updater-signature.mjs <更新包> [<签名文件>]',
|
||
);
|
||
}
|
||
const result = verifyUpdaterSignature({
|
||
artifactPath,
|
||
signaturePath,
|
||
pubkey: readUpdaterPubkey(),
|
||
});
|
||
console.log(
|
||
`[agc-macos] 更新包签名校验通过:${path.basename(artifactPath)}(alg=${result.algorithm},keyId=${result.keyId})`,
|
||
);
|
||
}
|