diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 11252ce3..86281cf7 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -148,6 +148,10 @@ jobs: throw "Updater failed PowerShell AST parsing." } ./tools/verify-cellular-policy.ps1 + $env:MINIMUM_TEST_SIGNED_APK = (Resolve-Path 'app/build/outputs/apk/foss/release/mumla-foss-release.apk').Path + $env:MINIMUM_TEST_EXPECTED_APPLICATION_ID = '${{ vars.MINIMUM_RELEASE_APPLICATION_ID }}' + $env:MINIMUM_TEST_EXPECTED_VERSION_CODE = '${{ inputs.expected_version_code }}' + $env:MINIMUM_TEST_EXPECTED_VERSION_NAME = '${{ inputs.tag }}' ./tests/update-minimum-device.Tests.ps1 - name: Build temporary Wi-Fi provisioner run: | diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index f48eac10..700248f8 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -371,16 +371,104 @@ function Parse-ApkSignerOutput { return $digests } +function Stop-ApkSignerProcess { + param([Parameter(Mandatory)]$Process) + try { + $killTree = $Process.GetType().GetMethod("Kill", [type[]]@([bool])) + if ($killTree) { $killTree.Invoke($Process, @($true)) | Out-Null } + elseif ($env:OS -ceq "Windows_NT") { + # .NET Framework (Windows PowerShell 5.1) lacks Kill(Boolean). + # taskkill's numeric PID argument avoids shell parsing and terminates + # cmd.exe plus any batch-launched Java descendants holding our pipes. + $stop = New-Object System.Diagnostics.ProcessStartInfo + $stop.FileName = Join-Path $env:SystemRoot "System32\taskkill.exe" + $stop.Arguments = "/PID $($Process.Id) /T /F" + $stop.UseShellExecute = $false + $stop.CreateNoWindow = $true + $killer = [Diagnostics.Process]::Start($stop) + if ($killer) { + $killer.WaitForExit(2000) | Out-Null + $killer.Dispose() + } + } else { $Process.Kill() } + } catch { } +} + +function Invoke-ApkSignerProcess { + param( + [Parameter(Mandatory)][string]$ApkSigner, + [Parameter(Mandatory)][string]$ApkPath, + [int]$TimeoutMilliseconds = 30000, + [int]$DrainTimeoutMilliseconds = 2000 + ) + # Do not use PowerShell's native-command stream redirection here. On Linux, + # pwsh can wrap output from the extensionless apksigner launcher as error + # records, losing the certificate lines when those records are stringified. + # Process captures the launcher's raw stdout/stderr on every supported host. + if ($ApkPath.IndexOfAny(@([char]0, [char]10, [char]13, [char]34)) -ge 0 -or + $ApkSigner.IndexOfAny(@([char]0, [char]10, [char]13, [char]34)) -ge 0) { + Throw-UpdateError "APK_SIGNATURE_INVALID" "Unsafe character in the APK or apksigner path." + } + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $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." + } + $start.FileName = $env:ComSpec + $start.Arguments = "/d /s /v:off /c `"`"$ApkSigner`" verify --verbose --print-certs `"$ApkPath`"`"" + } else { + $start.FileName = $ApkSigner + $start.Arguments = "verify --verbose --print-certs `"$ApkPath`"" + } + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $start + try { + if (-not $process.Start()) { + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner could not be started." + } + # Drain both pipes concurrently so a noisy rejected file cannot fill one + # pipe and deadlock while the other is read. Signature verification is + # local and bounded; a hung tool is killed and refused after 30 seconds. + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + Stop-ApkSignerProcess $process + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner timed out; no installation was attempted." + } + $tasks = [Threading.Tasks.Task[]]@($stdoutTask, $stderrTask) + if (-not [Threading.Tasks.Task]::WaitAll($tasks, $DrainTimeoutMilliseconds)) { + Stop-ApkSignerProcess $process + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner output pipes did not close; no installation was attempted." + } + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + } catch { + if ($_.Exception.Message -match '^\[APK_SIGNATURE_INVALID\]') { throw } + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner could not be executed; no installation was attempted." + } finally { + $process.Dispose() + } + return [pscustomobject]@{ ExitCode=$exitCode; Stdout=$stdout; Stderr=$stderr } +} + function Get-ApkSignerDigests { param([Parameter(Mandatory)][string]$ApkPath) $apksigner = Resolve-ApkSigner - $previous = $ErrorActionPreference - try { - $ErrorActionPreference = "Continue" - $output = @(& $apksigner verify --verbose --print-certs $ApkPath 2>&1) - $exitCode = $LASTEXITCODE - } finally { $ErrorActionPreference = $previous } - $text = (($output | ForEach-Object { [string]$_ }) -join "`n").Trim() + $result = Invoke-ApkSignerProcess -ApkSigner $apksigner -ApkPath $ApkPath + $exitCode = $result.ExitCode + $stdout = $result.Stdout + $stderr = $result.Stderr + $text = (($stdout, $stderr) -join "`n").Trim() if ($exitCode -ne 0) { Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner rejected the APK signature; no installation was attempted." } diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index f2f1d91d..092c6c66 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -117,6 +117,43 @@ Test-Case "apksigner output parser requires verified signer digest" { Assert-ThrowsCode { Parse-ApkSignerOutput "DOES NOT VERIFY" } "APK_SIGNATURE_INVALID" "missing signer digest" } +Test-Case "apksigner process rejects command-stream injection characters" { + $oldResolver = (Get-Item Function:\Resolve-ApkSigner).ScriptBlock + try { + Set-Item Function:\Resolve-ApkSigner { "C:\safe\apksigner.bat" } + Assert-ThrowsCode { Get-ApkSignerDigests 'C:\release\bad%PATH%.apk' } "APK_SIGNATURE_INVALID" "cmd expansion refused" + Set-Item Function:\Resolve-ApkSigner { "C:\bad`"tool\apksigner.bat" } + Assert-ThrowsCode { Get-ApkSignerDigests 'C:\release\minimum.apk' } "APK_SIGNATURE_INVALID" "quote refused" + Set-Item Function:\Resolve-ApkSigner { "C:\safe\apksigner.bat" } + Assert-ThrowsCode { Get-ApkSignerDigests "C:\release\minimum.apk`nextra" } "APK_SIGNATURE_INVALID" "newline refused" + } finally { + Set-Item Function:\Resolve-ApkSigner $oldResolver + } +} + +Test-Case "apksigner timeout has bounded cleanup" { + $root = Join-Path ([IO.Path]::GetTempPath()) ("minimum-apksigner-timeout-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $root | Out-Null + try { + $fakeApk = Join-Path $root "fixture.apk" + Set-Content -LiteralPath $fakeApk -Value "fixture" -NoNewline + if ($env:ComSpec) { + $fakeSigner = Join-Path $root "apksigner.bat" + Set-Content -LiteralPath $fakeSigner -Value "@ping -n 6 127.0.0.1 >nul" -Encoding ASCII + } else { + $fakeSigner = Join-Path $root "apksigner" + Set-Content -LiteralPath $fakeSigner -Value "#!/bin/sh`nsleep 5" -Encoding ASCII + & chmod +x $fakeSigner + } + $timer = [Diagnostics.Stopwatch]::StartNew() + Assert-ThrowsCode { Invoke-ApkSignerProcess $fakeSigner $fakeApk 100 100 } "APK_SIGNATURE_INVALID" "timeout refused" + $timer.Stop() + Assert-True ($timer.ElapsedMilliseconds -lt 3000) "timeout cleanup remained bounded" + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + Test-Case "migration dispatch is exact, versioned, and T56-only" { $migration = [pscustomobject]@{ id="CELLULAR_POLICY_V1_T56"; fromVersionCodeMax=3070300; toVersionCode=3070301; profiles=@("T56"); rebootRequired=$true; irreversible=$false } Assert-Equal 1 @(Get-RequiredMigrations @($migration) 3070300 3070301 "T56").Count "T56 migration" @@ -192,13 +229,26 @@ Test-Case "bundle allowlist and checksum reject tampering" { } } -$realApkPath = Join-Path $PSScriptRoot "..\app\build\outputs\apk\foss\debug\mumla-foss-debug.apk" +$realApkPath = if ($env:MINIMUM_TEST_SIGNED_APK) { + $env:MINIMUM_TEST_SIGNED_APK +} else { + Join-Path $PSScriptRoot "..\app\build\outputs\apk\foss\debug\mumla-foss-debug.apk" +} if (Test-Path -LiteralPath $realApkPath -PathType Leaf) { - Test-Case "real built APK identity and signer parsing" { + Test-Case "real built APK identity and raw apksigner process parsing" { $identity = Get-ApkManifestIdentity -ApkPath $realApkPath - Assert-Equal "se.lublin.mumla" $identity.ApplicationId "real APK package" - Assert-Equal ([long]3070301) $identity.VersionCode "real APK version code" - Assert-True ($identity.VersionName -match '-debug$') "real APK debug version" + if ($env:MINIMUM_TEST_SIGNED_APK) { + Assert-True (-not [string]::IsNullOrWhiteSpace($env:MINIMUM_TEST_EXPECTED_APPLICATION_ID)) "release expected package supplied" + Assert-True (-not [string]::IsNullOrWhiteSpace($env:MINIMUM_TEST_EXPECTED_VERSION_CODE)) "release expected version code supplied" + Assert-True (-not [string]::IsNullOrWhiteSpace($env:MINIMUM_TEST_EXPECTED_VERSION_NAME)) "release expected version name supplied" + Assert-Equal $env:MINIMUM_TEST_EXPECTED_APPLICATION_ID $identity.ApplicationId "release APK package" + Assert-Equal ([long]$env:MINIMUM_TEST_EXPECTED_VERSION_CODE) $identity.VersionCode "release APK version code" + Assert-Equal $env:MINIMUM_TEST_EXPECTED_VERSION_NAME $identity.VersionName "release APK version name" + } else { + Assert-Equal "se.lublin.mumla" $identity.ApplicationId "real APK package" + 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) 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"