d3ro-voice/scripts/ci/verify-android-app-links.mjs
2026-08-29 18:33:45 +09:00

295 lines
11 KiB
JavaScript

import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const PACKAGE_NAME = 'com.d3ro.voice'
const REQUIRED_RELATION = 'delegate_permission/common.handle_all_urls'
const LIVE_URL = 'https://d3ro.chanpaca.net/.well-known/assetlinks.json'
const COMPROMISED_SIGNER_SHA256 = '06eec757722ee7cd3dfbc53202d974aaf0417a7d397f9ef1e5611088ebb2e481'
const SOURCE_PATHS = [
'site/public/.well-known/assetlinks.json',
'apps/web/public/.well-known/assetlinks.json',
'apps/api-server/wwwroot/.well-known/assetlinks.json',
]
const releaseIdentity = JSON.parse(
await readFile(resolve('release/android-release-identity.json'), 'utf8'),
)
function fail(message) {
throw new Error(`android_app_links_invalid:${message}`)
}
function normalizeFingerprint(value) {
if (typeof value !== 'string') fail('certificate_fingerprint_not_string')
const normalized = value.replace(/:/g, '').toUpperCase()
if (!/^[0-9A-F]{64}$/.test(normalized)) fail('certificate_fingerprint_format')
return normalized
}
function rejectCompromisedFingerprint(value, label) {
const normalized = normalizeFingerprint(value)
if (normalized === COMPROMISED_SIGNER_SHA256.toUpperCase()) {
fail(`${label}_compromised_certificate`)
}
return normalized
}
function parseArguments(argv) {
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index]
if (!current.startsWith('--')) fail(`unexpected_argument_${current}`)
const value = argv[index + 1]
if (!value || value.startsWith('--')) fail(`missing_value_${current.slice(2)}`)
options[current.slice(2)] = value
index += 1
}
const allowed = new Set([
'expected-play-app-signing-cert-sha256',
'forbidden-upload-cert-sha256',
'live-url',
])
for (const name of Object.keys(options)) {
if (!allowed.has(name)) fail(`unexpected_argument_${name}`)
}
return options
}
function fingerprintsFromDocument(document, label) {
if (!Array.isArray(document)) fail(`${label}_root_not_array`)
const matchingEntries = document.filter((entry) => (
entry?.target?.namespace === 'android_app'
&& entry?.target?.package_name === PACKAGE_NAME
))
if (matchingEntries.length !== 1) fail(`${label}_package_entry_count_${matchingEntries.length}`)
const entry = matchingEntries[0]
if (!Array.isArray(entry.relation) || !entry.relation.includes(REQUIRED_RELATION)) {
fail(`${label}_required_relation_missing`)
}
if (!Array.isArray(entry.target.sha256_cert_fingerprints)) {
fail(`${label}_fingerprints_missing`)
}
const fingerprints = new Set(entry.target.sha256_cert_fingerprints.map((fingerprint) => (
rejectCompromisedFingerprint(fingerprint, label)
)))
if (fingerprints.size === 0) fail(`${label}_fingerprints_empty`)
if (fingerprints.size !== entry.target.sha256_cert_fingerprints.length) {
fail(`${label}_duplicate_fingerprints`)
}
return [...fingerprints].sort()
}
function rejectUploadCertificateAssociation(fingerprints, uploadFingerprint, label) {
if (uploadFingerprint && fingerprints.includes(uploadFingerprint)) {
fail(`${label}_contains_upload_certificate`)
}
}
function requireExactPlayCertificate(fingerprints, expectedFingerprint, label) {
if (!expectedFingerprint) return
if (fingerprints.length !== 1 || fingerprints[0] !== expectedFingerprint) {
fail(`${label}_play_certificate_set_mismatch`)
}
}
function validateLiveResponseMetadata(response, requestedUrl) {
if (response.status !== 200) fail(`live_http_${response.status}`)
if (response.redirected) fail('live_redirected')
if (new URL(response.url).href !== new URL(requestedUrl).href) {
fail('live_final_url_mismatch')
}
const contentType = response.headers.get('content-type') ?? ''
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
fail('live_content_type')
}
}
function runSelfTest() {
const safeFingerprint = '4FAC6924821C50DAABED764932A53C486F8C6C5F34B9F18DB920AA4099152B54'
const safeDocument = [{
relation: [REQUIRED_RELATION],
target: {
namespace: 'android_app',
package_name: PACKAGE_NAME,
sha256_cert_fingerprints: [safeFingerprint],
},
}]
if (fingerprintsFromDocument(safeDocument, 'self_test')[0] !== safeFingerprint) {
fail('self_test_safe_fingerprint_mismatch')
}
const uploadFingerprint = '7A'.repeat(32)
rejectUploadCertificateAssociation([safeFingerprint], uploadFingerprint, 'self_test_safe')
let uploadAssociationRejected = false
try {
rejectUploadCertificateAssociation([uploadFingerprint], uploadFingerprint, 'self_test')
} catch (error) {
uploadAssociationRejected = error instanceof Error
&& error.message.includes('self_test_contains_upload_certificate')
}
if (!uploadAssociationRejected) fail('self_test_upload_certificate_association_not_rejected')
let compromisedRejected = false
try {
fingerprintsFromDocument([{
...safeDocument[0],
target: {
...safeDocument[0].target,
sha256_cert_fingerprints: [COMPROMISED_SIGNER_SHA256],
},
}], 'self_test')
} catch (error) {
compromisedRejected = error instanceof Error
&& error.message.includes('self_test_compromised_certificate')
}
if (!compromisedRejected) fail('self_test_compromised_certificate_not_rejected')
let legacyArgumentRejected = false
try {
parseArguments(['--expected-cert-sha256', safeFingerprint])
} catch (error) {
legacyArgumentRejected = error instanceof Error
&& error.message.includes('unexpected_argument_expected-cert-sha256')
}
if (!legacyArgumentRejected) fail('self_test_legacy_certificate_argument_not_rejected')
requireExactPlayCertificate([safeFingerprint], safeFingerprint, 'self_test_safe')
let extraCertificateRejected = false
try {
requireExactPlayCertificate([safeFingerprint, uploadFingerprint].sort(), safeFingerprint, 'self_test')
} catch (error) {
extraCertificateRejected = error instanceof Error
&& error.message.includes('self_test_play_certificate_set_mismatch')
}
if (!extraCertificateRejected) fail('self_test_extra_certificate_not_rejected')
validateLiveResponseMetadata({
status: 200,
redirected: false,
url: LIVE_URL,
headers: new Headers({ 'content-type': 'application/json; charset=utf-8' }),
}, LIVE_URL)
for (const [label, response] of [
['redirect', {
status: 302,
redirected: false,
url: LIVE_URL,
headers: new Headers({ 'content-type': 'application/json' }),
}],
['mime', {
status: 200,
redirected: false,
url: LIVE_URL,
headers: new Headers({ 'content-type': 'text/plain' }),
}],
['final_url', {
status: 200,
redirected: true,
url: `${LIVE_URL}?redirected=1`,
headers: new Headers({ 'content-type': 'application/json' }),
}],
]) {
let rejected = false
try {
validateLiveResponseMetadata(response, LIVE_URL)
} catch (error) {
rejected = error instanceof Error && error.message.startsWith('android_app_links_invalid:')
}
if (!rejected) fail(`self_test_${label}_metadata_not_rejected`)
}
process.stdout.write(`${JSON.stringify({
ok: true,
compromisedCertificateRejected: true,
exactPlayCertificateRequired: true,
liveHttpMetadataRequired: true,
uploadCertificateAssociationRejected: true,
})}\n`)
}
const cliArguments = process.argv.slice(2)
if (cliArguments.length === 1 && cliArguments[0] === '--self-test') {
runSelfTest()
process.exit(0)
}
const options = parseArguments(cliArguments)
if (
releaseIdentity.schemaVersion !== 1
|| releaseIdentity.packageName !== PACKAGE_NAME
|| typeof releaseIdentity.playConsoleAppId !== 'string'
|| !/^\d+$/.test(releaseIdentity.playConsoleAppId)
) {
fail('release_identity_invalid')
}
const canonicalExpectedFingerprint = rejectCompromisedFingerprint(
releaseIdentity.playAppSigningCertificateSha256,
'release_identity_play_app_signing',
)
const expectedFingerprint = options['expected-play-app-signing-cert-sha256']
? rejectCompromisedFingerprint(
options['expected-play-app-signing-cert-sha256'],
'expected_play_app_signing',
)
: canonicalExpectedFingerprint
if (expectedFingerprint !== canonicalExpectedFingerprint) {
fail('expected_play_certificate_identity_mismatch')
}
const canonicalUploadFingerprint = rejectCompromisedFingerprint(
releaseIdentity.uploadCertificateSha256,
'release_identity_upload',
)
const forbiddenUploadFingerprint = options['forbidden-upload-cert-sha256']
? rejectCompromisedFingerprint(options['forbidden-upload-cert-sha256'], 'upload')
: canonicalUploadFingerprint
if (forbiddenUploadFingerprint !== canonicalUploadFingerprint) {
fail('upload_certificate_identity_mismatch')
}
if (expectedFingerprint && expectedFingerprint === forbiddenUploadFingerprint) {
fail('play_and_upload_certificates_must_differ')
}
const sourceEvidence = []
for (const path of SOURCE_PATHS) {
const document = JSON.parse(await readFile(resolve(path), 'utf8'))
const fingerprints = fingerprintsFromDocument(document, path)
rejectUploadCertificateAssociation(fingerprints, forbiddenUploadFingerprint, path)
sourceEvidence.push({ path, fingerprints })
}
const canonicalFingerprints = sourceEvidence[0].fingerprints
for (const evidence of sourceEvidence.slice(1)) {
if (JSON.stringify(evidence.fingerprints) !== JSON.stringify(canonicalFingerprints)) {
fail(`source_fingerprint_drift_${evidence.path}`)
}
}
if (expectedFingerprint && !canonicalFingerprints.includes(expectedFingerprint)) {
fail('release_certificate_missing_from_source_assetlinks')
}
requireExactPlayCertificate(canonicalFingerprints, expectedFingerprint, 'source')
const liveUrl = options['live-url'] ?? LIVE_URL
const response = await fetch(liveUrl, {
headers: { accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(15_000),
})
validateLiveResponseMetadata(response, liveUrl)
let liveDocument
try {
liveDocument = await response.json()
} catch {
fail('live_invalid_json')
}
const liveFingerprints = fingerprintsFromDocument(liveDocument, 'live')
rejectUploadCertificateAssociation(liveFingerprints, forbiddenUploadFingerprint, 'live')
if (JSON.stringify(liveFingerprints) !== JSON.stringify(canonicalFingerprints)) {
fail('live_source_fingerprint_drift')
}
if (expectedFingerprint && !liveFingerprints.includes(expectedFingerprint)) {
fail('release_certificate_missing_from_live_assetlinks')
}
requireExactPlayCertificate(liveFingerprints, expectedFingerprint, 'live')
console.log(JSON.stringify({
packageName: PACKAGE_NAME,
relation: REQUIRED_RELATION,
liveUrl,
fingerprints: canonicalFingerprints,
playConsoleAppId: releaseIdentity.playConsoleAppId,
expectedPlayAppSigningCertificateVerified: true,
uploadCertificateExcluded: true,
sourcePaths: SOURCE_PATHS,
}, null, 2))