859 lines
36 KiB
JavaScript
859 lines
36 KiB
JavaScript
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 gitlabWorkflow = readWorkspaceFile('.gitlab-ci.yml')
|
|
const mobileProductionJobStart = gitlabWorkflow.indexOf('mobile-production-release:\n')
|
|
const publishReleaseJobStart = gitlabWorkflow.indexOf('\npublish-release:\n')
|
|
assert(mobileProductionJobStart >= 0, 'gitlab_mobile_production_job_missing')
|
|
assert(publishReleaseJobStart > mobileProductionJobStart, 'gitlab_publish_release_job_missing')
|
|
const mobileProductionJob = gitlabWorkflow.slice(mobileProductionJobStart, publishReleaseJobStart)
|
|
const publishReleaseJob = gitlabWorkflow.slice(publishReleaseJobStart)
|
|
for (const required of [
|
|
'git fetch --no-tags origin main:refs/remotes/origin/main',
|
|
'test "${CI_COMMIT_REF_PROTECTED:-}" = "true"',
|
|
'test -z "${CI_COMMIT_BRANCH:-}"',
|
|
'test "$CI_COMMIT_TAG" = "v${VERSION_NAME}"',
|
|
'test "$(git rev-parse "refs/tags/${CI_COMMIT_TAG}^{commit}")" = "$CI_COMMIT_SHA"',
|
|
'RELEASE_GIT_REF="refs/tags/${CI_COMMIT_TAG}"',
|
|
'test "${CI_COMMIT_BRANCH:-}" = "main"',
|
|
'test "$CI_COMMIT_SHA" = "$(git rev-parse origin/main)"',
|
|
'RELEASE_GIT_REF="refs/heads/main"',
|
|
'--git-ref "$RELEASE_GIT_REF"',
|
|
'--expected-git-ref "$RELEASE_GIT_REF"',
|
|
'$CI_COMMIT_REF_PROTECTED == "true" && $CI_COMMIT_TAG =~ /^v\\d+\\.\\d+\\.\\d+$/',
|
|
'$CI_COMMIT_REF_PROTECTED == "true" && $CI_COMMIT_BRANCH == "main"',
|
|
]) {
|
|
assert(
|
|
mobileProductionJob.includes(required),
|
|
`gitlab_mobile_candidate_contract_missing_${required.replace(/[^a-z0-9]+/gi, '_')}`,
|
|
)
|
|
}
|
|
assert(
|
|
(mobileProductionJob.match(/\n\s+when: manual/g) ?? []).length === 2,
|
|
'gitlab_mobile_candidate_manual_rules_invalid',
|
|
)
|
|
assert(
|
|
!mobileProductionJob.includes('--git-ref "refs/tags/$CI_COMMIT_TAG"'),
|
|
'gitlab_mobile_evidence_ref_tag_only',
|
|
)
|
|
assert(
|
|
publishReleaseJob.includes('$CI_COMMIT_TAG =~ /^v\\d+\\.\\d+\\.\\d+.*$/'),
|
|
'gitlab_public_release_stable_tag_rule_missing',
|
|
)
|
|
assert(!publishReleaseJob.includes('CI_COMMIT_BRANCH'), 'gitlab_public_release_branch_trigger_enabled')
|
|
|
|
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`)
|