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 } }