diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d14f8378..02227dc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,19 @@ jobs: run: | chmod +x gradlew ./gradlew :app:testFossDebugUnitTest :app:assembleFossDebug :app:assembleFossRelease --no-daemon --stacktrace + - name: Test updater with real Linux apksigner and debug APK + shell: pwsh + run: | + $apk = (Resolve-Path 'app/build/outputs/apk/foss/debug/mumla-foss-debug.apk').Path + $badging = & "$env:ANDROID_HOME/build-tools/36.0.0/aapt" dump badging $apk + $package = [regex]::Match(($badging -join "`n"), "package: name='([^']+)' versionCode='([0-9]+)' versionName='([^']+)'" ) + if (-not $package.Success) { throw 'Could not parse debug APK identity.' } + $env:MINIMUM_TEST_SIGNED_APK = $apk + $env:MINIMUM_TEST_EXPECTED_APPLICATION_ID = $package.Groups[1].Value + $env:MINIMUM_TEST_EXPECTED_VERSION_CODE = $package.Groups[2].Value + $env:MINIMUM_TEST_EXPECTED_VERSION_NAME = $package.Groups[3].Value + ./tools/verify-cellular-policy.ps1 + ./tests/update-minimum-device.Tests.ps1 - name: Upload debug APK uses: actions/upload-artifact@v4 with: diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index 700248f8..4c67077e 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -358,17 +358,41 @@ function Find-ApkSignerInSdkRoots { function Parse-ApkSignerOutput { param([string]$Text) - # PowerShell 7 wraps some extensionless native-command output as ErrorRecord text on Linux, - # which can prefix the original line. Strip terminal control sequences and locate the exact - # apksigner label without requiring it to begin the rendered PowerShell line. + # Process capture returns raw streams; accept only anchored apksigner structural + # lines after removing terminal controls. PEM certificates are authoritative. $normalized = [regex]::Replace($Text, '\x1B\[[0-?]*[ -/]*[@-~]', '') - $digests = @([regex]::Matches($normalized, - '(?i)Signer #\d+ certificate SHA-256 digest:\s*([0-9a-f]{64})(?![0-9a-f])') | + $textDigests = @([regex]::Matches($normalized, + '(?im)^Signer #\d+ certificate SHA-256 digest:\s*([0-9a-f]{64})\s*$') | ForEach-Object { $_.Groups[1].Value.ToUpperInvariant() }) - if ($digests.Count -eq 0) { - Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner did not report a verified signing certificate." + $pemBlocks = @([regex]::Matches($normalized, + '(?s)-----BEGIN CERTIFICATE-----\s*(.*?)\s*-----END CERTIFICATE-----')) + $pemDigests = @($pemBlocks | ForEach-Object { + try { + $der = [Convert]::FromBase64String(([regex]::Replace($_.Groups[1].Value, '\s', ''))) + $certificate = New-Object Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$der) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { ([BitConverter]::ToString($sha256.ComputeHash($certificate.RawData))).Replace('-', '') } + finally { $sha256.Dispose(); $certificate.Dispose() } + } catch { + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner returned an invalid signer certificate." + } + }) + $countLines = @([regex]::Matches($normalized, '(?im)^Number of signers:\s*([0-9]+)\s*$')) + if ($countLines.Count -ne 1 -or $pemDigests.Count -eq 0 -or + [int]$countLines[0].Groups[1].Value -ne $pemDigests.Count -or + @($pemDigests | Select-Object -Unique).Count -ne $pemDigests.Count) { + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner did not report an exact verified signer certificate set." + } + if ($textDigests.Count -gt 0) { + $textSet = @($textDigests | Sort-Object) + $pemSet = @($pemDigests | Sort-Object) + if ($textDigests.Count -ne $pemDigests.Count -or + @($textDigests | Select-Object -Unique).Count -ne $textDigests.Count -or + (Compare-Object $textSet $pemSet)) { + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner textual and certificate signer sets disagree." + } } - return $digests + return $pemDigests } function Stop-ApkSignerProcess { @@ -415,19 +439,31 @@ function Invoke-ApkSignerProcess { $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true if ($ApkSigner.EndsWith(".bat", [StringComparison]::OrdinalIgnoreCase)) { - if (-not $env:ComSpec) { - Throw-UpdateError "APKSIGNER_MISSING" "The Windows command processor required for apksigner.bat is unavailable." - } # cmd expands percent variables even inside quotes. Refuse percent rather # than allow either path to be rewritten before the reviewed tool runs. if ($ApkPath.Contains('%') -or $ApkSigner.Contains('%')) { Throw-UpdateError "APK_SIGNATURE_INVALID" "Unsafe character in the Windows APK or apksigner path." } + if (-not $env:ComSpec) { + Throw-UpdateError "APKSIGNER_MISSING" "The Windows command processor required for apksigner.bat is unavailable." + } $start.FileName = $env:ComSpec - $start.Arguments = "/d /s /v:off /c `"`"$ApkSigner`" verify --verbose --print-certs `"$ApkPath`"`"" + $start.Arguments = "/d /s /v:off /c `"`"$ApkSigner`" verify --verbose --print-certs --print-certs-pem `"$ApkPath`"`"" } else { $start.FileName = $ApkSigner - $start.Arguments = "verify --verbose --print-certs `"$ApkPath`"" + if ($start.PSObject.Properties["ArgumentList"]) { + # .NET Core exposes a true argv collection. Use it for the Unix + # extensionless launcher so paths are never reparsed as one string. + $start.ArgumentList.Add("verify") + $start.ArgumentList.Add("--verbose") + $start.ArgumentList.Add("--print-certs") + $start.ArgumentList.Add("--print-certs-pem") + $start.ArgumentList.Add($ApkPath) + } else { + # Windows PowerShell 5.1 has no ArgumentList; extensionless launchers + # are unusual there, but retain safe quote-delimited compatibility. + $start.Arguments = "verify --verbose --print-certs --print-certs-pem `"$ApkPath`"" + } } $process = New-Object System.Diagnostics.Process $process.StartInfo = $start diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index 092c6c66..37dc7bcc 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -110,10 +110,7 @@ Test-Case "returning target switches to its correlated ADB port" { Test-Case "apksigner output parser requires verified signer digest" { $digest = "168F42ED412DA80ADAF27BED0984DBEE191168E9DF04F08AFA240A3F9DE45972" - Assert-Equal $digest (Parse-ApkSignerOutput "Signer #1 certificate SHA-256 digest: $digest") "apksigner digest" - $escape = [char]27 - $linuxWrapped = "NativeCommandError: ${escape}[36mSigner #1 certificate SHA-256 digest: $($digest.ToLowerInvariant())${escape}[0m" - Assert-Equal $digest (Parse-ApkSignerOutput $linuxWrapped) "PowerShell Linux wrapped digest" + Assert-ThrowsCode { Parse-ApkSignerOutput "Number of signers: 1`nSigner #1 certificate SHA-256 digest: $digest" } "APK_SIGNATURE_INVALID" "text digest without certificate refused" Assert-ThrowsCode { Parse-ApkSignerOutput "DOES NOT VERIFY" } "APK_SIGNATURE_INVALID" "missing signer digest" } @@ -249,9 +246,23 @@ if (Test-Path -LiteralPath $realApkPath -PathType Leaf) { Assert-Equal ([long]3070301) $identity.VersionCode "real APK version code" Assert-True ($identity.VersionName -match '-debug$') "real APK debug version" } - $signers = @(Get-ApkSignerDigests -ApkPath $realApkPath) + $probe = Invoke-ApkSignerProcess -ApkSigner (Resolve-ApkSigner) -ApkPath $realApkPath + Assert-Equal 0 $probe.ExitCode "real apksigner exit" + $probeText = (($probe.Stdout, $probe.Stderr) -join "`n") + $signers = @(Parse-ApkSignerOutput $probeText) Assert-True ($signers.Count -ge 1) "real APK signer count" Assert-True (@($signers | Where-Object { $_ -notmatch '^[0-9A-F]{64}$' }).Count -eq 0) "real APK signer format" + $pem = [regex]::Match($probeText, '(?s)-----BEGIN CERTIFICATE-----\s*.*?\s*-----END CERTIFICATE-----') + Assert-True $pem.Success "real signer PEM present" + Assert-ThrowsCode { Parse-ApkSignerOutput ($probeText + "`n" + $pem.Value) } "APK_SIGNATURE_INVALID" "duplicate PEM refused" + $invalidPemText = $probeText.Remove($pem.Index, $pem.Length).Insert($pem.Index, + "-----BEGIN CERTIFICATE-----`nNOT-BASE64`n-----END CERTIFICATE-----") + Assert-ThrowsCode { Parse-ApkSignerOutput $invalidPemText } "APK_SIGNATURE_INVALID" "invalid PEM refused" + $wrongCount = [regex]::Replace($probeText, '(?im)^Number of signers:\s*[0-9]+\s*$', 'Number of signers: 99', 1) + Assert-ThrowsCode { Parse-ApkSignerOutput $wrongCount } "APK_SIGNATURE_INVALID" "signer count mismatch refused" + Assert-ThrowsCode { Parse-ApkSignerOutput ($probeText + "`nNumber of signers: $($signers.Count)") } "APK_SIGNATURE_INVALID" "duplicate signer count refused" + $fakeDigest = if ($signers[0] -ceq ('A' * 64)) { 'B' * 64 } else { 'A' * 64 } + Assert-ThrowsCode { Parse-ApkSignerOutput ($probeText + "`nSigner #99 certificate SHA-256 digest: $fakeDigest") } "APK_SIGNATURE_INVALID" "fake textual digest refused" } }