From 36f052223b1ad6ce0c7c8a7577f6eaa6d55eb007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Fri, 12 Jun 2026 22:53:46 +0300 Subject: [PATCH] Verify Android release signing continuity --- Android/docs/argus-rollout-policy.md | 9 +- .../verify-release-signing-material.ps1 | 183 ++++++++++++++++++ PRODUCTION_READINESS.md | 6 +- 3 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 Android/scripts/verify-release-signing-material.ps1 diff --git a/Android/docs/argus-rollout-policy.md b/Android/docs/argus-rollout-policy.md index bff6d8e5..7c268531 100644 --- a/Android/docs/argus-rollout-policy.md +++ b/Android/docs/argus-rollout-policy.md @@ -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. diff --git a/Android/scripts/verify-release-signing-material.ps1 b/Android/scripts/verify-release-signing-material.ps1 new file mode 100644 index 00000000..3827b0d7 --- /dev/null +++ b/Android/scripts/verify-release-signing-material.ps1 @@ -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 + } +} diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index b26e4bd6..90ad72d7 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -25,12 +25,14 @@ Date: 2026-06-08. - Android version was raised to `1.3.12` with `versionCode` `40`. - Android version was raised to `1.3.13` with `versionCode` `41` for the next Argus publication. - Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and Argus upload. +- Android now has `Android/scripts/verify-release-signing-material.ps1` to verify local release signing properties, keystore readability, release certificate SHA-256, and optionally the latest published Argus APK certificate without printing signing secrets. - Android Argus staging now enforces release git provenance by default: the work tree must be clean, the branch must have an upstream, `git fetch` must succeed, and `HEAD` must match `@{u}` before a stable APK is prepared for upload. The generated `argus-publication.json` records the verified repository root, branch, upstream, commit, and timestamp; current monorepo publication can verify the pushed root `QSfera.git` branch by running from `Android/` with `-GitProvenanceRoot ..`. - Android `1.3.9`/`37` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `53ecc24e-106b-43a5-a39e-ffacd72a43f8`, package size `12226572`, and SHA-256 `cbd2c25328e395ea4da4e0f582feac39dec18d2d47b3e105d344904d8a24f017`. - Android `1.3.10`/`38` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `8465ba6d-a008-45f5-9c70-e6cd2c94317a`, package size `12228464`, and SHA-256 `ce33a73ce122de6c9c797e7cb552e5493b628c60ddc98fda6a9e230e3c1ed7fe`. - Android `1.3.11`/`39` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `3dbac5ad-b19c-46e6-b24f-8f22e1c466dd`, package size `12234884`, and SHA-256 `c9eae41bbaae421e1a69721e891d8431ce599684e0f655b71693a422aa83720a`. - Android `1.3.12`/`40` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `615b58c5-b5f7-4a2a-b6de-03f821eb43a4`, package size `12237552`, and SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`. - Android `1.3.13`/`41` was published to Argus as `qsfera-mobile` on 2026-06-12. Argus upload returned release id `39072ada-7557-48f3-ab42-ad68703d00b0`, package size `12261408`, and SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`. +- `Android/scripts/verify-release-signing-material.ps1 -VerifyPublishedApk` passed on 2026-06-12: local `Android/argus-signing.local.properties` and `Android/signing/argus-upload.jks` are complete/readable, alias `argus-upload` exists, and both the keystore certificate and the latest published Argus APK report certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`. - Android auto-upload source paths are normalized and new/first source folders start from timestamp `0`, so existing media is eligible for initial backup. - Android auto-upload validates persisted read permission and `DocumentFile.canRead()` before scanning SAF folders. - Android immediate auto-upload scheduling no longer blocks the caller while waiting for WorkManager state. @@ -83,13 +85,13 @@ Date: 2026-06-08. - Current public Argus manifest for `https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable` reports release id `39072ada-7557-48f3-ab42-ad68703d00b0`, version `1.3.13`, channel `stable`, platform `android`, package kind `apk`, `androidVersionCode` `41`, package size `12261408`, and SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`. - Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12261408` byte APK with the same SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`. - The previously published `1.3.5`/`33` APK matched Argus SHA-256 `b7d5bdcb49527c00983a15849252fa42189dee4287754803c46cf2f32e2130f3`, but `apksigner` reported `CN=Android Debug` with certificate SHA-256 `b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941`. -- The new `1.3.12`/`40` APK is signed by `CN=QSfera Argus Upload, O=QSfera, C=RU` with certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`. +- The current production-signed `1.3.13`/`41` APK is signed by `CN=QSfera Argus Upload, O=QSfera, C=RU` with certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`. - `AppUpdateManager` validates downloaded APK package name, greater `versionCode`, Argus `androidVersionCode`, and signer digest against the installed app signature. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the current production-signed stable APK. - The rollout policy for debug-signed `1.3.5` devices is documented in `Android/docs/argus-rollout-policy.md`: the app does not bypass signer mismatch; users must back up unsynced local files, uninstall the debug-signed build, then install the current stable APK from Argus. The Android app now shows a reinstall-required dialog for this certificate-mismatch path. ## Remaining P0 -- Back up the gitignored Android release signing files created under `Android/signing/` and `Android/argus-signing.local.properties` in a secure external secret store. Without this key material, future in-place Android updates from the production-signed `1.3.12` cannot be signed with the same certificate. +- Back up the gitignored Android release signing files created under `Android/signing/` and `Android/argus-signing.local.properties` in a secure external secret store. Without this key material, future in-place Android updates from the production-signed `1.3.13` cannot be signed with the same certificate. - Provide CI/Argus signing secrets derived from the same release key: `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`. - Fill production domains, ACME email, admin passwords, Keycloak passwords, and storage secrets from a secret manager. Do not commit real values. - Run `./validate-production-env.sh .env` and then `docker compose config` for `Server/devtools/deployments/qsfera_full` with a filled production `.env`; Docker CLI is not available in this local environment, so compose rendering is not locally verified.