Files
Genarrative/apps/ai-game-creator-shell/scripts/verify-updater-signature.test.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

183 lines
5.5 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 assert from 'node:assert/strict';
import {
createHash,
generateKeyPairSync,
randomBytes,
sign as cryptoSign,
} from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
decodeUpdaterPublicKey,
decodeUpdaterSignature,
readUpdaterPubkey,
verifyUpdaterSignature,
} from './verify-updater-signature.mjs';
/**
* 用进程内生成的 Ed25519 密钥自造 minisign 结构,
* 覆盖 Tauri 实际使用的 `ED`(BLAKE2b-512 预哈希)与 `Ed`(原文)两种模式。
*/
function createKeyMaterial() {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const rawKey = Buffer.from(
publicKey.export({ format: 'jwk' }).x,
'base64url',
);
const keyId = randomBytes(8);
const pubkey = Buffer.from(
`untrusted comment: minisign public key: ${keyId.reverse().toString('hex').toUpperCase()}\n` +
`${Buffer.concat([Buffer.from('Ed'), keyId, rawKey]).toString('base64')}\n`,
).toString('base64');
return { privateKey, keyId, rawKey, pubkey };
}
function signFixture({ privateKey, keyId }, payload, algorithm) {
const message =
algorithm === 'ED'
? createHash('blake2b512').update(payload).digest()
: payload;
const signature = cryptoSign(null, message, privateKey);
const blob = Buffer.concat([Buffer.from(algorithm), keyId, signature]);
const globalSignature = cryptoSign(null, blob, privateKey);
return Buffer.from(
'untrusted comment: signature from tauri secret key\n' +
`${blob.toString('base64')}\n` +
'trusted comment: timestamp:0\tfile:fixture\n' +
`${globalSignature.toString('base64')}\n`,
).toString('base64');
}
function withFixture(run) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-sig-test-'));
try {
const artifactPath = path.join(directory, 'app.app.tar.gz');
fs.writeFileSync(artifactPath, 'update payload');
return run({ directory, artifactPath });
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
test('接受 Tauri 实际使用的 ED(BLAKE2b-512 预哈希)签名', () => {
withFixture(({ directory, artifactPath }) => {
const material = createKeyMaterial();
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
fs.writeFileSync(
signaturePath,
signFixture(material, fs.readFileSync(artifactPath), 'ED'),
);
const result = verifyUpdaterSignature({
artifactPath,
signaturePath,
pubkey: material.pubkey,
});
assert.equal(result.algorithm, 'ED');
assert.equal(result.keyId, material.keyId.toString('hex'));
});
});
test('接受原文 Ed 签名,两种算法互不通用', () => {
withFixture(({ directory, artifactPath }) => {
const material = createKeyMaterial();
const payload = fs.readFileSync(artifactPath);
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
fs.writeFileSync(signaturePath, signFixture(material, payload, 'Ed'));
assert.equal(
verifyUpdaterSignature({
artifactPath,
signaturePath,
pubkey: material.pubkey,
}).algorithm,
'Ed',
);
// 原文模式下签名的是别的载荷时必须失败:证明确实在校验内容而非只看结构。
fs.writeFileSync(
signaturePath,
signFixture(material, Buffer.from('别的载荷'), 'Ed'),
);
assert.throws(
() =>
verifyUpdaterSignature({
artifactPath,
signaturePath,
pubkey: material.pubkey,
}),
/签名校验失败/u,
);
});
});
test('产物被篡改时失败关闭', () => {
withFixture(({ directory, artifactPath }) => {
const material = createKeyMaterial();
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
fs.writeFileSync(
signaturePath,
signFixture(material, fs.readFileSync(artifactPath), 'ED'),
);
fs.writeFileSync(artifactPath, 'tampered payload');
assert.throws(
() =>
verifyUpdaterSignature({
artifactPath,
signaturePath,
pubkey: material.pubkey,
}),
/签名校验失败/u,
);
});
});
test('签名私钥与内置公钥不是同一对时给出明确错误', () => {
withFixture(({ directory, artifactPath }) => {
const signing = createKeyMaterial();
const baked = createKeyMaterial();
const signaturePath = path.join(directory, 'app.app.tar.gz.sig');
fs.writeFileSync(
signaturePath,
signFixture(signing, fs.readFileSync(artifactPath), 'ED'),
);
assert.throws(
() =>
verifyUpdaterSignature({
artifactPath,
signaturePath,
pubkey: baked.pubkey,
}),
/keyId 不一致/u,
);
});
});
test('公钥或签名格式非法时拒绝解析', () => {
assert.throws(() => decodeUpdaterPublicKey(''), /为空/u);
assert.throws(
() => decodeUpdaterPublicKey('bm90IGEgbWluaXNpZ24ga2V5'),
/不是 minisign 内容/u,
);
assert.throws(
() =>
decodeUpdaterPublicKey(
Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'),
),
/长度异常/u,
);
assert.throws(
() =>
decodeUpdaterSignature(
Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'),
),
/长度异常/u,
);
});
test('仓库里配置的 updater 公钥可被解析(两平台共用)', () => {
const decoded = decodeUpdaterPublicKey(readUpdaterPubkey());
assert.equal(decoded.algorithm, 'Ed');
assert.equal(decoded.key.length, 32);
});