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
295
scripts/ci/verify-android-app-links.mjs
Normal file
295
scripts/ci/verify-android-app-links.mjs
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue