Verify Android release signing continuity

This commit is contained in:
Курнат Андрей
2026-06-12 22:53:46 +03:00
parent 2607c7d08e
commit 36f052223b
3 changed files with 192 additions and 6 deletions
+5 -4
View File
@@ -11,7 +11,7 @@
- Stable Argus releases for `qsfera-mobile` must keep the same package name and production signing certificate after the first production-signed `1.3.11` / `39` release.
- The in-app updater must not bypass certificate mismatch. `AppUpdateManager` validates package name, monotonically newer `versionCode`, Argus `androidVersionCode`, and signer digest before starting Android package installation.
- A client installed from the debug-signed `1.3.5` / `33` Argus APK cannot be moved to the current production-signed stable APK (`1.3.12` / `40` as of 2026-06-10) by the in-app updater because the signer digest check intentionally fails.
- A client installed from the debug-signed `1.3.5` / `33` Argus APK cannot be moved to the current production-signed stable APK (`1.3.13` / `41` as of 2026-06-12) by the in-app updater because the signer digest check intentionally fails.
- For debug-signed `1.3.5` installs, the supported migration path is: back up unsynced local files, uninstall the debug-signed build, then install the current stable QSfera APK from Argus.
- On certificate mismatch, the Android app must show an explicit reinstall-required dialog instead of a generic update error.
@@ -19,6 +19,7 @@
1. Build the stable APK with the same release key material through `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`.
2. Prepare Argus publication only from a clean release branch whose `HEAD` matches its upstream; `Android/scripts/prepare-argus-publication.ps1` enforces this by default and records repository root, branch, upstream, commit, and verification time in `argus-publication.json`. When the Android checkout is used from the pushed `QSfera.git` monorepo, run it from `Android/` with `-GitProvenanceRoot ..`.
3. Verify the APK certificate before upload with `apksigner verify --print-certs`.
4. Publish to Argus only after certificate, package name, version code, SHA-256, and git provenance checks pass.
5. Verify the published manifest and download with `scripts/verify-production-state.ps1 -VerifyAndroidDownload` from the repository root.
3. Verify the local release signing material and latest Argus APK certificate with `Android/scripts/verify-release-signing-material.ps1 -VerifyPublishedApk`.
4. Verify the APK certificate before upload with `apksigner verify --print-certs`.
5. Publish to Argus only after certificate, package name, version code, SHA-256, and git provenance checks pass.
6. Verify the published manifest and download with `scripts/verify-production-state.ps1 -VerifyAndroidDownload` from the repository root.
@@ -0,0 +1,183 @@
param(
[string]$PropertiesPath = 'argus-signing.local.properties',
[string]$ExpectedCertificateSha256 = 'ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877',
[string]$PublishedApkUrl = 'https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable',
[switch]$VerifyPublishedApk
)
$ErrorActionPreference = 'Stop'
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
function Read-Properties([string]$Path) {
$result = @{}
foreach ($line in Get-Content -LiteralPath $Path) {
$trimmed = $line.Trim()
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) {
continue
}
$separator = $trimmed.IndexOf('=')
if ($separator -le 0) {
continue
}
$key = $trimmed.Substring(0, $separator).Trim()
$value = $trimmed.Substring($separator + 1).Trim()
$result[$key] = $value
}
return $result
}
function Resolve-JavaHome {
$candidates = @(
$env:JAVA_HOME,
'C:\Program Files\Android\Android Studio\jbr',
'C:\Program Files\Android\openjdk\jdk-21.0.8'
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
$keytool = Join-Path $candidate 'bin\keytool.exe'
if (Test-Path -LiteralPath $keytool) {
return $candidate
}
}
Fail 'keytool.exe was not found. Set JAVA_HOME to a JDK path.'
}
function Resolve-AndroidHome {
$candidates = @(
$env:ANDROID_HOME,
"$env:LOCALAPPDATA\Android\Sdk"
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath (Join-Path $candidate 'build-tools')) {
return $candidate
}
}
Fail 'Android SDK was not found. Set ANDROID_HOME to the SDK path.'
}
function Find-FirstTool([string]$AndroidHome, [string]$ToolName) {
$tool = Get-ChildItem -Path (Join-Path $AndroidHome 'build-tools') -Recurse -File -Filter $ToolName |
Sort-Object FullName -Descending |
Select-Object -First 1
if (-not $tool) {
Fail "$ToolName was not found under Android build-tools."
}
return $tool.FullName
}
function Normalize-Sha256([string]$Value) {
return ($Value -replace '[^0-9a-fA-F]', '').ToLowerInvariant()
}
function Get-KeytoolCertificateSha256([string]$KeytoolPath, [string]$KeystorePath, [string]$Password, [string]$Alias) {
$env:QSFERA_VERIFY_KEYSTORE_PASSWORD = $Password
try {
$output = & $KeytoolPath -list -v -keystore $KeystorePath -storepass:env QSFERA_VERIFY_KEYSTORE_PASSWORD -alias $Alias 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "keytool could not read the release keystore alias '$Alias'. Output: $($output | Out-String)"
}
} finally {
Remove-Item Env:\QSFERA_VERIFY_KEYSTORE_PASSWORD -ErrorAction SilentlyContinue
}
$text = $output | Out-String
$match = [regex]::Match($text, 'SHA256:\s*([0-9A-Fa-f:]+)')
if (-not $match.Success) {
Fail 'Could not extract SHA256 certificate fingerprint from keytool output.'
}
return Normalize-Sha256 $match.Groups[1].Value
}
function Get-ApkCertificateSha256([string]$ApksignerPath, [string]$ApkPath) {
$output = & $ApksignerPath verify --print-certs $ApkPath 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "apksigner could not verify APK certificate. Output: $($output | Out-String)"
}
$text = $output | Out-String
$match = [regex]::Match($text, 'SHA-256 digest:\s*([0-9A-Fa-f]+)')
if (-not $match.Success) {
Fail 'Could not extract SHA-256 certificate fingerprint from apksigner output.'
}
return Normalize-Sha256 $match.Groups[1].Value
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$resolvedPropertiesPath = if ([System.IO.Path]::IsPathRooted($PropertiesPath)) {
$PropertiesPath
} else {
Join-Path $repoRoot $PropertiesPath
}
if (-not (Test-Path -LiteralPath $resolvedPropertiesPath)) {
Fail "Signing properties were not found: $resolvedPropertiesPath"
}
$properties = Read-Properties $resolvedPropertiesPath
foreach ($key in @(
'QSFERA_RELEASE_KEYSTORE',
'QSFERA_RELEASE_KEYSTORE_PASSWORD',
'QSFERA_RELEASE_KEY_ALIAS',
'QSFERA_RELEASE_KEY_PASSWORD'
)) {
if (-not $properties.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($properties[$key])) {
Fail "Signing property is missing: $key"
}
}
$keystorePath = $properties['QSFERA_RELEASE_KEYSTORE']
if (-not [System.IO.Path]::IsPathRooted($keystorePath)) {
$keystorePath = Join-Path $repoRoot $keystorePath
}
if (-not (Test-Path -LiteralPath $keystorePath)) {
Fail "Release keystore was not found: $keystorePath"
}
if ((Get-Item -LiteralPath $keystorePath).Length -le 0) {
Fail "Release keystore is empty: $keystorePath"
}
$javaHome = Resolve-JavaHome
$env:JAVA_HOME = $javaHome
$keytoolPath = Join-Path $javaHome 'bin\keytool.exe'
$expectedSha256 = Normalize-Sha256 $ExpectedCertificateSha256
$alias = $properties['QSFERA_RELEASE_KEY_ALIAS']
$keytoolSha256 = Get-KeytoolCertificateSha256 `
-KeytoolPath $keytoolPath `
-KeystorePath $keystorePath `
-Password $properties['QSFERA_RELEASE_KEYSTORE_PASSWORD'] `
-Alias $alias
if ($keytoolSha256 -ne $expectedSha256) {
Fail "Release keystore certificate SHA-256 expected $expectedSha256 but got $keytoolSha256."
}
Write-Output "OK: release signing properties are complete: $resolvedPropertiesPath"
Write-Output "OK: release keystore exists and is readable: $keystorePath"
Write-Output "OK: release key alias '$alias' certificate SHA-256 = $keytoolSha256"
if ($VerifyPublishedApk) {
$androidHome = Resolve-AndroidHome
$apksignerPath = Find-FirstTool $androidHome 'apksigner.bat'
$tempApk = [System.IO.Path]::ChangeExtension([System.IO.Path]::GetTempFileName(), '.apk')
try {
Invoke-WebRequest -Uri $PublishedApkUrl -UseBasicParsing -TimeoutSec 120 -OutFile $tempApk | Out-Null
$publishedSha256 = Get-ApkCertificateSha256 -ApksignerPath $apksignerPath -ApkPath $tempApk
if ($publishedSha256 -ne $expectedSha256) {
Fail "Published APK certificate SHA-256 expected $expectedSha256 but got $publishedSha256."
}
Write-Output "OK: published Argus APK certificate SHA-256 = $publishedSha256"
} finally {
Remove-Item -LiteralPath $tempApk -Force -ErrorAction SilentlyContinue
}
}