feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
818
scripts/ci/verify-mobile-release-boundary.mjs
Normal file
818
scripts/ci/verify-mobile-release-boundary.mjs
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
import { generateKeyPairSync } from 'node:crypto'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import {
|
||||
existsSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
buildReleasePayload,
|
||||
canonicalJson,
|
||||
COMPROMISED_SIGNER_SHA256,
|
||||
createImmutableVerificationSnapshot,
|
||||
hashRegularFileStable,
|
||||
prepareVerifiedReleasePublication,
|
||||
RELEASE_AAB_NAME,
|
||||
RELEASE_APK_NAME,
|
||||
RELEASE_PACKAGE_NAME,
|
||||
signReleaseEvidence,
|
||||
SIGNED_EVIDENCE_NAME,
|
||||
writeJsonCreateOnly,
|
||||
} from './mobile-release-evidence-lib.mjs'
|
||||
|
||||
const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNoMobileArtifacts } = require('../deploy-site-to-nas.js')
|
||||
const productionAdMobId = 'ca-app-pub-1234567890123456~1234567890'
|
||||
const productionSigner = '4fac6924821c50daabed764932a53c486f8c6c5f34b9f18db920aa4099152b54'
|
||||
const versionName = '9.8.7'
|
||||
const versionCode = 9_008_007
|
||||
const releaseProvenance = {
|
||||
repository: 'chanpaca/D3ROVoice',
|
||||
commitSha: 'a'.repeat(40),
|
||||
treeSha: 'b'.repeat(40),
|
||||
gitRef: 'refs/tags/v9.8.7',
|
||||
workflowIdentity: 'github:.github/workflows/release.yml:package-android',
|
||||
runId: '123456789',
|
||||
runAttempt: 1,
|
||||
runnerIdentity: 'github-hosted:ubuntu-latest',
|
||||
verifierSha256: 'c'.repeat(64),
|
||||
bundletoolSha256: 'd'.repeat(64),
|
||||
}
|
||||
|
||||
function fail(code) {
|
||||
throw new Error(`mobile_release_boundary_self_test:${code}`)
|
||||
}
|
||||
|
||||
function assert(condition, code) {
|
||||
if (!condition) fail(code)
|
||||
}
|
||||
|
||||
function readWorkspaceFile(relativePath) {
|
||||
return readFileSync(resolve(workspaceRoot, relativePath), 'utf8')
|
||||
}
|
||||
|
||||
function runVerifierSelfTest(relativePath) {
|
||||
const result = spawnSync(process.execPath, [resolve(workspaceRoot, relativePath), '--self-test'], {
|
||||
cwd: workspaceRoot,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
})
|
||||
assert(result.status === 0, `verifier_self_test_failed_${relativePath}_${result.stderr || result.stdout}`)
|
||||
}
|
||||
|
||||
function runCliExpectedFailure(relativePath, options, omitted, expectedCode) {
|
||||
const argumentsList = Object.entries(options)
|
||||
.filter(([name]) => name !== omitted)
|
||||
.flatMap(([name, value]) => [`--${name}`, String(value)])
|
||||
const result = spawnSync(process.execPath, [resolve(workspaceRoot, relativePath), ...argumentsList], {
|
||||
cwd: workspaceRoot,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
})
|
||||
const output = `${result.stderr ?? ''}${result.stdout ?? ''}`
|
||||
assert(result.status !== 0, `cli_missing_argument_accepted_${relativePath}_${omitted}`)
|
||||
assert(
|
||||
output.includes(expectedCode),
|
||||
`cli_missing_argument_wrong_failure_${relativePath}_${omitted}_${output}`,
|
||||
)
|
||||
}
|
||||
|
||||
function listScripts(directory) {
|
||||
const result = []
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules') continue
|
||||
const target = join(directory, entry.name)
|
||||
if (entry.isDirectory()) result.push(...listScripts(target))
|
||||
else if (/\.(?:cjs|js|mjs)$/.test(entry.name)) result.push(target)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function verifySourceContracts() {
|
||||
const legacySync = readWorkspaceFile('scripts/ci/sync-and-publish-forgejo-release.mjs')
|
||||
for (const forbidden of [
|
||||
'app-debug.apk',
|
||||
'd3ro-voice-v1.0.0.apk',
|
||||
'Android Release APK',
|
||||
]) {
|
||||
assert(!legacySync.includes(forbidden), `legacy_sync_contains_${forbidden.replace(/[^a-z0-9]+/gi, '_')}`)
|
||||
}
|
||||
|
||||
const retiredCapture = readWorkspaceFile('scripts/capture-pricing.js')
|
||||
for (const forbidden of [
|
||||
'assembleDebug',
|
||||
'app-debug.apk',
|
||||
'apksigner',
|
||||
'copyFileSync',
|
||||
'execSync',
|
||||
'fetch(',
|
||||
'forgejoAuthorization',
|
||||
'writeFileSync',
|
||||
]) {
|
||||
assert(!retiredCapture.includes(forbidden), `retired_capture_contains_${forbidden.replace(/[^a-z0-9]+/gi, '_')}`)
|
||||
}
|
||||
assert(retiredCapture.includes('LEGACY_MOBILE_RELEASE_PIPELINE_DISABLED'), 'retired_capture_marker_missing')
|
||||
|
||||
const releaseWorkflow = readWorkspaceFile('.github/workflows/release.yml')
|
||||
for (const required of [
|
||||
'create-mobile-release-evidence.mjs',
|
||||
'prepare-mobile-release-publication.mjs',
|
||||
'ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64',
|
||||
'ANDROID_UPLOAD_CERT_SHA256',
|
||||
'release/android-release-identity.json',
|
||||
'release/mobile-release-evidence-public.pem',
|
||||
'--expected-admob-app-id',
|
||||
'--expected-upload-cert-sha256',
|
||||
'--repository "$GITHUB_REPOSITORY"',
|
||||
'--commit-sha "$GITHUB_SHA"',
|
||||
'--tree-sha "$(git rev-parse',
|
||||
'--git-ref "$GITHUB_REF"',
|
||||
'--workflow-identity "$GITHUB_WORKFLOW_REF"',
|
||||
'--run-id "$GITHUB_RUN_ID"',
|
||||
'--run-attempt "$GITHUB_RUN_ATTEMPT"',
|
||||
'--runner-identity "$RUNNER_NAME:$RUNNER_OS:$RUNNER_ARCH"',
|
||||
'test "$GITHUB_SHA" = "$(git rev-parse HEAD)"',
|
||||
'git status --porcelain --untracked-files=all',
|
||||
'--expected-play-app-signing-cert-sha256',
|
||||
'--forbidden-upload-cert-sha256',
|
||||
'--expected-version-name',
|
||||
'--expected-version-code',
|
||||
'--bundletool',
|
||||
'--snapshot-dir',
|
||||
'a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29',
|
||||
'environment: mobile-production-release',
|
||||
'fetch-depth: 0',
|
||||
'git merge-base --is-ancestor',
|
||||
'tag_name: ${{ steps.release-identity.outputs.tag }}',
|
||||
'overwrite_files: false',
|
||||
'name: android-play-console-handoff',
|
||||
'REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}',
|
||||
'test "$REPOSITORY_VISIBILITY" = "private"',
|
||||
'release-publication/app-release.aab',
|
||||
'release-publication/android-release-evidence.json',
|
||||
'release-publication/android-publication-manifest.json',
|
||||
'release-snapshot/release-artifact-verification.json',
|
||||
'retention-days: 7',
|
||||
]) {
|
||||
assert(releaseWorkflow.includes(required), `release_workflow_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`)
|
||||
}
|
||||
for (const forbidden of [
|
||||
'ANDROID_RELEASE_CERT_SHA256',
|
||||
'--expected-cert-sha256',
|
||||
'ANDROID_RELEASE_EVIDENCE_PUBLIC_KEY_B64',
|
||||
'name: android-release-assets',
|
||||
'path: android-source/',
|
||||
'release-dist/android',
|
||||
]) {
|
||||
assert(!releaseWorkflow.includes(forbidden), `release_workflow_forbidden_${forbidden.replace(/[^a-z0-9]+/gi, '_')}`)
|
||||
}
|
||||
const handoffStep = releaseWorkflow.match(
|
||||
/- name: Upload Restricted Play Console AAB Handoff[\s\S]*?(?=\n\s{6}- name:|\n\s{2}#)/,
|
||||
)?.[0] ?? ''
|
||||
assert(handoffStep.includes('app-release.aab'), 'restricted_handoff_aab_missing')
|
||||
assert(!handoffStep.includes('app-release.apk'), 'restricted_handoff_contains_upload_key_apk')
|
||||
const publicReleaseStep = releaseWorkflow.slice(releaseWorkflow.indexOf('- name: Create GitHub Release'))
|
||||
assert(!publicReleaseStep.includes('.apk'), 'public_release_contains_android_apk')
|
||||
assert(!publicReleaseStep.includes('.aab'), 'public_release_contains_android_aab')
|
||||
assert(!publicReleaseStep.includes('android-play-console-handoff'), 'public_release_contains_android_handoff')
|
||||
assert(releaseWorkflow.includes('test "$GITHUB_SHA" = "$(git rev-parse origin/main)"'), 'dispatch_main_sha_guard_missing')
|
||||
assert(releaseWorkflow.includes('test "$RELEASE_TAG" = "v$VERSION_NAME"'), 'release_tag_identity_guard_missing')
|
||||
|
||||
const packageJson = JSON.parse(readWorkspaceFile('package.json'))
|
||||
assert(
|
||||
packageJson.scripts?.['release:mobile:boundary'] === 'node scripts/ci/verify-mobile-release-boundary.mjs',
|
||||
'package_boundary_script_missing',
|
||||
)
|
||||
assert(
|
||||
packageJson.scripts?.['release:mobile:boundary:test'] === 'node scripts/ci/verify-mobile-release-boundary.mjs --self-test',
|
||||
'package_boundary_self_test_script_missing',
|
||||
)
|
||||
|
||||
const evidenceCreator = readWorkspaceFile('scripts/ci/create-mobile-release-evidence.mjs')
|
||||
for (const required of [
|
||||
'verify-android-artifact.mjs',
|
||||
"'--mode', 'release'",
|
||||
"'--expected-admob-app-id'",
|
||||
"'--expected-upload-cert-sha256'",
|
||||
"'--expected-version-name'",
|
||||
"'--expected-version-code'",
|
||||
"'--bundletool'",
|
||||
'createImmutableVerificationSnapshot',
|
||||
'signReleaseEvidence',
|
||||
"'repository'",
|
||||
"'commit-sha'",
|
||||
"'tree-sha'",
|
||||
"'git-ref'",
|
||||
"'workflow-identity'",
|
||||
"'run-id'",
|
||||
"'run-attempt'",
|
||||
"'runner-identity'",
|
||||
'hashRegularFileStable(snapshot.verifierPath)',
|
||||
'hashRegularFileStable(snapshot.bundletoolPath)',
|
||||
'verifier_changed_during_verification',
|
||||
'bundletool_changed_during_verification',
|
||||
]) {
|
||||
assert(evidenceCreator.includes(required), `evidence_creator_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`)
|
||||
}
|
||||
assert(!evidenceCreator.includes("'expected-cert-sha256'"), 'evidence_creator_legacy_certificate_argument')
|
||||
|
||||
const publicationPreparer = readWorkspaceFile('scripts/ci/prepare-mobile-release-publication.mjs')
|
||||
for (const required of [
|
||||
"'expected-upload-cert-sha256'",
|
||||
"'expected-repository'",
|
||||
"'expected-commit-sha'",
|
||||
"'expected-tree-sha'",
|
||||
"'expected-git-ref'",
|
||||
"'expected-workflow-identity'",
|
||||
"'expected-run-id'",
|
||||
"'expected-run-attempt'",
|
||||
"'expected-runner-identity'",
|
||||
"'expected-verifier-sha256'",
|
||||
"'expected-bundletool-sha256'",
|
||||
]) {
|
||||
assert(
|
||||
publicationPreparer.includes(required),
|
||||
`publication_preparer_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`,
|
||||
)
|
||||
}
|
||||
assert(
|
||||
!publicationPreparer.includes("'expected-cert-sha256'"),
|
||||
'publication_preparer_legacy_certificate_argument',
|
||||
)
|
||||
|
||||
const excluded = new Set([
|
||||
resolve(workspaceRoot, 'scripts/ci/verify-mobile-release-boundary.mjs'),
|
||||
])
|
||||
for (const scriptPath of listScripts(resolve(workspaceRoot, 'scripts'))) {
|
||||
if (excluded.has(scriptPath)) continue
|
||||
const source = readFileSync(scriptPath, 'utf8')
|
||||
const consumesDebugApk = /outputs[\\/]apk[\\/]debug[\\/]app-debug\.apk/i.test(source)
|
||||
const publishesArtifact = /(copyFile|upload|forgejo|release asset|fetch\s*\()/i.test(source)
|
||||
assert(!(consumesDebugApk && publishesArtifact), `debug_apk_publish_script_${relative(workspaceRoot, scriptPath)}`)
|
||||
}
|
||||
|
||||
const evidenceLibrary = readWorkspaceFile('scripts/ci/mobile-release-evidence-lib.mjs')
|
||||
for (const required of [
|
||||
'O_NOFOLLOW',
|
||||
'fstatSync',
|
||||
'COPYFILE_EXCL',
|
||||
'source_changed_during_snapshot',
|
||||
'hardlink_rejected',
|
||||
'reparse_rejected',
|
||||
'destination_directory_replaced',
|
||||
COMPROMISED_SIGNER_SHA256,
|
||||
'compromised_signer_rejected',
|
||||
'RELEASE_EVIDENCE_SCHEMA_VERSION = 2',
|
||||
'validateReleaseProvenance',
|
||||
'publication_provenance_',
|
||||
]) {
|
||||
assert(evidenceLibrary.includes(required), `toctou_contract_missing_${required}`)
|
||||
}
|
||||
|
||||
const artifactVerifier = readWorkspaceFile('scripts/ci/verify-android-artifact.mjs')
|
||||
for (const required of [
|
||||
'expected-upload-cert-sha256',
|
||||
"'dump', 'manifest'",
|
||||
"'jarsigner'",
|
||||
"'keytool'",
|
||||
'aab_package_name_mismatch',
|
||||
'aab_version_name_mismatch',
|
||||
'aab_admob_application_id_mismatch',
|
||||
'aab_signer_sha256_mismatch',
|
||||
COMPROMISED_SIGNER_SHA256,
|
||||
'release_signer_sha256',
|
||||
'_compromised',
|
||||
'--self-test',
|
||||
'assertArchivePathStable',
|
||||
'artifact_hardlink',
|
||||
]) {
|
||||
assert(artifactVerifier.includes(required), `artifact_verifier_contract_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`)
|
||||
}
|
||||
assert(!artifactVerifier.includes("['expected-cert-sha256']"), 'artifact_verifier_legacy_certificate_argument')
|
||||
|
||||
const appLinksVerifier = readWorkspaceFile('scripts/ci/verify-android-app-links.mjs')
|
||||
for (const required of [
|
||||
'expected-play-app-signing-cert-sha256',
|
||||
'forbidden-upload-cert-sha256',
|
||||
COMPROMISED_SIGNER_SHA256,
|
||||
'expectedPlayAppSigningCertificateVerified',
|
||||
'uploadCertificateExcluded',
|
||||
'--self-test',
|
||||
]) {
|
||||
assert(appLinksVerifier.includes(required), `app_links_verifier_contract_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`)
|
||||
}
|
||||
|
||||
const siteConfig = readWorkspaceFile('site/vite.config.ts')
|
||||
assert(siteConfig.includes('publicDir: false'), 'site_public_directory_not_isolated')
|
||||
assert(!siteConfig.includes("'releases/"), 'site_release_binary_allowlisted')
|
||||
for (const safePublicPath of [
|
||||
'site/public/.well-known/assetlinks.json',
|
||||
'site/public/accept-invite.css',
|
||||
'site/public/accept-invite.html',
|
||||
'site/public/accept-invite.js',
|
||||
'site/public/accept-invite/index.html',
|
||||
'site/public/download.html',
|
||||
'site/public/favicon.svg',
|
||||
]) {
|
||||
const safePublicSource = readWorkspaceFile(safePublicPath)
|
||||
assert(!/(?:d3ro-voice[^"']*\.apk|git\.chanpaca\.net\/attachments\/(?:0b015367-dd8b-488c-8cc0-4db413b51792|d2e1b123-5678-496a-bf74-bc188938c999))/i.test(safePublicSource), `safe_public_mobile_reference_${safePublicPath}`)
|
||||
}
|
||||
const siteDeployWorkflow = readWorkspaceFile('.github/workflows/deploy-site.yml')
|
||||
assert(siteDeployWorkflow.includes('verify-mobile-release-boundary.mjs --self-test'), 'pages_deploy_boundary_gate_missing')
|
||||
const deploySite = readWorkspaceFile('scripts/deploy-site-to-nas.js')
|
||||
assert(deploySite.includes('assertNoMobileArtifacts'), 'nas_mobile_artifact_guard_missing')
|
||||
assert(!deploySite.includes('sync-and-publish-forgejo-release'), 'nas_legacy_release_sync_enabled')
|
||||
const apiProject = readWorkspaceFile('apps/api-server/D3ROVoice.Api.csproj')
|
||||
assert(apiProject.includes('<Content Remove="wwwroot\\releases\\**\\*" />'), 'api_static_release_exclusion_missing')
|
||||
for (const staleAsset of ['index-D7M5UQvT.js', 'index-JlYFxlAJ.js']) {
|
||||
assert(apiProject.includes(`<Content Remove="wwwroot\\assets\\${staleAsset}" />`), `api_stale_marketing_asset_publishable_${staleAsset}`)
|
||||
}
|
||||
const apiProgram = readWorkspaceFile('apps/api-server/Program.cs')
|
||||
assert(apiProgram.includes('mobileReleasePath') && apiProgram.indexOf('mobileReleasePath') < apiProgram.indexOf('app.UseStaticFiles()'), 'api_runtime_mobile_release_guard_missing')
|
||||
assert(apiProgram.includes('legacyMarketingAsset'), 'api_runtime_legacy_marketing_guard_missing')
|
||||
const osHook = readWorkspaceFile('site/src/hooks/useClientOS.ts')
|
||||
const androidConfig = osHook.match(/android:\s*\{[\s\S]*?\n\s*\},/)?.[0] ?? ''
|
||||
assert(androidConfig.includes("downloadUrl: '#download'"), 'android_download_not_unavailable')
|
||||
assert(!androidConfig.includes('attachments/'), 'android_attachment_link_enabled')
|
||||
const downloadUi = readWorkspaceFile('site/src/sections/Download.tsx')
|
||||
assert(downloadUi.includes('aria-disabled="true"'), 'android_download_ui_not_disabled')
|
||||
assert(!/attachments\/[0-9a-f-]+[\s\S]{0,120}\.apk/i.test(downloadUi), 'android_direct_attachment_enabled')
|
||||
}
|
||||
|
||||
function expectFailure(label, operation, expectedCode) {
|
||||
let thrown = null
|
||||
try {
|
||||
operation()
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
assert(thrown instanceof Error, `${label}_did_not_fail`)
|
||||
assert(thrown.message.includes(expectedCode), `${label}_wrong_failure_${thrown.message}`)
|
||||
}
|
||||
|
||||
function createFixture(root, privateKeyPem) {
|
||||
const sourceRoot = join(root, 'source')
|
||||
const apkDirectory = join(sourceRoot, 'apk', 'release')
|
||||
const aabDirectory = join(sourceRoot, 'bundle', 'release')
|
||||
mkdirSync(apkDirectory, { recursive: true })
|
||||
mkdirSync(aabDirectory, { recursive: true })
|
||||
const apkPath = join(apkDirectory, RELEASE_APK_NAME)
|
||||
const aabPath = join(aabDirectory, RELEASE_AAB_NAME)
|
||||
writeFileSync(apkPath, Buffer.from('fixture production apk\n'.repeat(80)))
|
||||
writeFileSync(aabPath, Buffer.from('fixture production aab\n'.repeat(90)))
|
||||
const apk = hashRegularFileStable(apkPath)
|
||||
const aab = hashRegularFileStable(aabPath)
|
||||
const verification = {
|
||||
artifact: RELEASE_APK_NAME,
|
||||
mode: 'release',
|
||||
packageName: RELEASE_PACKAGE_NAME,
|
||||
buildTools: 'fixture',
|
||||
apkSha256: apk.sha256,
|
||||
signerSha256: productionSigner,
|
||||
debuggable: false,
|
||||
adMobAppId: productionAdMobId,
|
||||
versionName,
|
||||
versionCode,
|
||||
bundleBytes: 123_456,
|
||||
modelBytes: 77_691_713,
|
||||
modelSha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21',
|
||||
abis: ['arm64-v8a'],
|
||||
aab: {
|
||||
artifact: RELEASE_AAB_NAME,
|
||||
sha256: aab.sha256,
|
||||
packageName: RELEASE_PACKAGE_NAME,
|
||||
signerSha256: productionSigner,
|
||||
debuggable: false,
|
||||
adMobAppId: productionAdMobId,
|
||||
versionName,
|
||||
versionCode,
|
||||
bundleBytes: 123_456,
|
||||
modelBytes: 77_691_713,
|
||||
modelSha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21',
|
||||
abis: ['arm64-v8a'],
|
||||
},
|
||||
}
|
||||
const expected = {
|
||||
versionName,
|
||||
versionCode,
|
||||
adMobAppId: productionAdMobId,
|
||||
signerSha256: productionSigner,
|
||||
}
|
||||
const provenance = structuredClone(releaseProvenance)
|
||||
expected.provenance = structuredClone(provenance)
|
||||
const payload = buildReleasePayload({ verification, apkPath, aabPath, expected, provenance })
|
||||
const evidence = signReleaseEvidence(payload, privateKeyPem)
|
||||
const evidencePath = join(sourceRoot, SIGNED_EVIDENCE_NAME)
|
||||
writeJsonCreateOnly(evidencePath, evidence)
|
||||
return {
|
||||
sourceRoot,
|
||||
apkPath,
|
||||
aabPath,
|
||||
evidencePath,
|
||||
evidence,
|
||||
payload,
|
||||
expected,
|
||||
provenance,
|
||||
verification,
|
||||
}
|
||||
}
|
||||
|
||||
function verifyNegativeAndMaterializationTests() {
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), 'd3ro-mobile-release-boundary-'))
|
||||
const trustedTempRoot = realpathSync(tmpdir())
|
||||
const resolvedTemporaryRoot = realpathSync(temporaryRoot)
|
||||
assert(
|
||||
resolvedTemporaryRoot.startsWith(`${trustedTempRoot}${sep}`),
|
||||
'temporary_root_outside_system_temp',
|
||||
)
|
||||
try {
|
||||
const creatorOptions = {
|
||||
aab: 'fixture',
|
||||
apk: 'fixture',
|
||||
bundletool: 'fixture',
|
||||
'commit-sha': releaseProvenance.commitSha,
|
||||
'expected-admob-app-id': productionAdMobId,
|
||||
'expected-upload-cert-sha256': productionSigner,
|
||||
'expected-version-code': versionCode,
|
||||
'expected-version-name': versionName,
|
||||
'git-ref': releaseProvenance.gitRef,
|
||||
'private-key': 'fixture',
|
||||
repository: releaseProvenance.repository,
|
||||
'run-attempt': releaseProvenance.runAttempt,
|
||||
'run-id': releaseProvenance.runId,
|
||||
'runner-identity': releaseProvenance.runnerIdentity,
|
||||
'snapshot-dir': 'fixture',
|
||||
'tree-sha': releaseProvenance.treeSha,
|
||||
'workflow-identity': releaseProvenance.workflowIdentity,
|
||||
}
|
||||
for (const name of [
|
||||
'repository',
|
||||
'commit-sha',
|
||||
'tree-sha',
|
||||
'git-ref',
|
||||
'workflow-identity',
|
||||
'run-id',
|
||||
'run-attempt',
|
||||
'runner-identity',
|
||||
]) {
|
||||
runCliExpectedFailure(
|
||||
'scripts/ci/create-mobile-release-evidence.mjs',
|
||||
creatorOptions,
|
||||
name,
|
||||
`create_evidence_argument_missing_${name}`,
|
||||
)
|
||||
}
|
||||
|
||||
const publicationOptions = {
|
||||
aab: 'fixture',
|
||||
apk: 'fixture',
|
||||
'destination-dir': 'fixture',
|
||||
evidence: 'fixture',
|
||||
'expected-admob-app-id': productionAdMobId,
|
||||
'expected-bundletool-sha256': releaseProvenance.bundletoolSha256,
|
||||
'expected-commit-sha': releaseProvenance.commitSha,
|
||||
'expected-git-ref': releaseProvenance.gitRef,
|
||||
'expected-repository': releaseProvenance.repository,
|
||||
'expected-run-attempt': releaseProvenance.runAttempt,
|
||||
'expected-run-id': releaseProvenance.runId,
|
||||
'expected-runner-identity': releaseProvenance.runnerIdentity,
|
||||
'expected-tree-sha': releaseProvenance.treeSha,
|
||||
'expected-upload-cert-sha256': productionSigner,
|
||||
'expected-verifier-sha256': releaseProvenance.verifierSha256,
|
||||
'expected-version-code': versionCode,
|
||||
'expected-version-name': versionName,
|
||||
'expected-workflow-identity': releaseProvenance.workflowIdentity,
|
||||
'public-key': 'fixture',
|
||||
'source-root': 'fixture',
|
||||
}
|
||||
for (const name of [
|
||||
'expected-repository',
|
||||
'expected-commit-sha',
|
||||
'expected-tree-sha',
|
||||
'expected-git-ref',
|
||||
'expected-workflow-identity',
|
||||
'expected-run-id',
|
||||
'expected-run-attempt',
|
||||
'expected-runner-identity',
|
||||
'expected-verifier-sha256',
|
||||
'expected-bundletool-sha256',
|
||||
]) {
|
||||
runCliExpectedFailure(
|
||||
'scripts/ci/prepare-mobile-release-publication.mjs',
|
||||
publicationOptions,
|
||||
name,
|
||||
`prepare_publication_argument_missing_${name}`,
|
||||
)
|
||||
}
|
||||
|
||||
runVerifierSelfTest('scripts/ci/verify-android-artifact.mjs')
|
||||
runVerifierSelfTest('scripts/ci/verify-android-app-links.mjs')
|
||||
const keyPair = generateKeyPairSync('ed25519')
|
||||
const otherKeyPair = generateKeyPairSync('ed25519')
|
||||
const privateKeyPem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' })
|
||||
const publicKeyPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' })
|
||||
const publicKeyPath = join(temporaryRoot, 'trusted-public.pem')
|
||||
const otherPublicKeyPath = join(temporaryRoot, 'other-public.pem')
|
||||
writeFileSync(publicKeyPath, publicKeyPem, { flag: 'wx', mode: 0o600 })
|
||||
writeFileSync(
|
||||
otherPublicKeyPath,
|
||||
otherKeyPair.publicKey.export({ type: 'spki', format: 'pem' }),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
)
|
||||
const fixture = createFixture(temporaryRoot, privateKeyPem)
|
||||
const rawSnapshotSource = join(temporaryRoot, 'raw-snapshot-source')
|
||||
mkdirSync(rawSnapshotSource)
|
||||
const rawApk = join(rawSnapshotSource, RELEASE_APK_NAME)
|
||||
const rawAab = join(rawSnapshotSource, RELEASE_AAB_NAME)
|
||||
const rawVerifier = join(rawSnapshotSource, 'verify-android-artifact.mjs')
|
||||
const rawBundletool = join(rawSnapshotSource, 'bundletool-fixture.jar')
|
||||
writeFileSync(rawApk, 'raw apk snapshot fixture')
|
||||
writeFileSync(rawAab, 'raw aab snapshot fixture')
|
||||
writeFileSync(rawVerifier, 'raw verifier snapshot fixture')
|
||||
writeFileSync(rawBundletool, 'raw bundletool snapshot fixture')
|
||||
const immutableSnapshot = createImmutableVerificationSnapshot({
|
||||
apkPath: rawApk,
|
||||
aabPath: rawAab,
|
||||
destinationDirectory: join(temporaryRoot, 'immutable-verification-snapshot'),
|
||||
verifierPath: rawVerifier,
|
||||
bundletoolPath: rawBundletool,
|
||||
})
|
||||
assert(hashRegularFileStable(immutableSnapshot.apkPath).sha256 === hashRegularFileStable(rawApk).sha256, 'immutable_apk_snapshot_mismatch')
|
||||
assert(hashRegularFileStable(immutableSnapshot.aabPath).sha256 === hashRegularFileStable(rawAab).sha256, 'immutable_aab_snapshot_mismatch')
|
||||
assert(hashRegularFileStable(immutableSnapshot.verifierPath).sha256 === hashRegularFileStable(rawVerifier).sha256, 'immutable_verifier_snapshot_mismatch')
|
||||
assert(hashRegularFileStable(immutableSnapshot.bundletoolPath).sha256 === hashRegularFileStable(rawBundletool).sha256, 'immutable_bundletool_snapshot_mismatch')
|
||||
const destination = join(temporaryRoot, 'sealed-release')
|
||||
const prepared = prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: destination,
|
||||
})
|
||||
assert(prepared.manifest.packageName === RELEASE_PACKAGE_NAME, 'happy_package_mismatch')
|
||||
assert(existsSync(join(destination, RELEASE_APK_NAME)), 'happy_apk_missing')
|
||||
assert(existsSync(join(destination, RELEASE_AAB_NAME)), 'happy_aab_missing')
|
||||
|
||||
expectFailure('overwrite', () => prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: destination,
|
||||
}), 'destination_must_not_exist')
|
||||
|
||||
expectFailure('wrong_key', () => prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
publicKeyPath: otherPublicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'wrong-key-output'),
|
||||
}), 'signature_key_mismatch')
|
||||
|
||||
expectFailure('wrong_version', () => prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'wrong-version-output'),
|
||||
expected: { ...fixture.expected, versionName: '9.8.8' },
|
||||
}), 'publication_version_name_mismatch')
|
||||
expectFailure('wrong_certificate', () => prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'wrong-certificate-output'),
|
||||
expected: { ...fixture.expected, signerSha256: '1'.repeat(64) },
|
||||
}), 'publication_signer_mismatch')
|
||||
expectFailure('compromised_expected_publication_certificate', () => prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'compromised-certificate-output'),
|
||||
expected: { ...fixture.expected, signerSha256: COMPROMISED_SIGNER_SHA256 },
|
||||
}), 'publication_expected_compromised_signer')
|
||||
expectFailure('wrong_admob', () => prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'wrong-admob-output'),
|
||||
expected: { ...fixture.expected, adMobAppId: 'ca-app-pub-1234567890123456~1234567891' },
|
||||
}), 'publication_admob_app_id_mismatch')
|
||||
|
||||
for (const [field, mismatch, code] of [
|
||||
['repository', 'attacker/D3ROVoice', 'repository'],
|
||||
['commitSha', 'e'.repeat(40), 'commit_sha'],
|
||||
['treeSha', 'f'.repeat(40), 'tree_sha'],
|
||||
['gitRef', 'refs/tags/v9.8.8', 'git_ref'],
|
||||
['workflowIdentity', 'github:.github/workflows/release.yml:attacker', 'workflow_identity'],
|
||||
['runId', '987654321', 'run_id'],
|
||||
['runAttempt', 2, 'run_attempt'],
|
||||
['runnerIdentity', 'self-hosted:attacker', 'runner_identity'],
|
||||
['verifierSha256', '1'.repeat(64), 'verifier_sha256'],
|
||||
['bundletoolSha256', '2'.repeat(64), 'bundletool_sha256'],
|
||||
]) {
|
||||
expectFailure(`wrong_provenance_${code}`, () => prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, `wrong-provenance-${code}-output`),
|
||||
expected: {
|
||||
...fixture.expected,
|
||||
provenance: { ...fixture.expected.provenance, [field]: mismatch },
|
||||
},
|
||||
}), `publication_provenance_${code}_mismatch`)
|
||||
}
|
||||
|
||||
const missingProvenanceField = structuredClone(fixture.payload)
|
||||
delete missingProvenanceField.provenance.treeSha
|
||||
expectFailure('missing_provenance_field', () => signReleaseEvidence(
|
||||
missingProvenanceField,
|
||||
privateKeyPem,
|
||||
), 'provenance_keys_invalid')
|
||||
|
||||
expectFailure('extra_provenance_field', () => signReleaseEvidence({
|
||||
...fixture.payload,
|
||||
provenance: { ...fixture.payload.provenance, untrusted: 'extra' },
|
||||
}, privateKeyPem), 'provenance_keys_invalid')
|
||||
expectFailure('noncanonical_run_attempt', () => signReleaseEvidence({
|
||||
...fixture.payload,
|
||||
provenance: { ...fixture.payload.provenance, runAttempt: '01' },
|
||||
}, privateKeyPem), 'provenance_run_attempt_invalid')
|
||||
|
||||
const missingExpectedProvenanceField = structuredClone(fixture.expected.provenance)
|
||||
delete missingExpectedProvenanceField.runnerIdentity
|
||||
expectFailure('missing_expected_provenance_field', () => prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'missing-expected-provenance-output'),
|
||||
expected: { ...fixture.expected, provenance: missingExpectedProvenanceField },
|
||||
}), 'expected_provenance_keys_invalid')
|
||||
|
||||
const legacySchemaFixture = createFixture(join(temporaryRoot, 'legacy-schema-case'), privateKeyPem)
|
||||
const legacySchemaEvidence = { ...legacySchemaFixture.evidence, schemaVersion: 1 }
|
||||
writeFileSync(
|
||||
legacySchemaFixture.evidencePath,
|
||||
`${JSON.stringify(legacySchemaEvidence)}\n`,
|
||||
{ flag: 'w', mode: 0o600 },
|
||||
)
|
||||
expectFailure('legacy_evidence_schema', () => prepareVerifiedReleasePublication({
|
||||
...legacySchemaFixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'legacy-schema-output'),
|
||||
}), 'evidence_schema_version')
|
||||
|
||||
const tamperedFixture = createFixture(join(temporaryRoot, 'tampered-case'), privateKeyPem)
|
||||
const tamperedEvidence = structuredClone(tamperedFixture.evidence)
|
||||
tamperedEvidence.payload.versionCode += 1
|
||||
writeFileSync(
|
||||
tamperedFixture.evidencePath,
|
||||
`${JSON.stringify(tamperedEvidence)}\n`,
|
||||
{ flag: 'w', mode: 0o600 },
|
||||
)
|
||||
expectFailure('tampered_signature', () => prepareVerifiedReleasePublication({
|
||||
...tamperedFixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'tampered-output'),
|
||||
}), 'signature_invalid')
|
||||
|
||||
const provenanceTamperedFixture = createFixture(
|
||||
join(temporaryRoot, 'provenance-tampered-case'),
|
||||
privateKeyPem,
|
||||
)
|
||||
const provenanceTamperedEvidence = structuredClone(provenanceTamperedFixture.evidence)
|
||||
provenanceTamperedEvidence.payload.provenance.commitSha = 'e'.repeat(40)
|
||||
writeFileSync(
|
||||
provenanceTamperedFixture.evidencePath,
|
||||
`${JSON.stringify(provenanceTamperedEvidence)}\n`,
|
||||
{ flag: 'w', mode: 0o600 },
|
||||
)
|
||||
expectFailure('tampered_provenance_signature', () => prepareVerifiedReleasePublication({
|
||||
...provenanceTamperedFixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'provenance-tampered-output'),
|
||||
}), 'signature_invalid')
|
||||
|
||||
expectFailure('nonrelease_mode', () => signReleaseEvidence({
|
||||
...fixture.payload,
|
||||
mode: 'e2e',
|
||||
}, privateKeyPem), 'nonrelease_mode_rejected')
|
||||
expectFailure('debuggable', () => signReleaseEvidence({
|
||||
...fixture.payload,
|
||||
debuggable: true,
|
||||
}, privateKeyPem), 'debuggable_release_rejected')
|
||||
expectFailure('test_admob', () => signReleaseEvidence({
|
||||
...fixture.payload,
|
||||
adMobAppId: 'ca-app-pub-3940256099942544~3347511713',
|
||||
}, privateKeyPem), 'test_admob_rejected')
|
||||
expectFailure('debug_signer', () => signReleaseEvidence({
|
||||
...fixture.payload,
|
||||
signerSha256: 'fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c',
|
||||
}, privateKeyPem), 'debug_signer_rejected')
|
||||
expectFailure('compromised_signer', () => signReleaseEvidence({
|
||||
...fixture.payload,
|
||||
signerSha256: COMPROMISED_SIGNER_SHA256,
|
||||
}, privateKeyPem), 'compromised_signer_rejected')
|
||||
expectFailure('package_name', () => signReleaseEvidence({
|
||||
...fixture.payload,
|
||||
packageName: 'com.attacker.voice',
|
||||
}, privateKeyPem), 'package_name_mismatch')
|
||||
expectFailure('path_escape_name', () => signReleaseEvidence({
|
||||
...fixture.payload,
|
||||
apk: { ...fixture.payload.apk, fileName: '../app-release.apk' },
|
||||
}, privateKeyPem), 'apk_file_name_invalid')
|
||||
|
||||
for (const [label, patch, code] of [
|
||||
['aab_package', { packageName: 'com.attacker.voice' }, 'verification_aab_package_mismatch'],
|
||||
['aab_version_name', { versionName: '9.8.8' }, 'aab_apk_version_name_mismatch'],
|
||||
['aab_version_code', { versionCode: versionCode + 1 }, 'aab_apk_version_code_mismatch'],
|
||||
['aab_admob', { adMobAppId: 'ca-app-pub-1234567890123456~1234567891' }, 'aab_apk_admob_app_id_mismatch'],
|
||||
['aab_signer', { signerSha256: '1'.repeat(64) }, 'aab_apk_signer_mismatch'],
|
||||
['aab_compromised_signer', { signerSha256: COMPROMISED_SIGNER_SHA256 }, 'verification_aab_compromised_signer'],
|
||||
['aab_debuggable', { debuggable: true }, 'verification_aab_debuggable'],
|
||||
]) {
|
||||
expectFailure(label, () => buildReleasePayload({
|
||||
verification: { ...fixture.verification, aab: { ...fixture.verification.aab, ...patch } },
|
||||
apkPath: fixture.apkPath,
|
||||
aabPath: fixture.aabPath,
|
||||
expected: fixture.expected,
|
||||
provenance: fixture.provenance,
|
||||
}), code)
|
||||
}
|
||||
|
||||
expectFailure('verification_compromised_signer', () => buildReleasePayload({
|
||||
verification: { ...fixture.verification, signerSha256: COMPROMISED_SIGNER_SHA256 },
|
||||
apkPath: fixture.apkPath,
|
||||
aabPath: fixture.aabPath,
|
||||
expected: fixture.expected,
|
||||
provenance: fixture.provenance,
|
||||
}), 'verification_compromised_signer')
|
||||
expectFailure('expected_compromised_signer', () => buildReleasePayload({
|
||||
verification: fixture.verification,
|
||||
apkPath: fixture.apkPath,
|
||||
aabPath: fixture.aabPath,
|
||||
expected: { ...fixture.expected, signerSha256: COMPROMISED_SIGNER_SHA256 },
|
||||
provenance: fixture.provenance,
|
||||
}), 'expected_compromised_signer')
|
||||
|
||||
const hardlinkPath = join(temporaryRoot, 'hardlinked.apk')
|
||||
linkSync(fixture.apkPath, hardlinkPath)
|
||||
expectFailure('hardlink_source', () => hashRegularFileStable(hardlinkPath), 'hardlink_rejected')
|
||||
rmSync(hardlinkPath)
|
||||
|
||||
const unsafeSite = join(temporaryRoot, 'unsafe-site')
|
||||
mkdirSync(unsafeSite)
|
||||
writeFileSync(join(unsafeSite, 'legacy.js'), 'location.href="https://git.chanpaca.net/attachments/0b015367-dd8b-488c-8cc0-4db413b51792"')
|
||||
expectFailure('static_link_bypass', () => assertNoMobileArtifacts(unsafeSite), 'Legacy mobile download link blocked')
|
||||
rmSync(join(unsafeSite, 'legacy.js'))
|
||||
writeFileSync(join(unsafeSite, 'unsealed.apk'), 'not a release')
|
||||
expectFailure('static_apk_bypass', () => assertNoMobileArtifacts(unsafeSite), 'Unsealed mobile artifact blocked')
|
||||
|
||||
const outsideRoot = join(temporaryRoot, 'outside')
|
||||
mkdirSync(outsideRoot)
|
||||
const outsideApk = join(outsideRoot, RELEASE_APK_NAME)
|
||||
writeFileSync(outsideApk, 'outside')
|
||||
expectFailure('outside_source_root', () => prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
apkPath: outsideApk,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'outside-output'),
|
||||
}), `${RELEASE_APK_NAME}_outside_source_root`)
|
||||
|
||||
const swappedOriginal = `${fixture.apkPath}.original`
|
||||
renameSync(fixture.apkPath, swappedOriginal)
|
||||
writeFileSync(fixture.apkPath, Buffer.alloc(fixture.payload.apk.bytes, 0x58))
|
||||
expectFailure('artifact_path_swap', () => prepareVerifiedReleasePublication({
|
||||
...fixture,
|
||||
publicKeyPath,
|
||||
destinationDirectory: join(temporaryRoot, 'hash-output'),
|
||||
}), `${RELEASE_APK_NAME}_hash_mismatch`)
|
||||
assert(!existsSync(join(temporaryRoot, 'hash-output')), 'failed_snapshot_destination_not_cleaned')
|
||||
|
||||
expectFailure('verification_nonrelease', () => buildReleasePayload({
|
||||
verification: { ...fixture.verification, mode: 'e2e' },
|
||||
apkPath: fixture.apkPath,
|
||||
aabPath: fixture.aabPath,
|
||||
expected: fixture.expected,
|
||||
provenance: fixture.provenance,
|
||||
}), 'verification_nonrelease_mode')
|
||||
|
||||
const createOnlyPath = join(temporaryRoot, 'create-only.json')
|
||||
writeJsonCreateOnly(createOnlyPath, { first: true })
|
||||
expectFailure('create_only_evidence', () => writeJsonCreateOnly(createOnlyPath, { second: true }), 'EEXIST')
|
||||
} finally {
|
||||
const finalRoot = realpathSync(temporaryRoot)
|
||||
if (!finalRoot.startsWith(`${trustedTempRoot}${sep}`)) fail('cleanup_target_outside_temp')
|
||||
rmSync(finalRoot, { recursive: true, force: false })
|
||||
}
|
||||
}
|
||||
|
||||
const cliArguments = process.argv.slice(2)
|
||||
const selfTest = cliArguments.includes('--self-test')
|
||||
if (cliArguments.some((argument) => argument !== '--self-test')) fail('unexpected_argument')
|
||||
verifySourceContracts()
|
||||
if (selfTest) verifyNegativeAndMaterializationTests()
|
||||
process.stdout.write(`${canonicalJson({
|
||||
ok: true,
|
||||
sourceContracts: true,
|
||||
negativeSelfTests: selfTest,
|
||||
})}\n`)
|
||||
Loading…
Add table
Add a link
Reference in a new issue