diff --git a/app/src/main/java/se/lublin/mumla/radio/PendingConfigTrialPolicy.java b/app/src/main/java/se/lublin/mumla/radio/PendingConfigTrialPolicy.java new file mode 100644 index 00000000..bd4c2453 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/PendingConfigTrialPolicy.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package se.lublin.mumla.radio; + +import se.lublin.humla.util.HumlaException; + +/** Fail-closed policy that tolerates only bounded transient config-trial dial failures. */ +final class PendingConfigTrialPolicy { + static final int MAX_CONNECTED_NETWORK_FAILURES = 3; + + private PendingConfigTrialPolicy() { + } + + static boolean shouldReject(HumlaException.HumlaDisconnectReason reason, + boolean networkConnected, int connectedNetworkFailures) { + if (reason == null) { + return false; + } + switch (reason) { + case REJECT: + case OTHER_ERROR: + return true; + case CONNECTION_ERROR: + return networkConnected + && connectedNetworkFailures >= MAX_CONNECTED_NETWORK_FAILURES; + case USER_REMOVE: + default: + return false; + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioShellActivity.java b/app/src/main/java/se/lublin/mumla/radio/RadioShellActivity.java index 5db06ef8..6650f4ae 100644 --- a/app/src/main/java/se/lublin/mumla/radio/RadioShellActivity.java +++ b/app/src/main/java/se/lublin/mumla/radio/RadioShellActivity.java @@ -26,6 +26,7 @@ import android.os.Looper; import android.os.SystemClock; import android.text.TextUtils; +import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; import android.view.View; @@ -68,6 +69,7 @@ /** Single-screen radio client driven by the Last Known Good Minimum config. */ public final class RadioShellActivity extends AppCompatActivity { + private static final String TAG = RadioShellActivity.class.getSimpleName(); public static final String EXTRA_CONNECT_ON_PTT = "se.lublin.mumla.extra.CONNECT_ON_PTT"; public static final String EXTRA_TOGGLE_IDENTITY = @@ -96,6 +98,7 @@ public final class RadioShellActivity extends AppCompatActivity { private boolean retryAfterConfiguredCertificateTrust; private boolean configReceiverRegistered; private boolean pendingConfigTrial; + private int pendingConfigConnectionFailures; private boolean pendingConfigIoInFlight; private boolean connectionRetrySuspended; private boolean joinedConfiguredRoom; @@ -230,9 +233,22 @@ public void onDisconnected(HumlaException error) { statusView.postDelayed(RadioShellActivity.this::maybeConnect, 400); return; } - if (pendingConfigTrial && shouldRejectPendingTrial(error)) { - failPendingConfiguration(); - return; + if (pendingConfigTrial && error != null) { + boolean networkConnected = RadioConfigUpdater.isNetworkConnected( + RadioShellActivity.this); + if (error.getReason() == HumlaException.HumlaDisconnectReason.CONNECTION_ERROR + && networkConnected) { + pendingConfigConnectionFailures++; + Log.w(TAG, "Pending config trial connection failure " + + pendingConfigConnectionFailures + "/" + + PendingConfigTrialPolicy.MAX_CONNECTED_NETWORK_FAILURES); + } + if (PendingConfigTrialPolicy.shouldReject(error.getReason(), networkConnected, + pendingConfigConnectionFailures)) { + Log.w(TAG, "Pending config trial rejected after bounded connection checks"); + failPendingConfiguration(); + return; + } } if (reconnectAfterDisconnect) { reconnectAfterDisconnect = false; @@ -720,6 +736,7 @@ private static boolean isAudibleTalkState(TalkState state) { private void beginPendingConfigurationTrial(RadioConnectionConfig candidate) { pendingConfigTrial = true; + pendingConfigConnectionFailures = 0; applyConfigurationToUi(candidate); setStatus(COLOR_BUSY, getString(R.string.radio_config_testing)); @@ -747,6 +764,7 @@ private void commitPendingConfiguration() { runOnUiThread(() -> { pendingConfigIoInFlight = false; pendingConfigTrial = false; + pendingConfigConnectionFailures = 0; if (service != null) { service.reloadTrackingConfig(); } @@ -789,6 +807,7 @@ private void failPendingConfiguration() { runOnUiThread(() -> { pendingConfigIoInFlight = false; pendingConfigTrial = false; + pendingConfigConnectionFailures = 0; if (destroyed) { return; } @@ -817,22 +836,6 @@ private void failPendingConfiguration() { }, "minimum-radio-config-rollback").start(); } - private boolean shouldRejectPendingTrial(HumlaException error) { - if (error == null) { - return false; - } - switch (error.getReason()) { - case REJECT: - case OTHER_ERROR: - return true; - case CONNECTION_ERROR: - return RadioConfigUpdater.isNetworkConnected(this); - case USER_REMOVE: - default: - return false; - } - } - private void maybeConnect() { boolean explicitlyRequested = connectOnPttRequest; if (destroyed || config == null || service == null || connectRequested diff --git a/app/src/test/java/se/lublin/mumla/radio/PendingConfigTrialPolicyTest.java b/app/src/test/java/se/lublin/mumla/radio/PendingConfigTrialPolicyTest.java new file mode 100644 index 00000000..c0455347 --- /dev/null +++ b/app/src/test/java/se/lublin/mumla/radio/PendingConfigTrialPolicyTest.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package se.lublin.mumla.radio; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import se.lublin.humla.util.HumlaException; + +public final class PendingConfigTrialPolicyTest { + @Test + public void transientConnectedFailuresKeepCandidateForRetry() { + assertFalse(PendingConfigTrialPolicy.shouldReject( + HumlaException.HumlaDisconnectReason.CONNECTION_ERROR, true, 1)); + assertFalse(PendingConfigTrialPolicy.shouldReject( + HumlaException.HumlaDisconnectReason.CONNECTION_ERROR, true, 2)); + } + + @Test + public void repeatedConnectedFailureRejectsCandidate() { + assertTrue(PendingConfigTrialPolicy.shouldReject( + HumlaException.HumlaDisconnectReason.CONNECTION_ERROR, true, 3)); + } + + @Test + public void offlineFailureWaitsForNetworkReturn() { + assertFalse(PendingConfigTrialPolicy.shouldReject( + HumlaException.HumlaDisconnectReason.CONNECTION_ERROR, false, 99)); + } + + @Test + public void permanentErrorsFailClosed() { + assertTrue(PendingConfigTrialPolicy.shouldReject( + HumlaException.HumlaDisconnectReason.REJECT, true, 0)); + assertTrue(PendingConfigTrialPolicy.shouldReject( + HumlaException.HumlaDisconnectReason.OTHER_ERROR, true, 0)); + } +} diff --git a/scripts/prepare-t99.ps1 b/scripts/prepare-t99.ps1 index 287ed11e..956112f5 100644 --- a/scripts/prepare-t99.ps1 +++ b/scripts/prepare-t99.ps1 @@ -55,7 +55,11 @@ $DeviceProfileProvisionAction = "se.lublin.mumla.action.PROVISION_DEVICE_PROFILE $IdentityReportAction = "se.lublin.mumla.action.PROVISION_REPORT_IDENTITY" $RadioConfigProvisionAction = "se.lublin.mumla.action.PROVISION_RADIO_CONFIG" $WifiHelperPackage = "dev.minimum.wifiprovisioner" -$WifiHelperActivity = "dev.minimum.wifiprovisioner/.WifiProvisionActivity" +$WifiHelperReceiver = "dev.minimum.wifiprovisioner/.WifiProvisionReceiver" +$WifiHelperImportAction = "dev.minimum.wifiprovisioner.action.IMPORT_REQUEST" +$WifiHelperStatusAction = "dev.minimum.wifiprovisioner.action.STATUS" +$WifiHelperRequestPathExtra = "requestPath" +$WifiHelperOperationIdExtra = "operationId" if (-not $LabWifiCredentialPath) { $LabWifiCredentialPath = Join-Path $PSScriptRoot (".secrets\{0}-lab-wifi.credential.xml" -f $TargetName.ToLowerInvariant()) } @@ -322,6 +326,113 @@ function Get-LabWifiCredential { throw "Lab Wi-Fi is not connected and no credential was supplied. Pass -LabWifiCredential or create the ignored DPAPI credential at $LabWifiCredentialPath." } +function Convert-WifiHelperStatusMarker { + param( + [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Output, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{32}$')][string]$ExpectedOperationId + ) + + $escapedOperationId = [regex]::Escape($ExpectedOperationId) + $markerPattern = '(?IMPORTED|SUCCESS):' + $escapedOperationId + + '|ERROR:' + $escapedOperationId + ':(?[a-z0-9-]{1,64})' + foreach ($line in $Output) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + $text = ([string]$line).Trim() + $match = [regex]::Match( + $text, + '^Broadcast completed:\s+result=-1,\s+data="?(?(?:' + + $markerPattern + '))"?$') + if (-not $match.Success) { + continue + } + $marker = $match.Groups['marker'].Value + if ($marker.StartsWith('ERROR:', [System.StringComparison]::Ordinal)) { + return [pscustomobject]@{ State = 'ERROR'; Error = $match.Groups['error'].Value } + } + $state = $marker.Substring(0, $marker.IndexOf(':')) + return [pscustomobject]@{ State = $state; Error = $null } + } + return $null +} + +function Convert-WifiHelperBroadcastOutput { + param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][object[]]$Output) + + return @($Output | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.ToString() + } else { + [string]$_ + } + }) +} + +function Invoke-WifiHelperBroadcast { + param([Parameter(Mandatory)][string[]]$Arguments) + + $savedErrorActionPreference = $ErrorActionPreference + try { + # Native adb diagnostics must be captured alongside stdout so a valid ordered result can + # still be found without allowing a permission/native error to masquerade as one. + $ErrorActionPreference = "Continue" + $combinedOutput = @(& $adbPath @Arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $savedErrorActionPreference + } + [pscustomobject]@{ + ExitCode = $exitCode + Output = @(Convert-WifiHelperBroadcastOutput -Output $combinedOutput) + } +} + +function Assert-WifiRemoteRequestAbsent { + param([Parameter(Mandatory)][string]$RemotePath) + + # The path is generated locally from a random operation ID and is never credential data. + # Keep the device-side probe output to one of two exact, non-secret words. + $probeCommand = "if [ -e '$RemotePath' ]; then echo PRESENT; else echo ABSENT; fi" + $probeOutput = @(& $adbPath @($targetArgs + @( + "shell", $probeCommand + )) 2>$null) + if ($LASTEXITCODE -ne 0) { + throw "remote request existence probe failed" + } + $probeText = (($probeOutput | ForEach-Object { [string]$_ }) -join "`n").Trim() + if ($probeText -ceq "ABSENT") { + return + } + if ($probeText -ceq "PRESENT") { + throw "remote request remains present" + } + throw "remote request existence probe returned unexpected output" +} + +function Assert-WifiHelperUninstalled { + $uninstallOutput = @(& $adbPath @($targetArgs + @( + "uninstall", $WifiHelperPackage + )) 2>$null) + if ($LASTEXITCODE -ne 0 -or + ($uninstallOutput -join "`n") -notmatch '(?im)^\s*Success\s*$') { + throw "helper uninstall was not acknowledged" + } + + # pm list packages is expected to exit successfully even when no package matches. Only the + # exact package line is meaningful; diagnostics are discarded and never echoed. + $packageListOutput = @(& $adbPath @($targetArgs + @( + "shell", "pm", "list", "packages", $WifiHelperPackage + )) 2>$null) + if ($LASTEXITCODE -ne 0) { + throw "helper package query failed" + } + $packageLines = @($packageListOutput | ForEach-Object { ([string]$_).Trim() }) + if ($packageLines -contains "package:$WifiHelperPackage") { + throw "helper package remains installed" + } +} + function Invoke-LabWifiProvisioning { param( [Parameter(Mandatory)][string]$Ssid, @@ -349,8 +460,11 @@ function Invoke-LabWifiProvisioning { $temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ( "minimum-wifi-" + [guid]::NewGuid().ToString("N")) $requestPath = Join-Path $temporaryDirectory "request.json" - $remoteRequest = "/data/local/tmp/minimum-wifi-$PID.json" + $operationId = [guid]::NewGuid().ToString("N") + $remoteRequest = "/data/local/tmp/minimum-wifi-$operationId.json" $helperInstalled = $false + $primaryError = $null + $cleanupError = $null New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null try { $plainPassword = $Credential.GetNetworkCredential().Password @@ -366,50 +480,140 @@ function Invoke-LabWifiProvisioning { Invoke-TargetAdb -Arguments @("install", "-r", $helperApk) | Out-Null $helperInstalled = $true Invoke-TargetAdb -Arguments @("push", $requestPath, $remoteRequest) | Out-Null - Invoke-TargetAdb -Arguments @( - "shell", "run-as", $WifiHelperPackage, "mkdir", "-p", "files" - ) | Out-Null - Invoke-TargetAdb -Arguments @( - "shell", "run-as", $WifiHelperPackage, "cp", $remoteRequest, "files/request.json" - ) | Out-Null - Invoke-TargetAdb -Arguments @( - "shell", "run-as", $WifiHelperPackage, "chmod", "600", "files/request.json" - ) | Out-Null - Invoke-TargetAdb -Arguments @("shell", "rm", "-f", $remoteRequest) | Out-Null - Invoke-TargetAdb -Arguments @("shell", "am", "start", "-n", $WifiHelperActivity) | - Out-Null + $importArgs = $targetArgs + @( + "shell", "am", "broadcast", "-n", $WifiHelperReceiver, + "-a", $WifiHelperImportAction, + "--es", $WifiHelperRequestPathExtra, $remoteRequest, + "--es", $WifiHelperOperationIdExtra, $operationId + ) + $importCall = Invoke-WifiHelperBroadcast -Arguments $importArgs + if ($importCall.ExitCode -ne 0) { + throw "Temporary Wi-Fi provisioner could not accept the request." + } + # Android 5.1 does not reliably preserve ordered-broadcast result data. Import output is + # therefore discarded. A nonce-bound STATUS marker from receiver-private, fsynced state + # is the only proof that the request was validated and copied. + $importCall = $null + $importProof = $null + $importDeadline = (Get-Date).AddSeconds(5) + while ((Get-Date) -lt $importDeadline -and $null -eq $importProof) { + $statusArgs = $targetArgs + @( + "shell", "am", "broadcast", "-n", $WifiHelperReceiver, + "-a", $WifiHelperStatusAction, + "--es", $WifiHelperOperationIdExtra, $operationId + ) + $statusCall = Invoke-WifiHelperBroadcast -Arguments $statusArgs + if ($statusCall.ExitCode -eq 0) { + $importProof = Convert-WifiHelperStatusMarker ` + -Output $statusCall.Output -ExpectedOperationId $operationId + if ($null -ne $importProof -and $importProof.State -eq 'ERROR') { + throw "Lab Wi-Fi provisioning failed: $($importProof.Error)" + } + } + if ($null -eq $importProof) { + Start-Sleep -Milliseconds 100 + } + } + if ($null -eq $importProof) { + throw "Temporary Wi-Fi provisioner did not prove a private request import." + } - $resultText = "" + # Only the shell UID can remove its /data/local/tmp entry. Removal occurs after the + # receiver-private import proof and is immediately verified without exposing content. + & $adbPath @($targetArgs + @("shell", "rm", "-f", $remoteRequest)) 1>$null 2>$null + if ($LASTEXITCODE -ne 0) { + throw "remote request deletion failed" + } + Assert-WifiRemoteRequestAbsent -RemotePath $remoteRequest + + $result = if ($importProof.State -eq 'SUCCESS') { $importProof } else { $null } $deadline = (Get-Date).AddSeconds(25) - while ((Get-Date) -lt $deadline) { + while ((Get-Date) -lt $deadline -and $null -eq $result) { Start-Sleep -Milliseconds 500 - $resultArgs = $targetArgs + @( - "shell", "run-as", $WifiHelperPackage, "cat", "files/result.json" + $statusArgs = $targetArgs + @( + "shell", "am", "broadcast", "-n", $WifiHelperReceiver, + "-a", $WifiHelperStatusAction, + "--es", $WifiHelperOperationIdExtra, $operationId ) - $resultOutput = & $adbPath @resultArgs 2>$null - if ($LASTEXITCODE -eq 0 -and $resultOutput) { - $resultText = ($resultOutput -join "") - break + $statusCall = Invoke-WifiHelperBroadcast -Arguments $statusArgs + if ($statusCall.ExitCode -eq 0) { + $status = Convert-WifiHelperStatusMarker ` + -Output $statusCall.Output -ExpectedOperationId $operationId + if ($null -ne $status -and $status.State -eq 'ERROR') { + throw "Lab Wi-Fi provisioning failed: $($status.Error)" + } + if ($null -ne $status -and $status.State -eq 'SUCCESS') { + $result = $status + } } } - if (-not $resultText) { + if ($null -eq $result) { throw "Temporary Wi-Fi provisioner did not return a result." } - $result = $resultText | ConvertFrom-Json - if (-not $result.ok) { - throw "Lab Wi-Fi provisioning failed: $($result.error)" - } Write-Host "Lab Wi-Fi profile saved for SSID '$Ssid'; credential value was not displayed." + } catch { + $primaryError = $_ } finally { - & $adbPath @($targetArgs + @("shell", "rm", "-f", $remoteRequest)) 1>$null 2>$null + $remoteCleanupError = $null + try { + & $adbPath @($targetArgs + @("shell", "rm", "-f", $remoteRequest)) 1>$null 2>$null + if ($LASTEXITCODE -ne 0) { + throw "remote request deletion failed" + } + } catch { + $remoteCleanupError = "remote request deletion failed" + } + try { + Assert-WifiRemoteRequestAbsent -RemotePath $remoteRequest + } catch { + if ($remoteCleanupError) { + $remoteCleanupError = "$remoteCleanupError; remote request absence probe failed" + } else { + $remoteCleanupError = "remote request absence probe failed" + } + } + if ($remoteCleanupError) { + $cleanupError = $remoteCleanupError + } + if ($helperInstalled) { - & $adbPath @($targetArgs + @("uninstall", $WifiHelperPackage)) 1>$null 2>$null + try { + Assert-WifiHelperUninstalled + } catch { + if ($cleanupError) { + $cleanupError = "$cleanupError; helper uninstall verification failed" + } else { + $cleanupError = "helper uninstall verification failed" + } + } } - if (Test-Path -LiteralPath $temporaryDirectory) { - Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force + + try { + if (Test-Path -LiteralPath $temporaryDirectory) { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force + } + if (Test-Path -LiteralPath $temporaryDirectory) { + throw "local request cleanup failed" + } + } catch { + if ($cleanupError) { + $cleanupError = "$cleanupError; local request cleanup failed" + } else { + $cleanupError = "local request cleanup failed" + } } } + if ($primaryError) { + if ($cleanupError) { + throw "$($primaryError.Exception.Message) Cleanup also failed: $cleanupError." + } + throw $primaryError.Exception.Message + } + if ($cleanupError) { + throw "Temporary Wi-Fi provisioner cleanup failed: $cleanupError." + } + $deadline = (Get-Date).AddSeconds(30) while ((Get-Date) -lt $deadline -and -not (Test-LabWifiConnected -Ssid $Ssid)) { Start-Sleep -Seconds 1 diff --git a/tools/t99-wifi-provisioner/app/src/main/AndroidManifest.xml b/tools/t99-wifi-provisioner/app/src/main/AndroidManifest.xml index bd5f4c96..9898241d 100644 --- a/tools/t99-wifi-provisioner/app/src/main/AndroidManifest.xml +++ b/tools/t99-wifi-provisioner/app/src/main/AndroidManifest.xml @@ -11,11 +11,17 @@ + android:exported="false" /> + + - - + + - + diff --git a/tools/t99-wifi-provisioner/app/src/main/java/dev/minimum/wifiprovisioner/WifiProvisionReceiver.java b/tools/t99-wifi-provisioner/app/src/main/java/dev/minimum/wifiprovisioner/WifiProvisionReceiver.java new file mode 100644 index 00000000..eeede999 --- /dev/null +++ b/tools/t99-wifi-provisioner/app/src/main/java/dev/minimum/wifiprovisioner/WifiProvisionReceiver.java @@ -0,0 +1,379 @@ +/* + * Copyright (C) 2026 The Minimum contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package dev.minimum.wifiprovisioner; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.json.JSONObject; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +import java.util.Locale; +import java.util.regex.Pattern; + +/** DUMP-protected shell entry point for importing a temporary Wi-Fi request. */ +public final class WifiProvisionReceiver extends BroadcastReceiver { + public static final String ACTION_IMPORT_REQUEST = + "dev.minimum.wifiprovisioner.action.IMPORT_REQUEST"; + public static final String ACTION_STATUS = + "dev.minimum.wifiprovisioner.action.STATUS"; + public static final String EXTRA_REQUEST_PATH = "requestPath"; + public static final String EXTRA_OPERATION_ID = "operationId"; + + private static final String REQUEST_FILE = "request.json"; + private static final String RESULT_FILE = "result.json"; + private static final String RECEIVER_STATUS_FILE = "receiver-status.json"; + private static final String REQUEST_DIRECTORY = "/data/local/tmp"; + private static final Pattern OPERATION_ID = Pattern.compile("^[0-9a-f]{32}$"); + private static final Pattern SAFE_ERROR = Pattern.compile("^[a-z0-9-]{1,64}$"); + private static final int MAX_REQUEST_BYTES = 8192; + private static final int MAX_RESULT_BYTES = 4096; + + private static final String STATE_IMPORTED = "imported"; + private static final String STATE_SUCCESS = "success"; + private static final String STATE_ERROR = "error"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) { + return; + } + String action = intent.getAction(); + if (ACTION_IMPORT_REQUEST.equals(action)) { + importRequest(context, + intent.getStringExtra(EXTRA_REQUEST_PATH), + intent.getStringExtra(EXTRA_OPERATION_ID)); + } else if (ACTION_STATUS.equals(action)) { + reportStatus(context, intent.getStringExtra(EXTRA_OPERATION_ID)); + } + } + + private void importRequest(Context context, String requestedPath, String operationId) { + setResultCode(0); + setResultData(null); + boolean validOperation = isValidOperationId(operationId); + try { + if (!validOperation) { + throw new IOException("invalid-operation"); + } + File source = validateRequestPath(requestedPath, operationId); + clearPrivateState(context); + byte[] request = readAndValidateRequest(source); + writeBytes(new File(context.getFilesDir(), REQUEST_FILE), request); + writeStatus(context, operationId, STATE_IMPORTED, null); + + try { + Intent activity = new Intent(context, WifiProvisionActivity.class); + activity.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + context.startActivity(activity); + } catch (RuntimeException startFailure) { + throw new IllegalStateException("activity-start-failed", startFailure); + } + setResultCode(-1); + setResultData(marker(STATE_IMPORTED, operationId, null)); + } catch (Exception error) { + bestEffortDelete(new File(context.getFilesDir(), REQUEST_FILE)); + bestEffortDelete(new File(context.getFilesDir(), RESULT_FILE)); + if (validOperation) { + String safeError = safeImportError(error); + try { + writeStatus(context, operationId, STATE_ERROR, safeError); + } catch (Exception ignored) { + // The host fails closed if no nonce-bound status is available. + } + setResultData(marker(STATE_ERROR, operationId, safeError)); + } + } + } + + private void reportStatus(Context context, String operationId) { + setResultCode(0); + setResultData(null); + if (!isValidOperationId(operationId)) { + return; + } + try { + Status privateStatus = readStatus(context); + if (privateStatus == null || !operationId.equals(privateStatus.operationId)) { + return; + } + if (STATE_ERROR.equals(privateStatus.state)) { + setResultCode(-1); + setResultData(marker(STATE_ERROR, operationId, privateStatus.error)); + return; + } + if (STATE_SUCCESS.equals(privateStatus.state)) { + setResultCode(-1); + setResultData(marker(STATE_SUCCESS, operationId, null)); + return; + } + if (!STATE_IMPORTED.equals(privateStatus.state)) { + return; + } + + File resultFile = new File(context.getFilesDir(), RESULT_FILE); + if (!resultFile.isFile()) { + setResultCode(-1); + setResultData(marker(STATE_IMPORTED, operationId, null)); + return; + } + JSONObject rawResult = new JSONObject(readSmallFile(resultFile, MAX_RESULT_BYTES)); + Status terminal = sanitizeActivityResult(operationId, rawResult); + if (terminal == null) { + return; + } + writeStatus(context, terminal.operationId, terminal.state, terminal.error); + setResultCode(-1); + setResultData(marker(terminal.state, terminal.operationId, terminal.error)); + } catch (Exception ignored) { + // Missing or partially-written private state is never treated as success. + } + } + + private static File validateRequestPath(String requestedPath, String operationId) + throws IOException { + if (requestedPath == null || !isValidOperationId(operationId) + || !requestedPath.startsWith(REQUEST_DIRECTORY + "/")) { + throw new IOException("invalid-request"); + } + File requested = new File(requestedPath); + String canonicalPath = requested.getCanonicalPath(); + if (!requestedPath.equals(canonicalPath)) { + throw new IOException("invalid-request"); + } + File canonical = new File(canonicalPath); + File parent = canonical.getParentFile(); + String expectedName = "minimum-wifi-" + operationId + ".json"; + if (parent == null + || !REQUEST_DIRECTORY.equals(parent.getCanonicalPath()) + || !expectedName.equals(canonical.getName())) { + throw new IOException("invalid-request"); + } + return canonical; + } + + private static byte[] readAndValidateRequest(File source) throws Exception { + byte[] data = readBytes(source, MAX_REQUEST_BYTES, "invalid-request"); + JSONObject request = new JSONObject(new String(data, StandardCharsets.UTF_8)); + Iterator keys = request.keys(); + int keyCount = 0; + while (keys.hasNext()) { + String key = keys.next(); + keyCount++; + if (!("ssid".equals(key) || "psk".equals(key))) { + throw new IllegalArgumentException("invalid-request"); + } + } + if (keyCount != 2) { + throw new IllegalArgumentException("invalid-request"); + } + Object ssidValue = request.opt("ssid"); + Object pskValue = request.opt("psk"); + if (!(ssidValue instanceof String) || !(pskValue instanceof String)) { + throw new IllegalArgumentException("invalid-request"); + } + validateCredentials((String) ssidValue, (String) pskValue); + return data; + } + + private static void validateCredentials(String ssid, String psk) { + int ssidBytes = ssid.getBytes(StandardCharsets.UTF_8).length; + if (ssidBytes < 1 || ssidBytes > 32) { + throw new IllegalArgumentException("invalid-ssid"); + } + if (!(psk.length() >= 8 && psk.length() <= 63) + && !psk.matches("(?i)^[0-9a-f]{64}$")) { + throw new IllegalArgumentException("invalid-wpa2-psk"); + } + } + + private static void clearPrivateState(Context context) throws IOException { + deleteIfPresent(new File(context.getFilesDir(), REQUEST_FILE)); + deleteIfPresent(new File(context.getFilesDir(), RESULT_FILE)); + deleteIfPresent(new File(context.getFilesDir(), RECEIVER_STATUS_FILE)); + } + + private static void writeStatus(Context context, String operationId, String state, String error) + throws Exception { + JSONObject value = new JSONObject(); + value.put("operationId", operationId); + value.put("state", state); + if (error != null) { + value.put("error", error); + } + writeBytes(new File(context.getFilesDir(), RECEIVER_STATUS_FILE), + value.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static Status readStatus(Context context) throws Exception { + File file = new File(context.getFilesDir(), RECEIVER_STATUS_FILE); + if (!file.isFile()) { + return null; + } + JSONObject value = new JSONObject(readSmallFile(file, MAX_RESULT_BYTES)); + if (!hasOnly(value, "operationId", "state", "error")) { + return null; + } + String operationId = value.optString("operationId", ""); + String state = value.optString("state", ""); + String error = value.has("error") ? value.optString("error", "") : null; + if (!isValidOperationId(operationId)) { + return null; + } + if (STATE_IMPORTED.equals(state) || STATE_SUCCESS.equals(state)) { + return error == null ? new Status(operationId, state, null) : null; + } + if (STATE_ERROR.equals(state) && error != null && SAFE_ERROR.matcher(error).matches()) { + return new Status(operationId, state, error); + } + return null; + } + + private static Status sanitizeActivityResult(String operationId, JSONObject result) { + Object ok = result.opt("ok"); + if (!(ok instanceof Boolean)) { + return null; + } + if (((Boolean) ok).booleanValue()) { + Object networkId = result.opt("networkId"); + if (!(networkId instanceof Number) || !hasExactly(result, "ok", "networkId")) { + return null; + } + return new Status(operationId, STATE_SUCCESS, null); + } + Object error = result.opt("error"); + if (error instanceof String && SAFE_ERROR.matcher((String) error).matches() + && hasExactly(result, "ok", "error")) { + return new Status(operationId, STATE_ERROR, (String) error); + } + return null; + } + + private static boolean hasOnly(JSONObject object, String... allowed) { + Iterator keys = object.keys(); + while (keys.hasNext()) { + String key = keys.next(); + boolean found = false; + for (String candidate : allowed) { + if (candidate.equals(key)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } + + private static boolean hasExactly(JSONObject object, String... expected) { + int count = 0; + Iterator keys = object.keys(); + while (keys.hasNext()) { + String key = keys.next(); + count++; + boolean found = false; + for (String candidate : expected) { + if (candidate.equals(key)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return count == expected.length; + } + + private static byte[] readBytes(File file, int maxBytes, String error) throws IOException { + if (!file.isFile() || file.length() <= 0 || file.length() > maxBytes) { + throw new IOException(error); + } + int size = (int) file.length(); + byte[] data = new byte[size]; + try (FileInputStream input = new FileInputStream(file)) { + int offset = 0; + while (offset < data.length) { + int read = input.read(data, offset, data.length - offset); + if (read < 0) { + throw new IOException(error); + } + offset += read; + } + if (input.read() >= 0 || file.length() != size) { + throw new IOException(error); + } + } + return data; + } + + private static String readSmallFile(File file, int maxBytes) throws IOException { + return new String(readBytes(file, maxBytes, "invalid-status"), StandardCharsets.UTF_8); + } + + private static void writeBytes(File file, byte[] value) throws IOException { + try (FileOutputStream output = new FileOutputStream(file, false)) { + output.write(value); + output.getFD().sync(); + } + } + + private static String marker(String state, String operationId, String error) { + String prefix = state.toUpperCase(Locale.US); + if (STATE_ERROR.equals(state)) { + return prefix + ":" + operationId + ":" + error; + } + return prefix + ":" + operationId; + } + + private static String safeImportError(Exception error) { + String message = error.getMessage(); + if (message != null && SAFE_ERROR.matcher(message).matches()) { + return message; + } + return "invalid-request"; + } + + private static boolean isValidOperationId(String operationId) { + return operationId != null && OPERATION_ID.matcher(operationId).matches(); + } + + private static void deleteIfPresent(File file) throws IOException { + if (file.exists() && !file.delete()) { + throw new IOException("file-cleanup-failed"); + } + } + + private static void bestEffortDelete(File file) { + if (file.exists()) { + file.delete(); + } + } + + private static final class Status { + final String operationId; + final String state; + final String error; + + Status(String operationId, String state, String error) { + this.operationId = operationId; + this.state = state; + this.error = error; + } + } +} diff --git a/tools/t99-wifi-provisioner/verify-prepare-t99-parser.ps1 b/tools/t99-wifi-provisioner/verify-prepare-t99-parser.ps1 new file mode 100644 index 00000000..2db8733f --- /dev/null +++ b/tools/t99-wifi-provisioner/verify-prepare-t99-parser.ps1 @@ -0,0 +1,136 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$preparePath = Join-Path $repoRoot "scripts\prepare-t99.ps1" +$prepareSource = Get-Content -LiteralPath $preparePath -Raw +$receiverPath = Join-Path $PSScriptRoot ` + "app\src\main\java\dev\minimum\wifiprovisioner\WifiProvisionReceiver.java" +$receiverSource = Get-Content -LiteralPath $receiverPath -Raw +$manifestSource = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot "app\src\main\AndroidManifest.xml") -Raw + +foreach ($snippet in @( + '[guid]::NewGuid().ToString("N")', + '$WifiHelperOperationIdExtra = "operationId"', + 'Convert-WifiHelperStatusMarker', + '-ExpectedOperationId $operationId', + '(?im)^\s*Success\s*$', + '"shell", "pm", "list", "packages", $WifiHelperPackage', + 'Assert-WifiRemoteRequestAbsent -RemotePath $remoteRequest', + 'function Invoke-WifiHelperBroadcast', + '$ErrorActionPreference = "Continue"', + '2>&1', + '$_.ToString()')) { + if (-not $prepareSource.Contains($snippet)) { + throw "prepare-t99.ps1 is missing required protocol/cleanup pattern: $snippet" + } +} +if ($prepareSource -match '(?i)Write-(Host|Output|Warning).*?(importCall|statusCall|combinedOutput)' -or + $prepareSource -match '(?i)(psk|password).*Write-(Host|Output|Warning)') { + throw "Helper output or credential material must not be logged." +} + +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $preparePath, [ref]$tokens, [ref]$errors) +if ($errors.Count -gt 0) { + throw "prepare-t99.ps1 has PowerShell parse errors: $($errors[0].Message)" +} +foreach ($name in @("Convert-WifiHelperStatusMarker", "Convert-WifiHelperBroadcastOutput")) { + $function = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $name + }, $true) + if ($null -eq $function) { throw "Missing parser function '$name'." } + Invoke-Expression $function.Extent.Text +} + +$provisioning = [regex]::Match( + $prepareSource, + '(?s)function Invoke-LabWifiProvisioning\s*\{.*?(?=\r?\n\$manufacturer\b)').Value +if (-not $provisioning) { throw "Missing Wi-Fi provisioning state machine." } +$proofIndex = $provisioning.IndexOf('if ($null -eq $importProof)', [StringComparison]::Ordinal) +$removeIndex = $provisioning.IndexOf('@("shell", "rm", "-f", $remoteRequest)', [StringComparison]::Ordinal) +$terminalIndex = $provisioning.IndexOf('$deadline = (Get-Date).AddSeconds(25)', [StringComparison]::Ordinal) +if ($proofIndex -lt 0 -or $removeIndex -le $proofIndex -or $terminalIndex -le $removeIndex) { + throw "Nonce-bound import proof must precede host deletion and terminal polling." +} +if ($provisioning.Contains('Convert-WifiHelperImportAcknowledgement') -or + $provisioning.Contains('$receiverDeleted')) { + throw "Legacy textual ACK/receiver-owned source deletion remains in the state machine." +} + +foreach ($snippet in @( + 'Pattern.compile("^[0-9a-f]{32}$")', + 'String expectedName = "minimum-wifi-" + operationId + ".json"', + 'writeBytes(new File(context.getFilesDir(), REQUEST_FILE), request);', + 'writeStatus(context, operationId, STATE_IMPORTED, null);', + 'output.getFD().sync();')) { + if (-not $receiverSource.Contains($snippet)) { + throw "Receiver is missing required nonce/private-state pattern: $snippet" + } +} +if (-not $manifestSource.Contains('android:permission="android.permission.DUMP"') -or + -not $manifestSource.Contains('android:exported="false"')) { + throw "Manifest must protect the exported receiver with DUMP and keep the activity private." +} +$privateWrite = $receiverSource.IndexOf( + 'writeBytes(new File(context.getFilesDir(), REQUEST_FILE), request);', + [StringComparison]::Ordinal) +$statusWrite = $receiverSource.IndexOf( + 'writeStatus(context, operationId, STATE_IMPORTED, null);', + [StringComparison]::Ordinal) +if ($privateWrite -lt 0 -or $statusWrite -le $privateWrite) { + throw "Receiver must fsync its private request before publishing IMPORTED." +} +if ($receiverSource -match '(?i)Log\.(d|i|w|e).*?(psk|password)' -or + $receiverSource.Contains('source.delete()')) { + throw "Receiver must not log credentials or try to delete the shell-owned source." +} + +function Assert-Fixture([string]$Name, [scriptblock]$Action, [scriptblock]$Expectation) { + $value = & $Action + if (-not (& $Expectation $value)) { throw "Parser fixture failed: $Name" } + Write-Host "Parser fixture passed: $Name" +} + +$nonce = '0123456789abcdef0123456789abcdef' +$other = 'fedcba9876543210fedcba9876543210' +Assert-Fixture "quoted imported" { + Convert-WifiHelperStatusMarker -ExpectedOperationId $nonce -Output @( + "Broadcast completed: result=-1, data=`"IMPORTED:$nonce`"") +} { param($v) $v.State -ceq 'IMPORTED' -and $null -eq $v.Error } +Assert-Fixture "unquoted success" { + Convert-WifiHelperStatusMarker -ExpectedOperationId $nonce -Output @( + "Broadcast completed: result=-1, data=SUCCESS:$nonce") +} { param($v) $v.State -ceq 'SUCCESS' } +Assert-Fixture "sanitized error" { + Convert-WifiHelperStatusMarker -ExpectedOperationId $nonce -Output @( + "Broadcast completed: result=-1, data=`"ERROR:${nonce}:invalid-request`"") +} { param($v) $v.State -ceq 'ERROR' -and $v.Error -ceq 'invalid-request' } +foreach ($fixture in @( + "Broadcast completed: result=0, data=`"SUCCESS:$nonce`"", + "Broadcast completed: result=-1, data=`"SUCCESS:$other`"", + "Broadcast completed: result=-1, data=`"SUCCESS:$nonce`:extra`"", + "SUCCESS:$nonce", + "diagnostic SUCCESS:$nonce", + "", " ")) { + Assert-Fixture "reject malformed/wrong marker" { + Convert-WifiHelperStatusMarker -ExpectedOperationId $nonce -Output @($fixture) + } { param($v) $null -eq $v } +} +Assert-Fixture "ErrorRecord normalization without parser bypass" { + try { throw "native permission denied" } catch { $record = $_ } + $normalized = @(Convert-WifiHelperBroadcastOutput -Output @( + $record, "Broadcast completed: result=-1, data=`"SUCCESS:$nonce`"")) + [pscustomobject]@{ + Normalized = $normalized[0] -is [string] + Parsed = Convert-WifiHelperStatusMarker -ExpectedOperationId $nonce -Output $normalized + } +} { param($v) $v.Normalized -and $v.Parsed.State -ceq 'SUCCESS' } + +Write-Host "All nonce-bound Wi-Fi helper protocol checks passed."