From 7a16eb668f64bd54b9c38c7b57892bd4789e2779 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Fri, 14 Aug 2026 01:35:07 +0700 Subject: [PATCH 1/5] Pass exact argv to Linux apksigner --- .github/workflows/ci.yml | 13 +++++++++++++ scripts/update-minimum-device.ps1 | 19 +++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) 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..e44b3b18 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -415,19 +415,30 @@ 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`"`"" } 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($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 `"$ApkPath`"" + } } $process = New-Object System.Diagnostics.Process $process.StartInfo = $start From dd32b279e83a40bafbe11b58c769f76e0f648a7c Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Fri, 14 Aug 2026 01:40:41 +0700 Subject: [PATCH 2/5] Add sanitized signer probe diagnostics --- tests/update-minimum-device.Tests.ps1 | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index 092c6c66..4465da3a 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -249,7 +249,17 @@ 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) + try { + $signers = @(Get-ApkSignerDigests -ApkPath $realApkPath) + } catch { + # CI diagnostics deliberately expose only stream sizes and structural + # labels, never a certificate digest or certificate identity. + $probe = Invoke-ApkSignerProcess -ApkSigner (Resolve-ApkSigner) -ApkPath $realApkPath + $probeText = (($probe.Stdout, $probe.Stderr) -join "`n") + $digestLabels = @([regex]::Matches($probeText, '(?i)Signer #\d+ certificate SHA-256 digest:') | ForEach-Object Value) + $signerCounts = @([regex]::Matches($probeText, '(?i)Number of signers:') | ForEach-Object Value) + throw "$($_.Exception.Message) [probe exit=$($probe.ExitCode) stdoutChars=$($probe.Stdout.Length) stderrChars=$($probe.Stderr.Length) digestLabels=$($digestLabels.Count) signerCountLabels=$($signerCounts.Count)]" + } 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" } From ede42dbbe1de2e665e2211c9d97801db7aa1ae07 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Fri, 14 Aug 2026 01:45:25 +0700 Subject: [PATCH 3/5] Report sanitized apksigner label names --- tests/update-minimum-device.Tests.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index 4465da3a..be4de6a4 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -258,7 +258,9 @@ if (Test-Path -LiteralPath $realApkPath -PathType Leaf) { $probeText = (($probe.Stdout, $probe.Stderr) -join "`n") $digestLabels = @([regex]::Matches($probeText, '(?i)Signer #\d+ certificate SHA-256 digest:') | ForEach-Object Value) $signerCounts = @([regex]::Matches($probeText, '(?i)Number of signers:') | ForEach-Object Value) - throw "$($_.Exception.Message) [probe exit=$($probe.ExitCode) stdoutChars=$($probe.Stdout.Length) stderrChars=$($probe.Stderr.Length) digestLabels=$($digestLabels.Count) signerCountLabels=$($signerCounts.Count)]" + $safeLabels = @($probeText -split '\r?\n' | Where-Object { $_ -match '^(Signer #\d+ certificate|Number of signers:)' } | + ForEach-Object { if ($_ -match '^([^:]+):') { $Matches[1] } } | Select-Object -Unique) + throw "$($_.Exception.Message) [probe exit=$($probe.ExitCode) stdoutChars=$($probe.Stdout.Length) stderrChars=$($probe.Stderr.Length) digestLabels=$($digestLabels.Count) signerCountLabels=$($signerCounts.Count) labels=$($safeLabels -join '|')]" } 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" From 194660f171ef6db4d6d95db020be1eadd6333c89 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Fri, 14 Aug 2026 01:51:35 +0700 Subject: [PATCH 4/5] Bind signer digest from verified PEM certificate --- scripts/update-minimum-device.ps1 | 27 ++++++++++++++++++++++++--- tests/update-minimum-device.Tests.ps1 | 4 ++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index e44b3b18..eddc3a17 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -366,7 +366,27 @@ function Parse-ApkSignerOutput { '(?i)Signer #\d+ certificate SHA-256 digest:\s*([0-9a-f]{64})(?![0-9a-f])') | ForEach-Object { $_.Groups[1].Value.ToUpperInvariant() }) if ($digests.Count -eq 0) { - Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner did not report a verified signing certificate." + # Some apksigner/launcher combinations omit the human digest labels from + # redirected output. PEM blocks still bind the verified signer certificate + # itself, so compute the same SHA-256 digest over its DER representation. + $pemBlocks = @([regex]::Matches($normalized, + '(?s)-----BEGIN CERTIFICATE-----\s*(.*?)\s*-----END CERTIFICATE-----')) + $digests = @($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." + } + }) + } + $reportedCount = [regex]::Match($normalized, '(?im)^Number of signers:\s*([0-9]+)\s*$') + if ($digests.Count -eq 0 -or -not $reportedCount.Success -or + [int]$reportedCount.Groups[1].Value -ne $digests.Count) { + Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner did not report an exact verified signer certificate set." } return $digests } @@ -424,7 +444,7 @@ function Invoke-ApkSignerProcess { 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 if ($start.PSObject.Properties["ArgumentList"]) { @@ -433,11 +453,12 @@ function Invoke-ApkSignerProcess { $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 `"$ApkPath`"" + $start.Arguments = "verify --verbose --print-certs --print-certs-pem `"$ApkPath`"" } } $process = New-Object System.Diagnostics.Process diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index be4de6a4..d1752292 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -110,9 +110,9 @@ 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" + Assert-Equal $digest (Parse-ApkSignerOutput "Number of signers: 1`nSigner #1 certificate SHA-256 digest: $digest") "apksigner digest" $escape = [char]27 - $linuxWrapped = "NativeCommandError: ${escape}[36mSigner #1 certificate SHA-256 digest: $($digest.ToLowerInvariant())${escape}[0m" + $linuxWrapped = "Number of signers: 1`nNativeCommandError: ${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 "DOES NOT VERIFY" } "APK_SIGNATURE_INVALID" "missing signer digest" } From 56a9c839108093606b93baec5736c70a7d947e85 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Fri, 14 Aug 2026 01:56:21 +0700 Subject: [PATCH 5/5] Make verified PEM signer set authoritative --- scripts/update-minimum-device.ps1 | 58 ++++++++++++++------------- tests/update-minimum-device.Tests.ps1 | 33 ++++++++------- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index eddc3a17..4c67077e 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -358,37 +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) { - # Some apksigner/launcher combinations omit the human digest labels from - # redirected output. PEM blocks still bind the verified signer certificate - # itself, so compute the same SHA-256 digest over its DER representation. - $pemBlocks = @([regex]::Matches($normalized, - '(?s)-----BEGIN CERTIFICATE-----\s*(.*?)\s*-----END CERTIFICATE-----')) - $digests = @($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." - } - }) - } - $reportedCount = [regex]::Match($normalized, '(?im)^Number of signers:\s*([0-9]+)\s*$') - if ($digests.Count -eq 0 -or -not $reportedCount.Success -or - [int]$reportedCount.Groups[1].Value -ne $digests.Count) { + $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." } - return $digests + 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 $pemDigests } function Stop-ApkSignerProcess { diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index d1752292..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 "Number of signers: 1`nSigner #1 certificate SHA-256 digest: $digest") "apksigner digest" - $escape = [char]27 - $linuxWrapped = "Number of signers: 1`nNativeCommandError: ${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,21 +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" } - try { - $signers = @(Get-ApkSignerDigests -ApkPath $realApkPath) - } catch { - # CI diagnostics deliberately expose only stream sizes and structural - # labels, never a certificate digest or certificate identity. - $probe = Invoke-ApkSignerProcess -ApkSigner (Resolve-ApkSigner) -ApkPath $realApkPath - $probeText = (($probe.Stdout, $probe.Stderr) -join "`n") - $digestLabels = @([regex]::Matches($probeText, '(?i)Signer #\d+ certificate SHA-256 digest:') | ForEach-Object Value) - $signerCounts = @([regex]::Matches($probeText, '(?i)Number of signers:') | ForEach-Object Value) - $safeLabels = @($probeText -split '\r?\n' | Where-Object { $_ -match '^(Signer #\d+ certificate|Number of signers:)' } | - ForEach-Object { if ($_ -match '^([^:]+):') { $Matches[1] } } | Select-Object -Unique) - throw "$($_.Exception.Message) [probe exit=$($probe.ExitCode) stdoutChars=$($probe.Stdout.Length) stderrChars=$($probe.Stderr.Length) digestLabels=$($digestLabels.Count) signerCountLabels=$($signerCounts.Count) labels=$($safeLabels -join '|')]" - } + $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" } }