737 lines
29 KiB
JavaScript
737 lines
29 KiB
JavaScript
import {
|
|
createHash,
|
|
createPrivateKey,
|
|
createPublicKey,
|
|
randomBytes,
|
|
sign as signBytes,
|
|
verify as verifyBytes,
|
|
} from 'node:crypto'
|
|
import {
|
|
closeSync,
|
|
chmodSync,
|
|
constants as fsConstants,
|
|
copyFileSync,
|
|
existsSync,
|
|
fstatSync,
|
|
fsyncSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readFileSync,
|
|
readSync,
|
|
realpathSync,
|
|
rmdirSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
writeSync,
|
|
} from 'node:fs'
|
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
|
|
export const RELEASE_EVIDENCE_SCHEMA_VERSION = 2
|
|
export const RELEASE_EVIDENCE_KIND = 'd3ro-android-production-release'
|
|
export const RELEASE_PACKAGE_NAME = 'com.d3ro.voice'
|
|
export const RELEASE_APK_NAME = 'app-release.apk'
|
|
export const RELEASE_AAB_NAME = 'app-release.aab'
|
|
export const SIGNED_EVIDENCE_NAME = 'release-artifact-evidence.json'
|
|
export const TEST_ADMOB_APP_ID = 'ca-app-pub-3940256099942544~3347511713'
|
|
export const COMPROMISED_SIGNER_SHA256 = '06eec757722ee7cd3dfbc53202d974aaf0417a7d397f9ef1e5611088ebb2e481'
|
|
|
|
const MAX_EVIDENCE_BYTES = 1024 * 1024
|
|
const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
|
|
const DEBUG_SIGNER_SHA256 = new Set([
|
|
'fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c',
|
|
])
|
|
const HEX_SHA256 = /^[0-9a-f]{64}$/
|
|
const GIT_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
const REPOSITORY_IDENTITY = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+$/
|
|
const DECIMAL_ID = /^[1-9][0-9]{0,39}$/
|
|
const VERSION_NAME = /^[0-9A-Za-z][0-9A-Za-z._-]{0,63}$/
|
|
const PRODUCTION_ADMOB_APP_ID = /^ca-app-pub-\d+~\d+$/
|
|
const RELEASE_PROVENANCE_KEYS = [
|
|
'bundletoolSha256',
|
|
'commitSha',
|
|
'gitRef',
|
|
'repository',
|
|
'runAttempt',
|
|
'runId',
|
|
'runnerIdentity',
|
|
'treeSha',
|
|
'verifierSha256',
|
|
'workflowIdentity',
|
|
]
|
|
|
|
export function releaseBoundaryFail(code) {
|
|
throw new Error(`mobile_release_boundary:${code}`)
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
|
|
const prototype = Object.getPrototypeOf(value)
|
|
return prototype === Object.prototype || prototype === null
|
|
}
|
|
|
|
function requireExactKeys(value, expected, label) {
|
|
if (!isPlainObject(value)) releaseBoundaryFail(`${label}_must_be_object`)
|
|
const actual = Object.keys(value).sort()
|
|
const wanted = [...expected].sort()
|
|
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
|
|
releaseBoundaryFail(`${label}_keys_invalid`)
|
|
}
|
|
}
|
|
|
|
function canonicalValue(value) {
|
|
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
|
if (typeof value === 'number') {
|
|
if (!Number.isFinite(value)) releaseBoundaryFail('canonical_number_invalid')
|
|
return value
|
|
}
|
|
if (Array.isArray(value)) return value.map(canonicalValue)
|
|
if (!isPlainObject(value)) releaseBoundaryFail('canonical_value_invalid')
|
|
return Object.fromEntries(
|
|
Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]),
|
|
)
|
|
}
|
|
|
|
export function canonicalJson(value) {
|
|
return JSON.stringify(canonicalValue(value))
|
|
}
|
|
|
|
export function sha256Buffer(buffer) {
|
|
return createHash('sha256').update(buffer).digest('hex')
|
|
}
|
|
|
|
export function normalizeSha256(value, label) {
|
|
if (typeof value !== 'string') releaseBoundaryFail(`${label}_missing`)
|
|
const normalized = value.replaceAll(':', '').trim().toLowerCase()
|
|
if (!HEX_SHA256.test(normalized)) releaseBoundaryFail(`${label}_invalid`)
|
|
return normalized
|
|
}
|
|
|
|
function normalizeBoundedIdentity(value, label, maximumLength = 256) {
|
|
if (typeof value !== 'string'
|
|
|| value.length === 0
|
|
|| value.length > maximumLength
|
|
|| value !== value.trim()
|
|
|| [...value].some((character) => {
|
|
const codePoint = character.codePointAt(0)
|
|
return codePoint <= 0x1f || codePoint === 0x7f
|
|
})) {
|
|
releaseBoundaryFail(`${label}_invalid`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function normalizeRepository(value, label) {
|
|
const normalized = normalizeBoundedIdentity(value, label)
|
|
if (!REPOSITORY_IDENTITY.test(normalized)
|
|
|| normalized.includes('//')
|
|
|| normalized.split('/').some((segment) => segment === '.' || segment === '..')) {
|
|
releaseBoundaryFail(`${label}_invalid`)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function normalizeGitObjectId(value, label) {
|
|
if (typeof value !== 'string' || !GIT_OBJECT_ID.test(value)) {
|
|
releaseBoundaryFail(`${label}_invalid`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function normalizeGitRef(value, label) {
|
|
const normalized = normalizeBoundedIdentity(value, label)
|
|
if (!normalized.startsWith('refs/')
|
|
|| normalized.endsWith('/')
|
|
|| normalized.endsWith('.')
|
|
|| normalized.includes('..')
|
|
|| normalized.includes('//')
|
|
|| normalized.includes('@{')
|
|
|| normalized.includes('\\')
|
|
|| [...' ~^:?*[]'].some((character) => normalized.includes(character))
|
|
|| normalized.split('/').some((segment) => segment === '' || segment === '.' || segment === '..' || segment.endsWith('.lock'))) {
|
|
releaseBoundaryFail(`${label}_invalid`)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function normalizeRunId(value, label) {
|
|
if (typeof value !== 'string' || !DECIMAL_ID.test(value)) {
|
|
releaseBoundaryFail(`${label}_invalid`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function normalizeRunAttempt(value, label) {
|
|
if (typeof value !== 'number' && (typeof value !== 'string' || !DECIMAL_ID.test(value))) {
|
|
releaseBoundaryFail(`${label}_invalid`)
|
|
}
|
|
const number = typeof value === 'number' ? value : Number(value)
|
|
if (!Number.isSafeInteger(number) || number <= 0 || number > 1_000_000) {
|
|
releaseBoundaryFail(`${label}_invalid`)
|
|
}
|
|
return number
|
|
}
|
|
|
|
export function validateReleaseProvenance(provenance, label = 'provenance') {
|
|
requireExactKeys(provenance, RELEASE_PROVENANCE_KEYS, label)
|
|
return {
|
|
repository: normalizeRepository(provenance.repository, `${label}_repository`),
|
|
commitSha: normalizeGitObjectId(provenance.commitSha, `${label}_commit_sha`),
|
|
treeSha: normalizeGitObjectId(provenance.treeSha, `${label}_tree_sha`),
|
|
gitRef: normalizeGitRef(provenance.gitRef, `${label}_git_ref`),
|
|
workflowIdentity: normalizeBoundedIdentity(
|
|
provenance.workflowIdentity,
|
|
`${label}_workflow_identity`,
|
|
),
|
|
runId: normalizeRunId(provenance.runId, `${label}_run_id`),
|
|
runAttempt: normalizeRunAttempt(provenance.runAttempt, `${label}_run_attempt`),
|
|
runnerIdentity: normalizeBoundedIdentity(
|
|
provenance.runnerIdentity,
|
|
`${label}_runner_identity`,
|
|
),
|
|
verifierSha256: normalizeSha256(
|
|
provenance.verifierSha256,
|
|
`${label}_verifier_sha256`,
|
|
),
|
|
bundletoolSha256: normalizeSha256(
|
|
provenance.bundletoolSha256,
|
|
`${label}_bundletool_sha256`,
|
|
),
|
|
}
|
|
}
|
|
|
|
function rejectCompromisedSigner(value, code) {
|
|
if (value === COMPROMISED_SIGNER_SHA256) releaseBoundaryFail(code)
|
|
return value
|
|
}
|
|
|
|
function stableStatValue(stat, key, fallbackKey) {
|
|
if (key in stat) return stat[key]
|
|
return BigInt(Math.trunc(Number(stat[fallbackKey]) * 1_000_000))
|
|
}
|
|
|
|
function sameFileSnapshot(left, right) {
|
|
// Windows lstat reports st_dev as zero while fstat reports the volume id.
|
|
// The stable inode plus size/timestamps remain comparable on that platform.
|
|
return (process.platform === 'win32' || left.dev === right.dev)
|
|
&& left.ino === right.ino
|
|
&& left.size === right.size
|
|
&& stableStatValue(left, 'mtimeNs', 'mtimeMs') === stableStatValue(right, 'mtimeNs', 'mtimeMs')
|
|
&& stableStatValue(left, 'ctimeNs', 'ctimeMs') === stableStatValue(right, 'ctimeNs', 'ctimeMs')
|
|
}
|
|
|
|
function sameFileIdentity(left, right) {
|
|
return (process.platform === 'win32' || left.dev === right.dev) && left.ino === right.ino
|
|
}
|
|
|
|
function openRegularFileNoFollow(filePath, maximumBytes = MAX_ARTIFACT_BYTES) {
|
|
const absolute = resolve(filePath)
|
|
const pathStat = lstatSync(absolute, { bigint: true })
|
|
if (pathStat.isSymbolicLink()) releaseBoundaryFail(`symlink_rejected_${basename(absolute)}`)
|
|
if (realpathSync(absolute) !== absolute) releaseBoundaryFail(`reparse_rejected_${basename(absolute)}`)
|
|
if (!pathStat.isFile()) releaseBoundaryFail(`regular_file_required_${basename(absolute)}`)
|
|
if (pathStat.nlink !== 1n) releaseBoundaryFail(`hardlink_rejected_${basename(absolute)}`)
|
|
if (pathStat.size <= 0n || pathStat.size > BigInt(maximumBytes)) {
|
|
releaseBoundaryFail(`file_size_invalid_${basename(absolute)}`)
|
|
}
|
|
const noFollow = fsConstants.O_NOFOLLOW ?? 0
|
|
const fd = openSync(absolute, fsConstants.O_RDONLY | noFollow)
|
|
const openedStat = fstatSync(fd, { bigint: true })
|
|
if (!openedStat.isFile() || !sameFileSnapshot(pathStat, openedStat)) {
|
|
closeSync(fd)
|
|
releaseBoundaryFail(`file_identity_changed_${basename(absolute)}`)
|
|
}
|
|
return { absolute, fd, openedStat }
|
|
}
|
|
|
|
export function readSmallFileStable(filePath, maximumBytes = MAX_EVIDENCE_BYTES) {
|
|
const opened = openRegularFileNoFollow(filePath, maximumBytes)
|
|
try {
|
|
const buffer = readFileSync(opened.fd)
|
|
const after = fstatSync(opened.fd, { bigint: true })
|
|
if (!sameFileSnapshot(opened.openedStat, after) || BigInt(buffer.length) !== after.size) {
|
|
releaseBoundaryFail(`file_changed_while_reading_${basename(opened.absolute)}`)
|
|
}
|
|
return buffer
|
|
} finally {
|
|
closeSync(opened.fd)
|
|
}
|
|
}
|
|
|
|
export function hashRegularFileStable(filePath) {
|
|
const opened = openRegularFileNoFollow(filePath)
|
|
try {
|
|
const hash = createHash('sha256')
|
|
const chunk = Buffer.allocUnsafe(1024 * 1024)
|
|
let total = 0n
|
|
for (;;) {
|
|
const count = readSync(opened.fd, chunk, 0, chunk.length, null)
|
|
if (count === 0) break
|
|
total += BigInt(count)
|
|
hash.update(chunk.subarray(0, count))
|
|
}
|
|
const after = fstatSync(opened.fd, { bigint: true })
|
|
if (!sameFileSnapshot(opened.openedStat, after) || total !== after.size) {
|
|
releaseBoundaryFail(`file_changed_while_hashing_${basename(opened.absolute)}`)
|
|
}
|
|
return { sha256: hash.digest('hex'), bytes: Number(total) }
|
|
} finally {
|
|
closeSync(opened.fd)
|
|
}
|
|
}
|
|
|
|
function assertPathWithinRoot(rootPath, candidatePath, expectedName) {
|
|
const unresolvedRoot = resolve(rootPath)
|
|
const unresolvedRootStat = lstatSync(unresolvedRoot)
|
|
if (!unresolvedRootStat.isDirectory() || unresolvedRootStat.isSymbolicLink()) {
|
|
releaseBoundaryFail('source_root_invalid')
|
|
}
|
|
const root = realpathSync(unresolvedRoot)
|
|
const rootStat = lstatSync(root)
|
|
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) releaseBoundaryFail('source_root_invalid')
|
|
const candidate = resolve(candidatePath)
|
|
if (basename(candidate) !== expectedName) releaseBoundaryFail(`${expectedName}_name_invalid`)
|
|
const relativePath = relative(root, candidate)
|
|
if (relativePath === '' || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
releaseBoundaryFail(`${expectedName}_outside_source_root`)
|
|
}
|
|
const realCandidate = realpathSync(candidate)
|
|
const realRelative = relative(root, realCandidate)
|
|
if (realRelative === '' || realRelative === '..' || realRelative.startsWith(`..${sep}`) || isAbsolute(realRelative)) {
|
|
releaseBoundaryFail(`${expectedName}_realpath_outside_source_root`)
|
|
}
|
|
if (basename(realCandidate) !== expectedName) releaseBoundaryFail(`${expectedName}_real_name_invalid`)
|
|
return realCandidate
|
|
}
|
|
|
|
function normalizeVersionCode(value, label = 'version_code') {
|
|
const number = typeof value === 'number' ? value : Number(value)
|
|
if (!Number.isSafeInteger(number) || number <= 0 || number > 2_100_000_000) {
|
|
releaseBoundaryFail(`${label}_invalid`)
|
|
}
|
|
return number
|
|
}
|
|
|
|
function normalizeVersionName(value, label = 'version_name') {
|
|
if (typeof value !== 'string' || !VERSION_NAME.test(value)) {
|
|
releaseBoundaryFail(`${label}_invalid`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function normalizeProductionAdMobId(value) {
|
|
if (typeof value !== 'string' || !PRODUCTION_ADMOB_APP_ID.test(value)) {
|
|
releaseBoundaryFail('production_admob_app_id_invalid')
|
|
}
|
|
if (value === TEST_ADMOB_APP_ID || value.startsWith('ca-app-pub-3940256099942544')) {
|
|
releaseBoundaryFail('test_admob_rejected')
|
|
}
|
|
return value
|
|
}
|
|
|
|
function normalizeReleaseArtifact(value, expectedFileName, label) {
|
|
requireExactKeys(value, ['bytes', 'fileName', 'sha256'], label)
|
|
if (value.fileName !== expectedFileName || basename(value.fileName) !== value.fileName) {
|
|
releaseBoundaryFail(`${label}_file_name_invalid`)
|
|
}
|
|
const bytes = Number(value.bytes)
|
|
if (!Number.isSafeInteger(bytes) || bytes <= 0 || bytes > MAX_ARTIFACT_BYTES) {
|
|
releaseBoundaryFail(`${label}_bytes_invalid`)
|
|
}
|
|
return {
|
|
fileName: expectedFileName,
|
|
sha256: normalizeSha256(value.sha256, `${label}_sha256`),
|
|
bytes,
|
|
}
|
|
}
|
|
|
|
export function validateReleasePayload(payload) {
|
|
requireExactKeys(payload, [
|
|
'aab',
|
|
'adMobAppId',
|
|
'apk',
|
|
'debuggable',
|
|
'mode',
|
|
'packageName',
|
|
'provenance',
|
|
'signerSha256',
|
|
'verificationSha256',
|
|
'verifier',
|
|
'versionCode',
|
|
'versionName',
|
|
], 'payload')
|
|
if (payload.mode !== 'release') releaseBoundaryFail('nonrelease_mode_rejected')
|
|
if (payload.packageName !== RELEASE_PACKAGE_NAME) releaseBoundaryFail('package_name_mismatch')
|
|
if (payload.debuggable !== false) releaseBoundaryFail('debuggable_release_rejected')
|
|
if (payload.verifier !== 'scripts/ci/verify-android-artifact.mjs') {
|
|
releaseBoundaryFail('verifier_identity_invalid')
|
|
}
|
|
const signerSha256 = normalizeSha256(payload.signerSha256, 'signer_sha256')
|
|
if (DEBUG_SIGNER_SHA256.has(signerSha256)) releaseBoundaryFail('debug_signer_rejected')
|
|
rejectCompromisedSigner(signerSha256, 'compromised_signer_rejected')
|
|
return {
|
|
mode: 'release',
|
|
packageName: RELEASE_PACKAGE_NAME,
|
|
debuggable: false,
|
|
versionName: normalizeVersionName(payload.versionName),
|
|
versionCode: normalizeVersionCode(payload.versionCode),
|
|
adMobAppId: normalizeProductionAdMobId(payload.adMobAppId),
|
|
signerSha256,
|
|
provenance: validateReleaseProvenance(payload.provenance),
|
|
verifier: 'scripts/ci/verify-android-artifact.mjs',
|
|
verificationSha256: normalizeSha256(payload.verificationSha256, 'verification_sha256'),
|
|
apk: normalizeReleaseArtifact(payload.apk, RELEASE_APK_NAME, 'apk'),
|
|
aab: normalizeReleaseArtifact(payload.aab, RELEASE_AAB_NAME, 'aab'),
|
|
}
|
|
}
|
|
|
|
function requireReleaseVerification(verification, expected) {
|
|
if (!isPlainObject(verification)) releaseBoundaryFail('verification_must_be_object')
|
|
if (verification.mode !== 'release') releaseBoundaryFail('verification_nonrelease_mode')
|
|
if (verification.packageName !== RELEASE_PACKAGE_NAME) releaseBoundaryFail('verification_package_mismatch')
|
|
if (verification.debuggable !== false) releaseBoundaryFail('verification_debuggable_release')
|
|
const versionName = normalizeVersionName(verification.versionName, 'verification_version_name')
|
|
const versionCode = normalizeVersionCode(verification.versionCode, 'verification_version_code')
|
|
const adMobAppId = normalizeProductionAdMobId(verification.adMobAppId)
|
|
const signerSha256 = normalizeSha256(verification.signerSha256, 'verification_signer_sha256')
|
|
if (DEBUG_SIGNER_SHA256.has(signerSha256)) releaseBoundaryFail('verification_debug_signer')
|
|
rejectCompromisedSigner(signerSha256, 'verification_compromised_signer')
|
|
if (verification.artifact !== RELEASE_APK_NAME) releaseBoundaryFail('verification_apk_name')
|
|
if (!isPlainObject(verification.aab) || verification.aab.artifact !== RELEASE_AAB_NAME) {
|
|
releaseBoundaryFail('verification_aab_name')
|
|
}
|
|
if (verification.aab.packageName !== RELEASE_PACKAGE_NAME) {
|
|
releaseBoundaryFail('verification_aab_package_mismatch')
|
|
}
|
|
if (!Array.isArray(verification.abis)
|
|
|| verification.abis.length !== 1
|
|
|| verification.abis[0] !== 'arm64-v8a') {
|
|
releaseBoundaryFail('verification_apk_abis')
|
|
}
|
|
if (!Array.isArray(verification.aab.abis)
|
|
|| verification.aab.abis.length !== 1
|
|
|| verification.aab.abis[0] !== 'arm64-v8a') {
|
|
releaseBoundaryFail('verification_aab_abis')
|
|
}
|
|
const expectedVersionName = normalizeVersionName(expected.versionName, 'expected_version_name')
|
|
const expectedVersionCode = normalizeVersionCode(expected.versionCode, 'expected_version_code')
|
|
const expectedAdMobAppId = normalizeProductionAdMobId(expected.adMobAppId)
|
|
const expectedSigner = normalizeSha256(expected.signerSha256, 'expected_signer_sha256')
|
|
rejectCompromisedSigner(expectedSigner, 'expected_compromised_signer')
|
|
if (versionName !== expectedVersionName) releaseBoundaryFail('expected_version_name_mismatch')
|
|
if (versionCode !== expectedVersionCode) releaseBoundaryFail('expected_version_code_mismatch')
|
|
if (adMobAppId !== expectedAdMobAppId) releaseBoundaryFail('expected_admob_app_id_mismatch')
|
|
if (signerSha256 !== expectedSigner) releaseBoundaryFail('expected_signer_mismatch')
|
|
if (verification.aab.versionName !== versionName) releaseBoundaryFail('aab_apk_version_name_mismatch')
|
|
if (normalizeVersionCode(verification.aab.versionCode, 'aab_version_code') !== versionCode) {
|
|
releaseBoundaryFail('aab_apk_version_code_mismatch')
|
|
}
|
|
if (normalizeProductionAdMobId(verification.aab.adMobAppId) !== adMobAppId) {
|
|
releaseBoundaryFail('aab_apk_admob_app_id_mismatch')
|
|
}
|
|
if (verification.aab.debuggable !== false) releaseBoundaryFail('verification_aab_debuggable')
|
|
const aabSignerSha256 = normalizeSha256(verification.aab.signerSha256, 'aab_signer_sha256')
|
|
rejectCompromisedSigner(aabSignerSha256, 'verification_aab_compromised_signer')
|
|
if (aabSignerSha256 !== signerSha256) {
|
|
releaseBoundaryFail('aab_apk_signer_mismatch')
|
|
}
|
|
return { versionName, versionCode, adMobAppId, signerSha256 }
|
|
}
|
|
|
|
export function buildReleasePayload({ verification, apkPath, aabPath, expected, provenance }) {
|
|
const normalized = requireReleaseVerification(verification, expected)
|
|
const apk = hashRegularFileStable(apkPath)
|
|
const aab = hashRegularFileStable(aabPath)
|
|
if (normalizeSha256(verification.apkSha256, 'verification_apk_sha256') !== apk.sha256) {
|
|
releaseBoundaryFail('verification_apk_hash_mismatch')
|
|
}
|
|
if (normalizeSha256(verification.aab.sha256, 'verification_aab_sha256') !== aab.sha256) {
|
|
releaseBoundaryFail('verification_aab_hash_mismatch')
|
|
}
|
|
return validateReleasePayload({
|
|
mode: 'release',
|
|
packageName: RELEASE_PACKAGE_NAME,
|
|
debuggable: false,
|
|
versionName: normalized.versionName,
|
|
versionCode: normalized.versionCode,
|
|
adMobAppId: normalized.adMobAppId,
|
|
signerSha256: normalized.signerSha256,
|
|
provenance,
|
|
verifier: 'scripts/ci/verify-android-artifact.mjs',
|
|
verificationSha256: sha256Buffer(Buffer.from(canonicalJson(verification))),
|
|
apk: { fileName: RELEASE_APK_NAME, sha256: apk.sha256, bytes: apk.bytes },
|
|
aab: { fileName: RELEASE_AAB_NAME, sha256: aab.sha256, bytes: aab.bytes },
|
|
})
|
|
}
|
|
|
|
function publicKeyFingerprint(key) {
|
|
const publicKey = key?.type === 'public' ? key : createPublicKey(key)
|
|
if (publicKey.asymmetricKeyType !== 'ed25519') releaseBoundaryFail('ed25519_key_required')
|
|
return sha256Buffer(publicKey.export({ format: 'der', type: 'spki' }))
|
|
}
|
|
|
|
export function signReleaseEvidence(payload, privateKeyPem) {
|
|
const normalizedPayload = validateReleasePayload(payload)
|
|
const privateKey = createPrivateKey(privateKeyPem)
|
|
if (privateKey.asymmetricKeyType !== 'ed25519') releaseBoundaryFail('ed25519_private_key_required')
|
|
const unsigned = {
|
|
schemaVersion: RELEASE_EVIDENCE_SCHEMA_VERSION,
|
|
kind: RELEASE_EVIDENCE_KIND,
|
|
payload: normalizedPayload,
|
|
}
|
|
const signature = signBytes(null, Buffer.from(canonicalJson(unsigned)), privateKey)
|
|
return {
|
|
...unsigned,
|
|
signature: {
|
|
algorithm: 'Ed25519',
|
|
keyId: publicKeyFingerprint(privateKey),
|
|
value: signature.toString('base64'),
|
|
},
|
|
}
|
|
}
|
|
|
|
export function verifyReleaseEvidence(envelope, publicKeyPem) {
|
|
requireExactKeys(envelope, ['kind', 'payload', 'schemaVersion', 'signature'], 'evidence')
|
|
if (envelope.schemaVersion !== RELEASE_EVIDENCE_SCHEMA_VERSION) {
|
|
releaseBoundaryFail('evidence_schema_version')
|
|
}
|
|
if (envelope.kind !== RELEASE_EVIDENCE_KIND) releaseBoundaryFail('evidence_kind')
|
|
requireExactKeys(envelope.signature, ['algorithm', 'keyId', 'value'], 'signature')
|
|
if (envelope.signature.algorithm !== 'Ed25519') releaseBoundaryFail('signature_algorithm')
|
|
const publicKey = createPublicKey(publicKeyPem)
|
|
if (publicKey.asymmetricKeyType !== 'ed25519') releaseBoundaryFail('ed25519_public_key_required')
|
|
const expectedKeyId = publicKeyFingerprint(publicKey)
|
|
if (normalizeSha256(envelope.signature.keyId, 'signature_key_id') !== expectedKeyId) {
|
|
releaseBoundaryFail('signature_key_mismatch')
|
|
}
|
|
let signature
|
|
try {
|
|
signature = Buffer.from(envelope.signature.value, 'base64')
|
|
} catch {
|
|
releaseBoundaryFail('signature_encoding')
|
|
}
|
|
if (signature.length !== 64) releaseBoundaryFail('signature_length')
|
|
const unsigned = {
|
|
schemaVersion: envelope.schemaVersion,
|
|
kind: envelope.kind,
|
|
payload: envelope.payload,
|
|
}
|
|
if (!verifyBytes(null, Buffer.from(canonicalJson(unsigned)), publicKey, signature)) {
|
|
releaseBoundaryFail('signature_invalid')
|
|
}
|
|
return validateReleasePayload(envelope.payload)
|
|
}
|
|
|
|
export function verifyExpectedRelease(payload, expected) {
|
|
const normalized = validateReleasePayload(payload)
|
|
if (normalized.versionName !== normalizeVersionName(expected.versionName, 'expected_version_name')) {
|
|
releaseBoundaryFail('publication_version_name_mismatch')
|
|
}
|
|
if (normalized.versionCode !== normalizeVersionCode(expected.versionCode, 'expected_version_code')) {
|
|
releaseBoundaryFail('publication_version_code_mismatch')
|
|
}
|
|
if (normalized.adMobAppId !== normalizeProductionAdMobId(expected.adMobAppId)) {
|
|
releaseBoundaryFail('publication_admob_app_id_mismatch')
|
|
}
|
|
const expectedSigner = normalizeSha256(expected.signerSha256, 'expected_signer_sha256')
|
|
rejectCompromisedSigner(expectedSigner, 'publication_expected_compromised_signer')
|
|
if (normalized.signerSha256 !== expectedSigner) {
|
|
releaseBoundaryFail('publication_signer_mismatch')
|
|
}
|
|
const expectedProvenance = validateReleaseProvenance(
|
|
expected.provenance,
|
|
'expected_provenance',
|
|
)
|
|
for (const key of RELEASE_PROVENANCE_KEYS) {
|
|
if (normalized.provenance[key] !== expectedProvenance[key]) {
|
|
const code = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
|
releaseBoundaryFail(`publication_provenance_${code}_mismatch`)
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function snapshotArtifact(sourcePath, destinationDirectory, expected) {
|
|
const directoryIdentity = lstatSync(destinationDirectory, { bigint: true })
|
|
const opened = openRegularFileNoFollow(sourcePath)
|
|
const temporaryName = `.${expected.fileName}.${randomBytes(16).toString('hex')}.tmp`
|
|
const temporaryPath = join(destinationDirectory, temporaryName)
|
|
const destinationPath = join(destinationDirectory, expected.fileName)
|
|
let outputFd = -1
|
|
let completed = false
|
|
try {
|
|
outputFd = openSync(
|
|
temporaryPath,
|
|
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
|
0o600,
|
|
)
|
|
const hash = createHash('sha256')
|
|
const chunk = Buffer.allocUnsafe(1024 * 1024)
|
|
let total = 0n
|
|
for (;;) {
|
|
const count = readSync(opened.fd, chunk, 0, chunk.length, null)
|
|
if (count === 0) break
|
|
writeSync(outputFd, chunk, 0, count)
|
|
hash.update(chunk.subarray(0, count))
|
|
total += BigInt(count)
|
|
}
|
|
fsyncSync(outputFd)
|
|
closeSync(outputFd)
|
|
outputFd = -1
|
|
const sourceAfter = fstatSync(opened.fd, { bigint: true })
|
|
if (!sameFileSnapshot(opened.openedStat, sourceAfter) || total !== sourceAfter.size) {
|
|
releaseBoundaryFail(`source_changed_during_snapshot_${expected.fileName}`)
|
|
}
|
|
if (Number(total) !== expected.bytes) releaseBoundaryFail(`${expected.fileName}_size_mismatch`)
|
|
if (hash.digest('hex') !== expected.sha256) releaseBoundaryFail(`${expected.fileName}_hash_mismatch`)
|
|
copyFileSync(temporaryPath, destinationPath, fsConstants.COPYFILE_EXCL)
|
|
const directoryAfterCreate = lstatSync(destinationDirectory, { bigint: true })
|
|
if (!sameFileIdentity(directoryIdentity, directoryAfterCreate)
|
|
|| realpathSync(destinationDirectory) !== destinationDirectory) {
|
|
releaseBoundaryFail('destination_directory_replaced')
|
|
}
|
|
const copied = hashRegularFileStable(destinationPath)
|
|
if (copied.bytes !== expected.bytes || copied.sha256 !== expected.sha256) {
|
|
releaseBoundaryFail(`${expected.fileName}_sealed_copy_mismatch`)
|
|
}
|
|
chmodSync(destinationPath, 0o400)
|
|
completed = true
|
|
return destinationPath
|
|
} finally {
|
|
closeSync(opened.fd)
|
|
if (outputFd >= 0) closeSync(outputFd)
|
|
if (existsSync(temporaryPath)) unlinkSync(temporaryPath)
|
|
if (!completed && existsSync(destinationPath)) unlinkSync(destinationPath)
|
|
}
|
|
}
|
|
|
|
function safeCreateDestination(destinationDirectory) {
|
|
const destination = resolve(destinationDirectory)
|
|
const unresolvedParent = dirname(destination)
|
|
const parentStat = lstatSync(unresolvedParent, { bigint: true })
|
|
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
|
|
releaseBoundaryFail('destination_parent_invalid')
|
|
}
|
|
const parent = realpathSync(unresolvedParent)
|
|
if (destination === parent || basename(destination) === '' || basename(destination) === '.' || basename(destination) === '..') {
|
|
releaseBoundaryFail('destination_directory_too_broad')
|
|
}
|
|
if (existsSync(destination)) releaseBoundaryFail('destination_must_not_exist')
|
|
mkdirSync(destination, { recursive: false, mode: 0o700 })
|
|
const created = realpathSync(destination)
|
|
if (dirname(created) !== parent) releaseBoundaryFail('destination_parent_mismatch')
|
|
return created
|
|
}
|
|
|
|
export function createImmutableVerificationSnapshot({
|
|
apkPath,
|
|
aabPath,
|
|
destinationDirectory,
|
|
verifierPath,
|
|
bundletoolPath,
|
|
}) {
|
|
if (basename(resolve(apkPath)) !== RELEASE_APK_NAME) releaseBoundaryFail('snapshot_apk_name_invalid')
|
|
if (basename(resolve(aabPath)) !== RELEASE_AAB_NAME) releaseBoundaryFail('snapshot_aab_name_invalid')
|
|
const includesTools = verifierPath !== undefined || bundletoolPath !== undefined
|
|
if (includesTools && (verifierPath === undefined || bundletoolPath === undefined)) {
|
|
releaseBoundaryFail('snapshot_tool_pair_required')
|
|
}
|
|
const expectedApk = { fileName: RELEASE_APK_NAME, ...hashRegularFileStable(apkPath) }
|
|
const expectedAab = { fileName: RELEASE_AAB_NAME, ...hashRegularFileStable(aabPath) }
|
|
const expectedVerifier = includesTools
|
|
? { fileName: 'verify-android-artifact.mjs', ...hashRegularFileStable(verifierPath) }
|
|
: null
|
|
const expectedBundletool = includesTools
|
|
? { fileName: 'bundletool.jar', ...hashRegularFileStable(bundletoolPath) }
|
|
: null
|
|
const destination = safeCreateDestination(destinationDirectory)
|
|
const createdFiles = []
|
|
try {
|
|
const apk = snapshotArtifact(apkPath, destination, expectedApk)
|
|
createdFiles.push(apk)
|
|
const aab = snapshotArtifact(aabPath, destination, expectedAab)
|
|
createdFiles.push(aab)
|
|
if (!includesTools) return { destination, apkPath: apk, aabPath: aab }
|
|
const verifier = snapshotArtifact(verifierPath, destination, expectedVerifier)
|
|
createdFiles.push(verifier)
|
|
const bundletool = snapshotArtifact(bundletoolPath, destination, expectedBundletool)
|
|
createdFiles.push(bundletool)
|
|
return {
|
|
destination,
|
|
apkPath: apk,
|
|
aabPath: aab,
|
|
verifierPath: verifier,
|
|
bundletoolPath: bundletool,
|
|
}
|
|
} catch (error) {
|
|
cleanupCreatedDestination(destination, createdFiles)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function cleanupCreatedDestination(destination, createdFiles) {
|
|
for (const file of [...createdFiles].reverse()) {
|
|
if (existsSync(file) && dirname(file) === destination) unlinkSync(file)
|
|
}
|
|
if (existsSync(destination)) rmdirSync(destination)
|
|
}
|
|
|
|
export function prepareVerifiedReleasePublication({
|
|
sourceRoot,
|
|
apkPath,
|
|
aabPath,
|
|
evidencePath,
|
|
publicKeyPath,
|
|
destinationDirectory,
|
|
expected,
|
|
}) {
|
|
const apk = assertPathWithinRoot(sourceRoot, apkPath, RELEASE_APK_NAME)
|
|
const aab = assertPathWithinRoot(sourceRoot, aabPath, RELEASE_AAB_NAME)
|
|
const evidence = assertPathWithinRoot(sourceRoot, evidencePath, SIGNED_EVIDENCE_NAME)
|
|
const evidenceBuffer = readSmallFileStable(evidence)
|
|
let envelope
|
|
try {
|
|
envelope = JSON.parse(evidenceBuffer.toString('utf8'))
|
|
} catch {
|
|
releaseBoundaryFail('evidence_json_invalid')
|
|
}
|
|
const publicKeyPem = readSmallFileStable(publicKeyPath, 64 * 1024)
|
|
const payload = verifyExpectedRelease(
|
|
verifyReleaseEvidence(envelope, publicKeyPem),
|
|
expected,
|
|
)
|
|
const destination = safeCreateDestination(destinationDirectory)
|
|
const createdFiles = []
|
|
try {
|
|
createdFiles.push(snapshotArtifact(apk, destination, payload.apk))
|
|
createdFiles.push(snapshotArtifact(aab, destination, payload.aab))
|
|
const sealedEvidence = join(destination, 'android-release-evidence.json')
|
|
writeFileSync(sealedEvidence, evidenceBuffer, { flag: 'wx', mode: 0o600 })
|
|
createdFiles.push(sealedEvidence)
|
|
const manifest = {
|
|
schemaVersion: 2,
|
|
kind: 'd3ro-android-publication-set',
|
|
packageName: payload.packageName,
|
|
versionName: payload.versionName,
|
|
versionCode: payload.versionCode,
|
|
signerSha256: payload.signerSha256,
|
|
adMobAppId: payload.adMobAppId,
|
|
evidenceKeyId: envelope.signature.keyId,
|
|
provenance: payload.provenance,
|
|
artifacts: [payload.apk, payload.aab],
|
|
}
|
|
const manifestPath = join(destination, 'android-publication-manifest.json')
|
|
writeFileSync(manifestPath, `${canonicalJson(manifest)}\n`, { flag: 'wx', mode: 0o600 })
|
|
createdFiles.push(manifestPath)
|
|
return { destination, manifest, files: createdFiles.map((file) => basename(file)) }
|
|
} catch (error) {
|
|
cleanupCreatedDestination(destination, createdFiles)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export function writeJsonCreateOnly(filePath, value) {
|
|
const absolute = resolve(filePath)
|
|
if (!existsSync(dirname(absolute))) releaseBoundaryFail('output_parent_missing')
|
|
writeFileSync(absolute, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
|
|
}
|