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 上传计划顺序正确。
183 lines
5.5 KiB
JavaScript
183 lines
5.5 KiB
JavaScript
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);
|
||
});
|