Files
Genarrative/apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs
suzmii 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 渠道接入更新包签名、OSS 上传与渠道清单
问题: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 上传计划顺序正确。
2026-09-19 17:49:25 +08:00

178 lines
6.0 KiB
JavaScript
Raw Permalink 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.
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})`,
);
}