diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02227dc3..5f6189d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ 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 + - name: Test updater and fresh provisioner with real Linux apksigner and debug APK shell: pwsh run: | $apk = (Resolve-Path 'app/build/outputs/apk/foss/debug/mumla-foss-debug.apk').Path @@ -55,6 +55,7 @@ jobs: $env:MINIMUM_TEST_EXPECTED_VERSION_NAME = $package.Groups[3].Value ./tools/verify-cellular-policy.ps1 ./tests/update-minimum-device.Tests.ps1 + ./tests/provision-minimum-device.Tests.ps1 - name: Upload debug APK uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 9b24e3d4..7c7ea398 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -44,12 +44,40 @@ jobs: echo "expected_version_code must be a positive integer." >&2 exit 1 fi - - name: Checkout release tag with Humla + # workflow_dispatch always loads this workflow from the default branch. Check out that + # reviewed branch first and refuse to run repository code from a supplied tag until the + # tag has been resolved and bound to the exact current origin/main commit. + - name: Checkout reviewed main for release authorization uses: actions/checkout@v4 with: - ref: refs/tags/${{ inputs.tag }} - submodules: recursive + ref: main fetch-depth: 0 + persist-credentials: false + + - name: Bind release tag to current reviewed main + id: authorize_release + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + git fetch --force --no-tags origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + main_sha=$(git rev-parse --verify "refs/remotes/origin/main^{commit}") + tag_sha=$(git rev-parse --verify "refs/tags/$RELEASE_TAG^{commit}") + if [[ "$tag_sha" != "$main_sha" ]]; then + echo "Release tag must point to the exact current reviewed origin/main commit." >&2 + echo "Refusing to check out or execute repository code from the supplied tag." >&2 + exit 1 + fi + echo "release_sha=$tag_sha" >> "$GITHUB_OUTPUT" + + - name: Checkout authorized release commit with Humla + env: + RELEASE_SHA: ${{ steps.authorize_release.outputs.release_sha }} + run: | + git checkout --detach "$RELEASE_SHA" + git submodule sync --recursive + git submodule update --init --recursive - name: Verify required signing secrets env: MINIMUM_RELEASE_KEYSTORE_BASE64: ${{ secrets.MINIMUM_RELEASE_KEYSTORE_BASE64 }} @@ -134,18 +162,20 @@ jobs: grep -F "versionName='$RELEASE_TAG'" "$RUNNER_TEMP/apk-badging.txt" cp "$APK" "minimum-${RELEASE_TAG}-foss.apk" sha256sum "minimum-${RELEASE_TAG}-foss.apk" > "minimum-${RELEASE_TAG}-foss.apk.sha256" - - name: Test Windows updater logic + - name: Test Windows updater and fresh provisioning logic shell: pwsh run: | - $errors = $null - $tokens = $null - [System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path 'scripts/update-minimum-device.ps1'), - [ref]$tokens, - [ref]$errors) | Out-Null - if ($errors.Count -gt 0) { - $errors | Format-List * - throw "Updater failed PowerShell AST parsing." + foreach ($script in @('scripts/update-minimum-device.ps1', 'scripts/provision-minimum-device.ps1')) { + $errors = $null + $tokens = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path $script), + [ref]$tokens, + [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $errors | Format-List * + throw "$script 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 @@ -153,6 +183,7 @@ jobs: $env:MINIMUM_TEST_EXPECTED_VERSION_CODE = '${{ inputs.expected_version_code }}' $env:MINIMUM_TEST_EXPECTED_VERSION_NAME = '${{ inputs.tag }}' ./tests/update-minimum-device.Tests.ps1 + ./tests/provision-minimum-device.Tests.ps1 - name: Build temporary Wi-Fi provisioner run: | ./gradlew -p tools/t99-wifi-provisioner :app:assembleDebug --no-daemon --stacktrace diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 50a3b52c..af6ba66f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ ~ along with this program. If not, see . --> - - @@ -91,11 +90,14 @@ + + android:foregroundServiceType="microphone" + tools:ignore="ExportedService" /> - + + android:exported="true" + tools:ignore="ExportedReceiver"> diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioConnectionConfig.java b/app/src/main/java/se/lublin/mumla/radio/RadioConnectionConfig.java index 4a5cd8a6..74eb4d08 100644 --- a/app/src/main/java/se/lublin/mumla/radio/RadioConnectionConfig.java +++ b/app/src/main/java/se/lublin/mumla/radio/RadioConnectionConfig.java @@ -34,16 +34,19 @@ public final class RadioConnectionConfig { private final String serviceName; private final boolean autoConnect; private final boolean autoReconnect; + private final int maximumTxSeconds; private final List channels; private final int defaultChannelIndex; private RadioConnectionConfig(int configVersion, String serviceName, boolean autoConnect, - boolean autoReconnect, List channels, + boolean autoReconnect, int maximumTxSeconds, + List channels, int defaultChannelIndex) { this.configVersion = configVersion; this.serviceName = serviceName; this.autoConnect = autoConnect; this.autoReconnect = autoReconnect; + this.maximumTxSeconds = maximumTxSeconds; this.channels = Collections.unmodifiableList(new ArrayList<>(channels)); this.defaultChannelIndex = defaultChannelIndex; } @@ -56,6 +59,8 @@ public static RadioConnectionConfig fromJson(JSONObject config) throws JSONExcep ? "Minimum" : requireNonBlank(service.optString("name", "Minimum"), "service name"); JSONObject radio = config.getJSONObject("radio"); + JSONObject ptt = config.getJSONObject("ptt"); + int maximumTxSeconds = ptt.getInt("maximumTxSeconds"); String defaultChannelId = requireIdentifier( radio.optString("defaultChannel", ""), "default channel"); @@ -111,6 +116,7 @@ public static RadioConnectionConfig fromJson(JSONObject config) throws JSONExcep serviceName, radio.optBoolean("autoConnect", false), radio.optBoolean("autoReconnect", true), + maximumTxSeconds, channels, defaultChannelIndex); } @@ -312,6 +318,10 @@ public boolean isAutoReconnect() { return autoReconnect; } + public int getMaximumTxSeconds() { + return maximumTxSeconds; + } + public List getChannels() { return channels; } diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioProcessWatchdog.java b/app/src/main/java/se/lublin/mumla/radio/RadioProcessWatchdog.java index ca7b4f5c..9e72f830 100644 --- a/app/src/main/java/se/lublin/mumla/radio/RadioProcessWatchdog.java +++ b/app/src/main/java/se/lublin/mumla/radio/RadioProcessWatchdog.java @@ -9,6 +9,7 @@ package se.lublin.mumla.radio; +import android.annotation.SuppressLint; import android.app.AlarmManager; import android.app.PendingIntent; import android.content.Context; @@ -33,7 +34,9 @@ public final class RadioProcessWatchdog { private RadioProcessWatchdog() { } + @SuppressLint("MissingPermission") public static void arm(Context context) { + // Exact alarm APIs are confined to pre-S; S+ deliberately uses the inexact API. Context appContext = context.getApplicationContext(); AlarmManager alarmManager = (AlarmManager) appContext.getSystemService(Context.ALARM_SERVICE); if (alarmManager == null) { 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 6650f4ae..7022bec4 100644 --- a/app/src/main/java/se/lublin/mumla/radio/RadioShellActivity.java +++ b/app/src/main/java/se/lublin/mumla/radio/RadioShellActivity.java @@ -36,6 +36,7 @@ import android.widget.ProgressBar; import android.widget.TextView; +import androidx.activity.OnBackPressedCallback; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; @@ -328,6 +329,7 @@ public void onServiceConnected(ComponentName name, IBinder binder) { // Service-owned room readiness survives Activity stop/start (including screen-off). // Connection and channel observers still clear it when the actual radio state changes. service.registerObserver(observer); + applyServicePttPolicy(); updateFromService(); maybeApplyPendingConfiguration(); maybeConnect(); @@ -348,6 +350,13 @@ protected void onCreate(Bundle savedInstanceState) { settings = Settings.getInstance(this); database = new MumlaSQLiteDatabase(this); database.open(); + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + // Dedicated radio Back always returns to the controlled recovery dashboard. + openRecoveryDashboard(); + } + }); buildUi(); acceptIdentityToggleIntent(getIntent()); acceptPttRecoveryIntent(getIntent()); @@ -408,11 +417,6 @@ protected void onDestroy() { super.onDestroy(); } - @Override - public void onBackPressed() { - openRecoveryDashboard(); - } - private void openRecoveryDashboard() { Intent recoveryDashboard = new Intent(this, MinimumHomeActivity.class) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); @@ -650,6 +654,7 @@ private void loadConfiguration() { private void applyConfigurationToUi(RadioConnectionConfig loaded) { config = loaded; + applyServicePttPolicy(); connectionRetrySuspended = false; joinedConfiguredRoom = false; updateServiceRoomReady(false); @@ -1431,6 +1436,12 @@ private void updateServiceRoomReady(boolean ready) { } } + private void applyServicePttPolicy() { + if (service != null && config != null) { + service.setMaximumPttSeconds(config.getMaximumTxSeconds()); + } + } + private int dp(int value) { return (int) (value * getResources().getDisplayMetrics().density + 0.5f); } diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java index 3ec27ac6..1f90322f 100644 --- a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java @@ -9,6 +9,7 @@ package se.lublin.mumla.radio.tracking; +import android.annotation.SuppressLint; import android.Manifest; import android.app.AlarmManager; import android.app.PendingIntent; @@ -114,6 +115,9 @@ public void onStatusChanged(String provider, int status, android.os.Bundle extra private volatile AprsTrackingConfig config = AprsTrackingConfig.disabled(); private volatile boolean stopped; + private final TrackingAttemptGate attemptGate = new TrackingAttemptGate(); + /** Accessed only on the location handler thread. */ + private long inFlightLogicalId = TrackingAttemptGate.NO_ATTEMPT; private volatile int mobileRssiDbm = AprsHealthSnapshot.UNKNOWN; private volatile long mobileRssiElapsedRealtime; private TelephonyManager telephonyManager; @@ -153,21 +157,31 @@ public void reloadConfig(JSONObject root) { return; } final AprsTrackingConfig loaded = parseConfigOrDisabled(root, hardwareProfile, true); - if (!loaded.isEnabled() || !loaded.isAprsEnabled()) { - // Flip the in-memory gate before scheduling listener/alarm cleanup so concurrent - // PTT/location callbacks fail closed while the handler drains its queue. - config = loaded; - } + final long reconfigurationTicket = attemptGate.beginReconfigure(); handler.post(() -> { String nextObjectName = loaded.getObjectName().isEmpty() ? defaultObjectName : loaded.getObjectName(); - if (!nextObjectName.equals(objectName)) { - objectName = nextObjectName; - coordinator.resetForObjectIdentity(); - clearPersistedSuccess(); + boolean objectIdentityChanged = !nextObjectName.equals(objectName); + boolean enabled = loaded.isEnabled() && loaded.isAprsEnabled(); + boolean applied = attemptGate.applyReconfiguration(reconfigurationTicket, enabled, + () -> { + if (inFlightLogicalId != TrackingAttemptGate.NO_ATTEMPT) { + // Lifecycle-owned abandonment, not a stale transport result. + coordinator.onPermanentFailure(inFlightLogicalId); + inFlightLogicalId = TrackingAttemptGate.NO_ATTEMPT; + } + if (objectIdentityChanged) { + objectName = nextObjectName; + coordinator.resetForObjectIdentity(); + clearPersistedSuccess(); + } + config = loaded; + }); + if (!applied) { + return; } - config = loaded; if (!config.isEnabled() || !config.isAprsEnabled()) { + coordinator.resetForObjectIdentity(); stopLocationUpdates(); stopMobileSignalListener(); cancelPoll(); @@ -225,6 +239,11 @@ public void onPttPressed() { } // This only evaluates the cached accepted fix; it never waits for or starts a GPS fix. handler.post(() -> { + if (stopped || !config.isEnabled() || !config.isAprsEnabled() + || !config.isPttTriggered() + || attemptGate.beginAttempt() == TrackingAttemptGate.NO_ATTEMPT) { + return; + } AprsBeaconCoordinator.Decision decision = coordinator.onPtt( System.currentTimeMillis(), SystemClock.elapsedRealtime()); logDecision("PTT", decision); @@ -237,6 +256,7 @@ public void stop() { return; } stopped = true; + attemptGate.stop(); handler.post(() -> { stopLocationUpdates(); stopMobileSignalListener(); @@ -273,11 +293,16 @@ private void sendReady() { if (stopped || !config.isAprsEnabled()) { return; } + final long attempt = attemptGate.beginAttempt(); + if (attempt == TrackingAttemptGate.NO_ATTEMPT) { + return; + } AprsBeaconCoordinator.Beacon beacon = coordinator.takeReady(SystemClock.elapsedRealtime()); if (beacon == null) { scheduleRetryIfNeeded(); return; } + inFlightLogicalId = beacon.getLogicalId(); final AprsTrackingConfig packetConfig = config; final String packetObjectName = objectName; final String packet; @@ -289,41 +314,60 @@ private void sendReady() { symbolCodeFor(beacon.getMovementState()), healthComment); } catch (RuntimeException exception) { Log.w(TAG, "APRS packet rejected before transport: " + exception.getClass().getSimpleName()); - coordinator.onSendFailure(beacon.getLogicalId(), false, SystemClock.elapsedRealtime()); - scheduleRetryIfNeeded(); + attemptGate.runIfCurrent(attempt, () -> { + coordinator.onSendFailure(beacon.getLogicalId(), false, + SystemClock.elapsedRealtime()); + inFlightLogicalId = TrackingAttemptGate.NO_ATTEMPT; + scheduleRetryIfNeeded(); + }); return; } try { transportExecutor.execute(() -> { - AprsTransport.SendResult result = transport.send(packetConfig, packet); + AprsTransport.SendResult result; + try { + result = transport.send(packetConfig, packet); + } catch (RuntimeException exception) { + result = AprsTransport.SendResult.retryable( + "transport exception: " + exception.getClass().getSimpleName()); + } + AprsTransport.SendResult finalResult = result; handler.post(() -> { - long now = SystemClock.elapsedRealtime(); - if (result.getStatus() == AprsTransport.SendResult.Status.SUCCESS) { - boolean applied = coordinator.onSendSuccess(beacon.getLogicalId(), now); - if (applied && packetObjectName.equals(objectName)) { - persistSuccess(beacon, now, packetObjectName); - Log.i(TAG, "APRS position accepted by send-only server"); + attemptGate.runIfCurrent(attempt, () -> { + long now = SystemClock.elapsedRealtime(); + if (finalResult.getStatus() == AprsTransport.SendResult.Status.SUCCESS) { + boolean resultApplied = coordinator.onSendSuccess( + beacon.getLogicalId(), now); + if (resultApplied && packetObjectName.equals(objectName)) { + persistSuccess(beacon, now, packetObjectName); + Log.i(TAG, "APRS position accepted by send-only server"); + } else { + Log.i(TAG, "APRS receipt ignored after Object identity changed"); + } + } else if (finalResult.getStatus() + == AprsTransport.SendResult.Status.PERMANENT_FAILURE) { + coordinator.onPermanentFailure(beacon.getLogicalId()); + Log.w(TAG, "APRS send disabled until configuration changes: " + + finalResult.getDetail()); } else { - Log.i(TAG, "APRS receipt ignored after Object identity changed"); + coordinator.onSendFailure(beacon.getLogicalId(), + finalResult.getStatus() + == AprsTransport.SendResult.Status.UNCERTAIN_DELIVERY, + now); + Log.w(TAG, "APRS send failed: " + finalResult.getDetail()); } - } else if (result.getStatus() - == AprsTransport.SendResult.Status.PERMANENT_FAILURE) { - coordinator.onPermanentFailure(beacon.getLogicalId()); - Log.w(TAG, "APRS send disabled until configuration changes: " - + result.getDetail()); - } else { - coordinator.onSendFailure(beacon.getLogicalId(), - result.getStatus() - == AprsTransport.SendResult.Status.UNCERTAIN_DELIVERY, - now); - Log.w(TAG, "APRS send failed: " + result.getDetail()); - } - scheduleRetryIfNeeded(); + inFlightLogicalId = TrackingAttemptGate.NO_ATTEMPT; + scheduleRetryIfNeeded(); + }); }); }); } catch (RejectedExecutionException ignored) { - coordinator.onSendFailure(beacon.getLogicalId(), false, - SystemClock.elapsedRealtime()); + attemptGate.runIfCurrent(attempt, () -> { + coordinator.onSendFailure(beacon.getLogicalId(), false, + SystemClock.elapsedRealtime()); + inFlightLogicalId = TrackingAttemptGate.NO_ATTEMPT; + scheduleRetryIfNeeded(); + }); } } @@ -407,13 +451,18 @@ private boolean hasLocationPermission() { == PackageManager.PERMISSION_GRANTED; } + @SuppressLint("MissingPermission") private void stopLocationUpdates() { - if (locationManager != null) { - try { - locationManager.removeUpdates(listener); - } catch (SecurityException ignored) { - // Nothing to release when the permission was revoked. - } + if (locationManager == null) { + return; + } + try { + // Unregistration is required even after permission is revoked. The permission lint is + // intentionally suppressed for this cleanup-only call; a concurrent/revoked denial is + // harmless and caught below. + locationManager.removeUpdates(listener); + } catch (SecurityException ignored) { + // Permission can be revoked before or during listener removal. } } diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/TrackingAttemptGate.java b/app/src/main/java/se/lublin/mumla/radio/tracking/TrackingAttemptGate.java new file mode 100644 index 00000000..9acdd239 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/TrackingAttemptGate.java @@ -0,0 +1,50 @@ +/* + * 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.tracking; + +/** Linearizes asynchronous tracking results against config reload and manager shutdown. */ +final class TrackingAttemptGate { + static final long NO_ATTEMPT = -1L; + + private long generation = 1L; + private boolean active = true; + + synchronized long beginAttempt() { + return active ? generation : NO_ATTEMPT; + } + + synchronized long beginReconfigure() { + active = false; + return ++generation; + } + + synchronized boolean applyReconfiguration(long ticket, boolean enableAttempts, + Runnable configurationCommit) { + if (ticket != generation) { + return false; + } + configurationCommit.run(); + active = enableAttempts; + return true; + } + + synchronized boolean runIfCurrent(long attempt, Runnable resultCommit) { + if (!active || attempt != generation) { + return false; + } + resultCommit.run(); + return true; + } + + synchronized void stop() { + active = false; + generation++; + } +} diff --git a/app/src/main/java/se/lublin/mumla/service/IMumlaService.java b/app/src/main/java/se/lublin/mumla/service/IMumlaService.java index acd31fad..a09cf667 100644 --- a/app/src/main/java/se/lublin/mumla/service/IMumlaService.java +++ b/app/src/main/java/se/lublin/mumla/service/IMumlaService.java @@ -22,12 +22,18 @@ public interface IMumlaService extends IHumlaService { void onTalkKeyUp(); + /** Applies a legacy external TALK state through service-owned readiness and watchdog gates. */ + void onExternalTalkCommand(String status); + /** Blocks PTT until a subsequent key-up proves the recovery press has ended. */ void requirePttRelease(); /** Updates the managed-radio TX gate after the configured room has been verified. */ void setRadioRoomReady(boolean ready); + /** Applies the validated managed-radio maximum continuous transmission duration. */ + void setMaximumPttSeconds(int maximumTxSeconds); + boolean isRadioReceiving(); List getRadioTalkers(); diff --git a/app/src/main/java/se/lublin/mumla/service/MumlaConnectionNotification.java b/app/src/main/java/se/lublin/mumla/service/MumlaConnectionNotification.java index 037f80b4..2eea2899 100644 --- a/app/src/main/java/se/lublin/mumla/service/MumlaConnectionNotification.java +++ b/app/src/main/java/se/lublin/mumla/service/MumlaConnectionNotification.java @@ -19,7 +19,6 @@ import static android.app.PendingIntent.FLAG_CANCEL_CURRENT; import static android.app.PendingIntent.FLAG_IMMUTABLE; -import static android.content.Context.RECEIVER_NOT_EXPORTED; import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; import android.app.Notification; @@ -34,6 +33,7 @@ import android.os.Build; import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; import se.lublin.mumla.R; import se.lublin.mumla.app.DrawerAdapter; @@ -105,11 +105,8 @@ public void show() { filter.addAction(BROADCAST_MUTE); filter.addAction(BROADCAST_OVERLAY); try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - mService.registerReceiver(mNotificationReceiver, filter, RECEIVER_NOT_EXPORTED); - } else { - mService.registerReceiver(mNotificationReceiver, filter); - } + ContextCompat.registerReceiver(mService, mNotificationReceiver, filter, + ContextCompat.RECEIVER_NOT_EXPORTED); } catch (IllegalArgumentException e) { // Thrown if receiver is already registered. e.printStackTrace(); diff --git a/app/src/main/java/se/lublin/mumla/service/MumlaMessageNotification.java b/app/src/main/java/se/lublin/mumla/service/MumlaMessageNotification.java index c170fc10..23ea8ada 100644 --- a/app/src/main/java/se/lublin/mumla/service/MumlaMessageNotification.java +++ b/app/src/main/java/se/lublin/mumla/service/MumlaMessageNotification.java @@ -20,16 +20,19 @@ import static android.app.PendingIntent.FLAG_CANCEL_CURRENT; import static android.app.PendingIntent.FLAG_IMMUTABLE; +import android.Manifest; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.os.Build; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; import java.util.ArrayList; import java.util.List; @@ -104,7 +107,15 @@ public void show(IMessage message) { final NotificationManagerCompat manager = NotificationManagerCompat.from(mContext); Notification notification = builder.build(); - manager.notify(NOTIFICATION_ID, notification); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + || ContextCompat.checkSelfPermission(mContext, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + try { + manager.notify(NOTIFICATION_ID, notification); + } catch (SecurityException ignored) { + // Permission can be revoked between the explicit check and notification delivery. + } + } } /** diff --git a/app/src/main/java/se/lublin/mumla/service/MumlaReconnectNotification.java b/app/src/main/java/se/lublin/mumla/service/MumlaReconnectNotification.java index 2a85e07c..2d6b2e69 100644 --- a/app/src/main/java/se/lublin/mumla/service/MumlaReconnectNotification.java +++ b/app/src/main/java/se/lublin/mumla/service/MumlaReconnectNotification.java @@ -19,8 +19,8 @@ import static android.app.PendingIntent.FLAG_CANCEL_CURRENT; import static android.app.PendingIntent.FLAG_IMMUTABLE; -import static android.content.Context.RECEIVER_NOT_EXPORTED; +import android.Manifest; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; @@ -28,10 +28,12 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.pm.PackageManager; import android.os.Build; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; import se.lublin.mumla.R; @@ -82,11 +84,8 @@ public void show(String error, boolean autoReconnect) { filter.addAction(BROADCAST_RECONNECT); filter.addAction(BROADCAST_CANCEL_RECONNECT); try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - mContext.registerReceiver(mNotificationReceiver, filter, RECEIVER_NOT_EXPORTED); - } else { - mContext.registerReceiver(mNotificationReceiver, filter); - } + ContextCompat.registerReceiver(mContext, mNotificationReceiver, filter, + ContextCompat.RECEIVER_NOT_EXPORTED); } catch (IllegalArgumentException e) { // Thrown if receiver is already registered. e.printStackTrace(); @@ -132,7 +131,15 @@ public void show(String error, boolean autoReconnect) { } NotificationManagerCompat nmc = NotificationManagerCompat.from(mContext); - nmc.notify(NOTIFICATION_ID, builder.build()); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + || ContextCompat.checkSelfPermission(mContext, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + try { + nmc.notify(NOTIFICATION_ID, builder.build()); + } catch (SecurityException ignored) { + // Permission can be revoked between the explicit check and notification delivery. + } + } } public void hide() { diff --git a/app/src/main/java/se/lublin/mumla/service/MumlaService.java b/app/src/main/java/se/lublin/mumla/service/MumlaService.java index 7bc3908e..8c4096e3 100644 --- a/app/src/main/java/se/lublin/mumla/service/MumlaService.java +++ b/app/src/main/java/se/lublin/mumla/service/MumlaService.java @@ -17,6 +17,7 @@ package se.lublin.mumla.service; +import android.annotation.SuppressLint; import android.content.BroadcastReceiver; import android.content.Intent; import android.content.IntentFilter; @@ -57,6 +58,7 @@ import se.lublin.humla.model.IUser; import se.lublin.humla.model.Message; import se.lublin.humla.model.TalkState; +import se.lublin.humla.util.CleanupRunner; import se.lublin.humla.util.HumlaException; import se.lublin.humla.util.HumlaObserver; import se.lublin.mumla.R; @@ -103,7 +105,6 @@ public class MumlaService extends HumlaService implements public static final int TTS_THRESHOLD = 250; // Maximum number of characters to read public static final int RECONNECT_DELAY = 10000; static final int MAX_MESSAGE_LOG_ENTRIES = 256; - private static final int MAX_PTT_SECONDS = 120; private static final long PTT_DELIVERY_CONFIRM_MS = 1500L; private static final long RADIO_WAKE_COOLDOWN_MS = 1000L; private static final long RADIO_WAKE_DURATION_MS = 5000L; @@ -139,6 +140,8 @@ public static boolean dispatchRadioPttAction(String action) { private MumlaConnectionNotification mNotification; private MumlaMessageNotification mMessageNotification; private MumlaReconnectNotification mReconnectNotification; + /** Set before app-owned fields are cleared so late Humla callbacks cannot touch them. */ + private volatile boolean mDestroying; /** Channel view overlay. */ private MumlaOverlay mChannelOverlay; /** Proximity lock for handset mode. */ @@ -159,6 +162,9 @@ public static boolean dispatchRadioPttAction(String action) { private final Handler mPttWatchdogHandler = new Handler(Looper.getMainLooper()); private boolean mPttInputDown; private boolean mPttWatchdogLockout; + private boolean mPttWatchdogArmed; + private int mMaximumPttSeconds = RadioPttWatchdogPolicy.DEFAULT_MAXIMUM_TX_SECONDS; + private int mArmedPttMaximumSeconds = RadioPttWatchdogPolicy.DEFAULT_MAXIMUM_TX_SECONDS; private final RadioReceiveTracker mRadioReceiveTracker = new RadioReceiveTracker(); private final Runnable mRadioProcessWatchdogHeartbeat = new Runnable() { @Override @@ -179,7 +185,7 @@ public void run() { private final Runnable mPttDeliveryFailureCheck = new Runnable() { @Override public void run() { - if (mPttInputDown && mPttPressStartedElapsedRealtime > 0L + if (mPttWatchdogArmed && isTalking() && mPttPressStartedElapsedRealtime > 0L && getLastAudioPacketSentElapsedRealtime() < mPttPressStartedElapsedRealtime) { mPttFailureAlerted = true; @@ -190,18 +196,19 @@ && getLastAudioPacketSentElapsedRealtime() private final Runnable mPttWatchdog = new Runnable() { @Override public void run() { - if (!mPttInputDown) { + if (!mPttWatchdogArmed || !isTalking()) { return; } // Fail safe: stop transmitting and require a real release before another TX. + mPttWatchdogArmed = false; + mPttWatchdogHandler.removeCallbacks(mPttDeliveryFailureCheck); mPttWatchdogLockout = true; mPttInputDown = false; - if (isTalking()) { - setTalkingState(false); - } + setPttTalkingState(false); playPttFailureAlert(); - Log.w(TAG, "PTT watchdog stopped transmission after " + MAX_PTT_SECONDS + " seconds"); + Log.w(TAG, "PTT watchdog stopped transmission after " + + mArmedPttMaximumSeconds + " seconds"); } }; /** Try to shorten spoken messages when using TTS */ @@ -518,62 +525,118 @@ public int onStartCommand(Intent intent, int flags, int startId) { @Override public void onDestroy() { + mDestroying = true; + try { + destroyMumlaResources(); + } finally { + try { + setProximitySensorOn(false); + } finally { + super.onDestroy(); + } + } + } + + private void destroyMumlaResources() { if (sRunningService == this) { sRunningService = null; } - if (mAprsTrackingManager != null) { - mAprsTrackingManager.stop(); - mAprsTrackingManager = null; - } - mPttWatchdogHandler.removeCallbacks(mRadioProcessWatchdogHeartbeat); - mRadioReceiveTracker.clear(); - releasePttForSafety(true); - setPttMediaSessionActive(false); - if (mPttMediaSession != null) { - mPttMediaSession.release(); - mPttMediaSession = null; - } - if (mRadioAlertTone != null) { - mRadioAlertTone.release(); - mRadioAlertTone = null; - } - if (mRadioScreenWakeLock != null && mRadioScreenWakeLock.isHeld()) { - mRadioScreenWakeLock.release(); - } - if (mNotification != null) { - mNotification.hide(); - mNotification = null; - } - if (mReconnectNotification != null) { - mReconnectNotification.hide(); - mReconnectNotification = null; + AprsTrackingManager trackingManager = mAprsTrackingManager; + mAprsTrackingManager = null; + MediaSession pttMediaSession = mPttMediaSession; + ToneGenerator radioAlertTone = mRadioAlertTone; + PowerManager.WakeLock radioScreenWakeLock = mRadioScreenWakeLock; + MumlaConnectionNotification notification = mNotification; + MumlaReconnectNotification reconnectNotification = mReconnectNotification; + RadioHardwareKeyReceiver hardwareKeyReceiver = mRadioHardwareKeyReceiver; + TextToSpeech textToSpeech = mTTS; + MumlaMessageNotification messageNotification = mMessageNotification; + mPttMediaSession = null; + mRadioAlertTone = null; + mRadioScreenWakeLock = null; + mNotification = null; + mReconnectNotification = null; + mRadioHardwareKeyReceiver = null; + mTTS = null; + mMessageNotification = null; + mPttMediaKeyDown = false; + mMessageLog = null; + + RuntimeException failure = CleanupRunner.runAll( + () -> { + if (trackingManager != null) { + trackingManager.stop(); + } + }, + () -> mPttWatchdogHandler.removeCallbacks(mRadioProcessWatchdogHeartbeat), + mRadioReceiveTracker::clear, + () -> releasePttForSafety(true), + () -> { + if (pttMediaSession != null) { + pttMediaSession.setActive(false); + } + }, + () -> { + if (pttMediaSession != null) { + pttMediaSession.release(); + } + }, + () -> { + if (radioAlertTone != null) { + radioAlertTone.release(); + } + }, + () -> { + if (radioScreenWakeLock != null && radioScreenWakeLock.isHeld()) { + radioScreenWakeLock.release(); + } + }, + () -> { + if (notification != null) { + notification.hide(); + } + }, + () -> { + if (reconnectNotification != null) { + reconnectNotification.hide(); + } + }, + () -> PreferenceManager.getDefaultSharedPreferences(this) + .unregisterOnSharedPreferenceChangeListener(this), + () -> unregisterReceiverIfRegistered(mTalkReceiver), + () -> unregisterReceiverIfRegistered(hardwareKeyReceiver), + () -> unregisterObserver(mObserver), + () -> { + if (textToSpeech != null) { + textToSpeech.shutdown(); + } + }, + () -> { + if (messageNotification != null) { + messageNotification.dismiss(); + } + }); + if (failure != null) { + Log.e(TAG, "Mumla teardown completed with a resource failure", failure); } + } - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - preferences.unregisterOnSharedPreferenceChangeListener(this); - try { - unregisterReceiver(mTalkReceiver); - } catch (IllegalArgumentException e) { - e.printStackTrace(); + private void unregisterReceiverIfRegistered(BroadcastReceiver receiver) { + if (receiver == null) { + return; } - if (mRadioHardwareKeyReceiver != null) { - try { - unregisterReceiver(mRadioHardwareKeyReceiver); - } catch (IllegalArgumentException ignored) { - // A partial service startup may not have completed receiver registration. - } - mRadioHardwareKeyReceiver = null; + try { + unregisterReceiver(receiver); + } catch (IllegalArgumentException ignored) { + // A partial service startup may not have completed receiver registration. } - - unregisterObserver(mObserver); - if(mTTS != null) mTTS.shutdown(); - mMessageLog = null; - mMessageNotification.dismiss(); - super.onDestroy(); } @Override public void onConnectionSynchronized() { + if (mDestroying) { + return; + } // TODO? We seem to be getting a RuntimeException here, from the call // to the superclass function (in HumlaService). In there, // mConnect.getSession() finds that isSynchronized==false and throws @@ -596,11 +659,10 @@ public void onConnectionSynchronized() { setSelfMuteDeafState(mSettings.isMuted(), mSettings.isDeafened()); } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - registerReceiver(mTalkReceiver, new IntentFilter(TalkBroadcastReceiver.BROADCAST_TALK), RECEIVER_EXPORTED); - } else { - registerReceiver(mTalkReceiver, new IntentFilter(TalkBroadcastReceiver.BROADCAST_TALK)); - } + // Intentional legacy external TALK control on controlled dedicated deployments. + ContextCompat.registerReceiver(this, mTalkReceiver, + new IntentFilter(TalkBroadcastReceiver.BROADCAST_TALK), + ContextCompat.RECEIVER_EXPORTED); if (mSettings.isHotCornerEnabled()) { mHotCorner.setShown(true); @@ -619,6 +681,13 @@ public void onConnectionDisconnected(HumlaException e) { mRadioRoomReady = false; releasePttForSafety(true); super.onConnectionDisconnected(e); + // Humla disconnects its connection from super.onDestroy(). Dynamic dispatch can therefore + // arrive here after destroyMumlaResources() has already cleared app-owned notification and + // UI fields. The Humla half still receives the disconnect above; skip only the cleared + // Mumla resources so teardown remains idempotent and cannot throw a late NPE. + if (mDestroying) { + return; + } updatePttMediaSessionState(); try { unregisterReceiver(mTalkReceiver); @@ -726,13 +795,19 @@ else if (mTTS != null && !mSettings.isTextToSpeechEnabled()) { } } + @SuppressLint("WakelockTimeout") private void setProximitySensorOn(boolean on) { if(on) { + if (mProximityLock != null && mProximityLock.isHeld()) { + return; + } PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); mProximityLock = pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, "Mumla:Proximity"); + // This lifecycle-owned lock must remain held while handset audio is connected. + mProximityLock.setReferenceCounted(false); mProximityLock.acquire(); } else { - if(mProximityLock != null) mProximityLock.release(); + if(mProximityLock != null && mProximityLock.isHeld()) mProximityLock.release(); mProximityLock = null; } } @@ -757,13 +832,6 @@ public void onDeafenToggled() { @Override public void onOverlayToggled() { - // Ditch notification shade/panel to make overlay presence/permission request visible. - // But on Android 12 that's no longer allowed. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - Intent close = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); - getApplicationContext().sendBroadcast(close); - } - if (!mChannelOverlay.isShown()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!android.provider.Settings.canDrawOverlays(getApplicationContext())) { @@ -1010,12 +1078,8 @@ public void onTalkKeyDown() { mPttInputDown = true; mPttFailureAlerted = false; - mPttPressStartedElapsedRealtime = SystemClock.elapsedRealtime(); if (!mSettings.isPushToTalkToggle() && !isTalking()) { - setTalkingState(true); // Start talking - mPttWatchdogHandler.postDelayed(mPttWatchdog, MAX_PTT_SECONDS * 1000L); - mPttWatchdogHandler.postDelayed(mPttDeliveryFailureCheck, - PTT_DELIVERY_CONFIRM_MS); + setPttTalkingState(true); // Start talking } } @@ -1026,20 +1090,26 @@ public void onTalkKeyDown() { @Override public void onTalkKeyUp() { RadioPttRecoveryGuard.noteRelease(); - boolean hadNoAudioPacket = mPttInputDown && mPttPressStartedElapsedRealtime > 0L + boolean wasLockedOut = mPttWatchdogLockout; + boolean wasTalking = isTalking(); + boolean hadNoAudioPacket = wasTalking && mPttPressStartedElapsedRealtime > 0L && getLastAudioPacketSentElapsedRealtime() < mPttPressStartedElapsedRealtime; mPttInputDown = false; - mPttPressStartedElapsedRealtime = 0L; mPttWatchdogLockout = false; - mPttWatchdogHandler.removeCallbacks(mPttWatchdog); - mPttWatchdogHandler.removeCallbacks(mPttDeliveryFailureCheck); + if (wasLockedOut) { + disarmPttTransmissionWatchdog(); + mPttFailureAlerted = false; + return; + } if(isConnectionEstablished() && Settings.ARRAY_INPUT_METHOD_PTT.equals(mSettings.getInputMethod())) { if (mSettings.isPushToTalkToggle()) { - setTalkingState(!isTalking()); // Toggle talk state + setPttTalkingState(!wasTalking); // Toggle talk state } else if (isTalking()) { - setTalkingState(false); // Stop talking + setPttTalkingState(false); // Stop talking } + } else { + disarmPttTransmissionWatchdog(); } if (hadNoAudioPacket && !mPttFailureAlerted) { playPttFailureAlert(); @@ -1047,6 +1117,78 @@ public void onTalkKeyUp() { mPttFailureAlerted = false; } + @Override + public void onExternalTalkCommand(String status) { + if (mDestroying) { + return; + } + boolean readyToTransmit = RadioPttSafetyPolicy.canStartTransmission( + isSynchronized(), + Settings.ARRAY_INPUT_METHOD_PTT.equals(mSettings.getInputMethod()), + isManagedRadioDevice(), + mRadioRoomReady); + RadioExternalTalkPolicy.Decision decision = RadioExternalTalkPolicy.decide( + status, + isTalking(), + readyToTransmit, + mPttWatchdogLockout || RadioPttRecoveryGuard.isReleaseRequired()); + if (decision == RadioExternalTalkPolicy.Decision.START) { + setPttTalkingState(true); + } else if (decision == RadioExternalTalkPolicy.Decision.STOP) { + // An explicit OFF (or toggle from ON) is the release edge for legacy control. + RadioPttRecoveryGuard.noteRelease(); + releasePttForSafety(false); + } else if (decision == RadioExternalTalkPolicy.Decision.REJECT) { + Log.w(TAG, "External TALK start rejected by radio readiness or release gate"); + } else if (decision == RadioExternalTalkPolicy.Decision.KEEP) { + // Do not extend an existing deadline, but adopt any legacy unarmed TX safely. + ensurePttTransmissionWatchdogArmed(); + } + } + + private void setPttTalkingState(boolean talking) { + boolean wasTalking = isTalking(); + if (wasTalking == talking) { + if (talking) { + ensurePttTransmissionWatchdogArmed(); + } + return; + } + setTalkingState(talking); + boolean isNowTalking = isTalking(); + if (RadioPttWatchdogPolicy.shouldArm(wasTalking, isNowTalking)) { + armPttTransmissionWatchdog(); + } else if (RadioPttWatchdogPolicy.shouldDisarm(wasTalking, isNowTalking)) { + disarmPttTransmissionWatchdog(); + } + } + + private void ensurePttTransmissionWatchdogArmed() { + if (isTalking() && !mPttWatchdogArmed) { + armPttTransmissionWatchdog(); + } + } + + private void armPttTransmissionWatchdog() { + mPttWatchdogHandler.removeCallbacks(mPttWatchdog); + mPttWatchdogHandler.removeCallbacks(mPttDeliveryFailureCheck); + mPttWatchdogArmed = true; + mPttFailureAlerted = false; + mPttPressStartedElapsedRealtime = SystemClock.elapsedRealtime(); + mArmedPttMaximumSeconds = mMaximumPttSeconds; + mPttWatchdogHandler.postDelayed(mPttWatchdog, + RadioPttWatchdogPolicy.delayMillis(mArmedPttMaximumSeconds)); + mPttWatchdogHandler.postDelayed(mPttDeliveryFailureCheck, + PTT_DELIVERY_CONFIRM_MS); + } + + private void disarmPttTransmissionWatchdog() { + mPttWatchdogArmed = false; + mPttWatchdogHandler.removeCallbacks(mPttWatchdog); + mPttWatchdogHandler.removeCallbacks(mPttDeliveryFailureCheck); + mPttPressStartedElapsedRealtime = 0L; + } + @Override public void requirePttRelease() { RadioPttRecoveryGuard.requireRelease(); @@ -1058,16 +1200,27 @@ public void setRadioRoomReady(boolean ready) { mRadioRoomReady = ready; } + @Override + public void setMaximumPttSeconds(int maximumTxSeconds) { + int validated = RadioPttWatchdogPolicy.sanitizeMaximumSeconds(maximumTxSeconds); + if (mMaximumPttSeconds == validated) { + return; + } + boolean transmissionActive = mPttInputDown || isTalking(); + if (transmissionActive) { + releasePttForSafety(true); + } + mMaximumPttSeconds = validated; + } + /** Stops TX and clears pending watchdog work during lifecycle or connection failures. */ private void releasePttForSafety(boolean requireRelease) { - mPttWatchdogHandler.removeCallbacks(mPttWatchdog); - mPttWatchdogHandler.removeCallbacks(mPttDeliveryFailureCheck); + disarmPttTransmissionWatchdog(); mPttInputDown = false; - mPttPressStartedElapsedRealtime = 0L; mPttFailureAlerted = false; mPttWatchdogLockout = requireRelease; if (isTalking()) { - setTalkingState(false); + setPttTalkingState(false); } } diff --git a/app/src/main/java/se/lublin/mumla/service/RadioExternalTalkPolicy.java b/app/src/main/java/se/lublin/mumla/service/RadioExternalTalkPolicy.java new file mode 100644 index 00000000..59e3375d --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/RadioExternalTalkPolicy.java @@ -0,0 +1,46 @@ +/* + * 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.service; + +import se.lublin.mumla.service.ipc.TalkBroadcastReceiver; + +/** Pure state policy for the controlled legacy external TALK interface. */ +final class RadioExternalTalkPolicy { + enum Decision { + START, + STOP, + KEEP, + REJECT, + IGNORE + } + + private RadioExternalTalkPolicy() { + } + + static Decision decide(String status, boolean talking, boolean readyToTransmit, + boolean releaseRequired) { + if (TalkBroadcastReceiver.TALK_STATUS_OFF.equals(status)) { + return Decision.STOP; + } + if (TalkBroadcastReceiver.TALK_STATUS_TOGGLE.equals(status) + && (talking || releaseRequired)) { + return Decision.STOP; + } + boolean requestsStart = TalkBroadcastReceiver.TALK_STATUS_ON.equals(status) + || TalkBroadcastReceiver.TALK_STATUS_TOGGLE.equals(status); + if (!requestsStart) { + return Decision.IGNORE; + } + if (talking) { + return Decision.KEEP; + } + return readyToTransmit && !releaseRequired ? Decision.START : Decision.REJECT; + } +} diff --git a/app/src/main/java/se/lublin/mumla/service/RadioPttWatchdogPolicy.java b/app/src/main/java/se/lublin/mumla/service/RadioPttWatchdogPolicy.java new file mode 100644 index 00000000..9649792f --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/RadioPttWatchdogPolicy.java @@ -0,0 +1,39 @@ +/* + * 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.service; + +/** Pure validation and timing policy for the service-owned PTT watchdog. */ +final class RadioPttWatchdogPolicy { + static final int DEFAULT_MAXIMUM_TX_SECONDS = 120; + private static final int MINIMUM_TX_SECONDS = 1; + + private RadioPttWatchdogPolicy() { + } + + static int sanitizeMaximumSeconds(int maximumTxSeconds) { + if (maximumTxSeconds < MINIMUM_TX_SECONDS + || maximumTxSeconds > DEFAULT_MAXIMUM_TX_SECONDS) { + return DEFAULT_MAXIMUM_TX_SECONDS; + } + return maximumTxSeconds; + } + + static long delayMillis(int maximumTxSeconds) { + return sanitizeMaximumSeconds(maximumTxSeconds) * 1000L; + } + + static boolean shouldArm(boolean wasTalking, boolean isTalking) { + return !wasTalking && isTalking; + } + + static boolean shouldDisarm(boolean wasTalking, boolean isTalking) { + return wasTalking && !isTalking; + } +} diff --git a/app/src/main/java/se/lublin/mumla/service/ipc/TalkBroadcastReceiver.java b/app/src/main/java/se/lublin/mumla/service/ipc/TalkBroadcastReceiver.java index 0cd95f12..e74838cc 100644 --- a/app/src/main/java/se/lublin/mumla/service/ipc/TalkBroadcastReceiver.java +++ b/app/src/main/java/se/lublin/mumla/service/ipc/TalkBroadcastReceiver.java @@ -21,8 +21,7 @@ import android.content.Context; import android.content.Intent; -import se.lublin.humla.IHumlaService; -import se.lublin.humla.IHumlaSession; +import se.lublin.mumla.service.IMumlaService; /** * Created by andrew on 08/08/14. @@ -34,27 +33,18 @@ public class TalkBroadcastReceiver extends BroadcastReceiver { public static final String TALK_STATUS_OFF = "off"; public static final String TALK_STATUS_TOGGLE = "toggle"; - private IHumlaService mService; + private final IMumlaService mService; - public TalkBroadcastReceiver(IHumlaService service) { + public TalkBroadcastReceiver(IMumlaService service) { mService = service; } @Override public void onReceive(Context context, Intent intent) { if (BROADCAST_TALK.equals(intent.getAction())) { - if (!mService.isConnected()) - return; - IHumlaSession session = mService.HumlaSession(); String status = intent.getStringExtra(EXTRA_TALK_STATUS); if (status == null) status = TALK_STATUS_TOGGLE; - if (TALK_STATUS_ON.equals(status)) { - session.setTalkingState(true); - } else if (TALK_STATUS_OFF.equals(status)) { - session.setTalkingState(false); - } else if (TALK_STATUS_TOGGLE.equals(status)) { - session.setTalkingState(!session.isTalking()); - } + mService.onExternalTalkCommand(status); } else { throw new UnsupportedOperationException(); } diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 0e295125..5e5af264 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -143,6 +143,7 @@ Superposició %d nou missatge + %d nous missatges %d nous missatges No s\'ha pogut connectar amb el proxy local de Tor al port SOCKS %d (SOCKS). @@ -153,6 +154,7 @@ Crida als canals enllaçats %d usuari + %d usuaris %d usuaris Gràcies per la donació. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f56c5c4b..17923bd6 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -5,11 +5,13 @@ %d nová zpráva %d nové zprávy + %d nových zpráv %d nových zpráv %d uživatel %d uživatelé + %d uživatelů %d uživatelů Děkujeme vám za váš dar. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index aabbd58b..2c522371 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -153,10 +153,12 @@ Predeterminado %d usuario + %d usuarios %d usuarios %d mensaje nuevo + %d mensajes nuevos %d mensajes nuevos No se pudo crear el objetivo de voz. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c174fba3..4c931e19 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -40,10 +40,12 @@ Não Conectado %d nova mensagem + %d novas mensagens %d novas mensagens %d usuário + %d usuários %d usuários Obrigado pela doação. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 49cdd027..1642e6f0 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -102,10 +102,12 @@ %1$s (%2$s) %d nova mensagem + %d novas mensagens %d novas mensagens %d usuário + %d usuários %d usuários Padrão diff --git a/app/src/test/java/se/lublin/mumla/radio/RadioConnectionConfigTest.java b/app/src/test/java/se/lublin/mumla/radio/RadioConnectionConfigTest.java index 92180f02..e0c2224b 100644 --- a/app/src/test/java/se/lublin/mumla/radio/RadioConnectionConfigTest.java +++ b/app/src/test/java/se/lublin/mumla/radio/RadioConnectionConfigTest.java @@ -20,6 +20,7 @@ public void parsesPerChannelConnectionsPasswordsAndTokens() throws JSONException assertEquals("Minimum Test", config.getServiceName()); assertTrue(config.isAutoConnect()); assertFalse(config.isAutoReconnect()); + assertEquals(120, config.getMaximumTxSeconds()); assertEquals(2, config.getChannels().size()); RadioConnectionConfig.Channel main = config.getDefaultChannel(); @@ -40,6 +41,17 @@ public void parsesPerChannelConnectionsPasswordsAndTokens() throws JSONException assertTrue(main.requiresReconnectTo(other)); } + @Test + public void exposesValidatedMaximumTransmissionDuration() throws JSONException { + JSONObject json = new JSONObject(validConfig()); + json.getJSONObject("ptt").put("maximumTxSeconds", 1); + + assertEquals(1, RadioConnectionConfig.fromJson(json).getMaximumTxSeconds()); + + json.getJSONObject("ptt").put("maximumTxSeconds", 121); + assertThrows(JSONException.class, () -> RadioConnectionConfig.fromJson(json)); + } + @Test public void sameConnectionAndTokensCanReuseSession() throws JSONException { JSONObject json = new JSONObject(validConfig()); diff --git a/app/src/test/java/se/lublin/mumla/radio/tracking/TrackingAttemptGateTest.java b/app/src/test/java/se/lublin/mumla/radio/tracking/TrackingAttemptGateTest.java new file mode 100644 index 00000000..7de700ee --- /dev/null +++ b/app/src/test/java/se/lublin/mumla/radio/tracking/TrackingAttemptGateTest.java @@ -0,0 +1,47 @@ +package se.lublin.mumla.radio.tracking; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +public class TrackingAttemptGateTest { + @Test + public void reconfigureInvalidatesOldAttemptsUntilLatestConfigIsApplied() { + TrackingAttemptGate gate = new TrackingAttemptGate(); + long oldAttempt = gate.beginAttempt(); + long oldTicket = gate.beginReconfigure(); + long latestTicket = gate.beginReconfigure(); + AtomicInteger commits = new AtomicInteger(); + + assertFalse(gate.runIfCurrent(oldAttempt, commits::incrementAndGet)); + assertFalse(gate.applyReconfiguration(oldTicket, true, commits::incrementAndGet)); + assertEquals(TrackingAttemptGate.NO_ATTEMPT, gate.beginAttempt()); + assertTrue(gate.applyReconfiguration(latestTicket, true, commits::incrementAndGet)); + + long currentAttempt = gate.beginAttempt(); + assertNotEquals(TrackingAttemptGate.NO_ATTEMPT, currentAttempt); + assertTrue(gate.runIfCurrent(currentAttempt, commits::incrementAndGet)); + assertEquals(2, commits.get()); + } + + @Test + public void disabledConfigAndStopRejectResults() { + TrackingAttemptGate gate = new TrackingAttemptGate(); + long firstAttempt = gate.beginAttempt(); + long ticket = gate.beginReconfigure(); + AtomicInteger commits = new AtomicInteger(); + + assertTrue(gate.applyReconfiguration(ticket, false, commits::incrementAndGet)); + assertEquals(TrackingAttemptGate.NO_ATTEMPT, gate.beginAttempt()); + assertFalse(gate.runIfCurrent(firstAttempt, commits::incrementAndGet)); + + gate.stop(); + assertEquals(TrackingAttemptGate.NO_ATTEMPT, gate.beginAttempt()); + assertEquals(1, commits.get()); + } +} diff --git a/app/src/test/java/se/lublin/mumla/service/RadioExternalTalkPolicyTest.java b/app/src/test/java/se/lublin/mumla/service/RadioExternalTalkPolicyTest.java new file mode 100644 index 00000000..b875b955 --- /dev/null +++ b/app/src/test/java/se/lublin/mumla/service/RadioExternalTalkPolicyTest.java @@ -0,0 +1,51 @@ +package se.lublin.mumla.service; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import se.lublin.mumla.service.ipc.TalkBroadcastReceiver; + +public class RadioExternalTalkPolicyTest { + @Test + public void onAndToggleStartOnlyWhenReadyAndReleased() { + assertEquals(RadioExternalTalkPolicy.Decision.START, + RadioExternalTalkPolicy.decide(TalkBroadcastReceiver.TALK_STATUS_ON, + false, true, false)); + assertEquals(RadioExternalTalkPolicy.Decision.START, + RadioExternalTalkPolicy.decide(TalkBroadcastReceiver.TALK_STATUS_TOGGLE, + false, true, false)); + assertEquals(RadioExternalTalkPolicy.Decision.REJECT, + RadioExternalTalkPolicy.decide(TalkBroadcastReceiver.TALK_STATUS_ON, + false, false, false)); + assertEquals(RadioExternalTalkPolicy.Decision.REJECT, + RadioExternalTalkPolicy.decide(TalkBroadcastReceiver.TALK_STATUS_ON, + false, true, true)); + } + + @Test + public void repeatedOnKeepsOriginalWatchdogDeadline() { + assertEquals(RadioExternalTalkPolicy.Decision.KEEP, + RadioExternalTalkPolicy.decide(TalkBroadcastReceiver.TALK_STATUS_ON, + true, true, false)); + } + + @Test + public void offIsAlwaysAReleaseAndToggleStopsActiveTransmission() { + assertEquals(RadioExternalTalkPolicy.Decision.STOP, + RadioExternalTalkPolicy.decide(TalkBroadcastReceiver.TALK_STATUS_OFF, + false, false, true)); + assertEquals(RadioExternalTalkPolicy.Decision.STOP, + RadioExternalTalkPolicy.decide(TalkBroadcastReceiver.TALK_STATUS_TOGGLE, + true, true, false)); + assertEquals(RadioExternalTalkPolicy.Decision.STOP, + RadioExternalTalkPolicy.decide(TalkBroadcastReceiver.TALK_STATUS_TOGGLE, + false, true, true)); + } + + @Test + public void unknownStatusIsIgnored() { + assertEquals(RadioExternalTalkPolicy.Decision.IGNORE, + RadioExternalTalkPolicy.decide("invalid", false, true, false)); + } +} diff --git a/app/src/test/java/se/lublin/mumla/service/RadioPttWatchdogPolicyTest.java b/app/src/test/java/se/lublin/mumla/service/RadioPttWatchdogPolicyTest.java new file mode 100644 index 00000000..61094249 --- /dev/null +++ b/app/src/test/java/se/lublin/mumla/service/RadioPttWatchdogPolicyTest.java @@ -0,0 +1,36 @@ +package se.lublin.mumla.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class RadioPttWatchdogPolicyTest { + @Test + public void acceptsValidatedConfigBounds() { + assertEquals(1, RadioPttWatchdogPolicy.sanitizeMaximumSeconds(1)); + assertEquals(120, RadioPttWatchdogPolicy.sanitizeMaximumSeconds(120)); + assertEquals(1_000L, RadioPttWatchdogPolicy.delayMillis(1)); + assertEquals(120_000L, RadioPttWatchdogPolicy.delayMillis(120)); + } + + @Test + public void invalidValuesFailSafeToDefault() { + assertEquals(120, RadioPttWatchdogPolicy.sanitizeMaximumSeconds(0)); + assertEquals(120, RadioPttWatchdogPolicy.sanitizeMaximumSeconds(-1)); + assertEquals(120, RadioPttWatchdogPolicy.sanitizeMaximumSeconds(121)); + assertEquals(120_000L, RadioPttWatchdogPolicy.delayMillis(Integer.MAX_VALUE)); + } + + @Test + public void armsAndDisarmsOnlyOnTalkingTransitions() { + assertTrue(RadioPttWatchdogPolicy.shouldArm(false, true)); + assertFalse(RadioPttWatchdogPolicy.shouldArm(true, true)); + assertFalse(RadioPttWatchdogPolicy.shouldArm(false, false)); + + assertTrue(RadioPttWatchdogPolicy.shouldDisarm(true, false)); + assertFalse(RadioPttWatchdogPolicy.shouldDisarm(true, true)); + assertFalse(RadioPttWatchdogPolicy.shouldDisarm(false, false)); + } +} diff --git a/docs/CONFIG_BACKEND.md b/docs/CONFIG_BACKEND.md index 1f90c188..70228edc 100644 --- a/docs/CONFIG_BACKEND.md +++ b/docs/CONFIG_BACKEND.md @@ -13,6 +13,25 @@ project must provide `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN` and test endpoint. `SESSION_SECRET` must be at least 32 characters. These values are deployment secrets and must never be committed or printed. +Login admission uses a separate Cloudflare D1 database because a process-local counter and KV +read/modify/write cannot enforce a limit consistently across concurrent Vercel instances. Apply +`web/cloudflare/d1/0001_login_rate_limit.sql` before deployment, then set +`CLOUDFLARE_D1_DATABASE_ID`, a least-privilege `CLOUDFLARE_D1_API_TOKEN`, and a stable +`LOGIN_RATE_LIMIT_KEY_SECRET` of at least 32 UTF-8 bytes. The limiter stores only versioned HMAC +bucket digests, never a submitted username or client IP. Client admission happens before the KV +administrator lookup; only an admitted client advances either the configured-account bucket or one +bounded decoy bucket for all other usernames. Schema triggers prune buckets that have been expired +for more than 24 hours. Production login fails closed if D1 or its configuration is unavailable; +development and tests use an in-memory adapter. Rotating the HMAC secret intentionally starts a new +bucket namespace. + +The administrator username is an identifier, not a secret, in this controlled lab deployment. The +configured/decoy split intentionally favors bounded storage and prevents arbitrary usernames from +locking the real administrator bucket. An attacker able to distribute enough admitted attempts and +pre-saturate the decoy bucket could distinguish its state from the configured bucket; password +verification, BotID, same-origin mutation checks and the real account limit remain enforced. Revisit +this tradeoff before exposing the portal publicly. + The first-run handoff is deliberately short: open the portal, create the administrator account, then register the radio's six-character Device ID and edit its profile. No bearer token is copied to the radio. The portal stores the scrypt administrator hash plus device metadata and Schema-3 config; diff --git a/docs/DEVELOPMENT_RUNBOOK.md b/docs/DEVELOPMENT_RUNBOOK.md index b86deda7..f0f63e5b 100644 --- a/docs/DEVELOPMENT_RUNBOOK.md +++ b/docs/DEVELOPMENT_RUNBOOK.md @@ -59,6 +59,22 @@ mutations; do not disable it as a deployment workaround. Next.js 15.3+ initializ `/api/devices/*`), not Next.js `:deviceId` syntax; otherwise the client omits the verification header and the server correctly rejects the mutation. +Production login rate limiting additionally requires a Cloudflare D1 database. Apply +`web/cloudflare/d1/0001_login_rate_limit.sql`, then configure `CLOUDFLARE_D1_DATABASE_ID`, a +least-privilege `CLOUDFLARE_D1_API_TOKEN` with D1 query access, and a stable +`LOGIN_RATE_LIMIT_KEY_SECRET` of at least 32 UTF-8 bytes. Keep these values server-side. Login +admission performs an atomic fixed-window D1 upsert per bucket: client first, then configured account +or a bounded decoy account only after the client passes. This ordering prevents an already-blocked +client from advancing the administrator lockout counter or consuming a KV administrator read. D1 +timeout, configuration, network or response errors return a generic `503` and do not fall back to a +per-process counter. A blocked request returns `429` with `Retry-After`; successful logins consume +quota as attempts. Migration triggers prune rows expired for more than 24 hours. + +The Vercel adapter uses Cloudflare's D1 REST control-plane API. This is acceptable for this +BotID-protected, low-volume private lab portal, but its latency and account-level API quota must be +monitored. Move admission behind a narrowly authenticated Cloudflare Worker with a D1 binding before +turning the portal into a public or high-volume service. + First-run production handoff: 1. Open `https://minimum.vra.or.th/` and create the administrator account when the `FIRST-RUN diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index d43329d2..398ec7b6 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,8 +1,8 @@ # Minimum project status (source of truth) -Last reviewed: 2026-08-13 +Last reviewed: 2026-08-24 -This is the canonical hand-off document for the `awatchar/minimum` public PoC. If another +This is the canonical hand-off document for the `awatchar/minimum` private-lab deployment. If another document disagrees with this file, verify the code and update this file first. ## Repository and build identity @@ -13,8 +13,8 @@ document disagrees with this file, verify the code and update this file first. - GitLab upstream remote: `https://gitlab.com/quite/mumla.git` - Humla upstream history is retained in the submodule; Minimum's required Humla commit is published as branch `humla-minimum` in the same GitHub repository and `.gitmodules` points there. -- Working branch: `agent/minimum-foundation` -- Draft PR: https://github.com/awatchar/minimum/pull/1 +- Integration base: `main` +- Active correctness/security batch: `fix/lab-correctness-batch` ([PR #26](https://github.com/awatchar/minimum/pull/26)) - Android application ID: `se.lublin.mumla` - The integrated Issue #11/#12 release candidate uses versionCode `3070301`; the compatible next Git-derived release tag/versionName is `3.7.3-minimum.2`. This is a preliminary integration @@ -49,6 +49,12 @@ document disagrees with this file, verify the code and update this file first. `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_KV_NAMESPACE_ID`, `SESSION_SECRET` is a deployment secret; the KV base URL is configurable for compatible test services. +- Distributed login admission is implemented for a separate Cloudflare D1 database and atomic + fixed-window upserts; production activation is pending Vercel secret provisioning, deployment and + smoke acceptance. The client gate precedes the KV lookup and account gate; nonmatching usernames + share a bounded decoy account bucket, and expired rows are pruned by schema triggers. Identities + are stored only as HMAC bucket digests. Production fails closed when D1 or its server-only + configuration is unavailable; local development/tests retain a deterministic in-memory adapter. - The portal provides a first-run administrator handoff, scrypt password hashing, an eight-hour HttpOnly admin session, pending-device registration, device CRUD, a structured Schema-3 editor, canonical model templates and automatic config-version advancement. The everyday editor has @@ -91,8 +97,18 @@ document disagrees with this file, verify the code and update this file first. same. The triggering press is never queued for later TX, so the operator must press again after Ready. A service-backed release-required lock is armed before the Activity transition and cleared only after key-up, preventing the original press from becoming a new RadioShell DOWN event. -- Added a 120-second PTT watchdog, release-on-disconnect/service-destroy behavior and lockout until - the physical key is released after a timeout. +- The service-owned PTT watchdog now uses validated `ptt.maximumTxSeconds` (1..120) for both + hold-to-talk and toggle transitions, captures the active limit per transmission, and safely + releases TX if policy changes mid-transmission. The accepted exported TALK control is routed + through the same readiness/release gates and watchdog; repeated ON cannot extend an active + deadline. Disconnect/service destroy and timeout still disarm callbacks and require a release + edge before another TX. +- APRS transport results are generation-gated across stop and configuration changes. Stale + success/failure callbacks cannot persist a receipt, mutate coordinator state or schedule retry; + rejected/throwing transport paths release the in-flight logical attempt. +- Mumla/Humla teardown is best-effort and idempotent across connection, audio input/output/encoder, + SCO, notifications and wake locks. Destroy state blocks deferred reconnects and late callbacks; + each cleanup action still runs when an earlier resource reports a runtime failure. - Added a service-owned managed-radio TX gate: synchronization, PTT mode and verified entry into the configured room must all be true before Activity or MediaSession input can start transmission. - Made first-run client certificate creation automatic with retry on failure. @@ -141,7 +157,8 @@ document disagrees with this file, verify the code and update this file first. - RX state now retains the complete ordered set of simultaneous remote talkers. T99-class compact displays reserve two lines, larger displays reserve four, and overflow uses the final visible line for `+N` while accessibility retains the full list. -- RadioShell now renders an optional per-channel `alias` in a prominent amber `CHANNEL` badge, +- RadioShell now renders an optional per-channel `alias` in a prominent amber badge without a + redundant literal `CHANNEL` prefix, separate from the talker area. Room resolution still uses the full configured Mumble `path`; legacy configs fall back to `label`, and join/service refresh callbacks no longer expose the path. - Managed radios retain server chat logging and TTS but suppress Mumla's priority-high/vibrating @@ -377,6 +394,7 @@ The detailed Technical Brief comparison and implementation order are maintained - Keep RYKS scans 216 and 249 as PTT while the OEM broadcast has no scan-code extra; assigning scan 249 to another action could cause an unintended transmission. - Keep the normal Mumla build working while the radio interface is developed. -- Do not merge PR #1 without explicit user approval. +- Merge changes only after their required automated gates pass and any production dependencies are + provisioned; hardware-only acceptance may remain explicitly deferred when no device is attached. - Do not publish an APK as a GitHub Release until the signing identity, CI artifact provenance, checksums, release notes and explicitly accepted hardware limitations satisfy the release gate. diff --git a/docs/PROVISIONING_BUNDLE_README.txt b/docs/PROVISIONING_BUNDLE_README.txt index 85b37e1b..6ccd4ecc 100644 --- a/docs/PROVISIONING_BUNDLE_README.txt +++ b/docs/PROVISIONING_BUNDLE_README.txt @@ -16,6 +16,12 @@ all bundle file hashes, APK checksum/package/version/exactly-one-reviewed-signer identity/config preservation and Ready. Read "UPDATER-README.md" in this bundle for advanced modes and recovery guidance. +Provisioning a new/reset device performs the same strict Release artifact preflight before it opens +ADB or changes the radio: exact manifest schema and file allowlist, every manifest hash, the APK +checksum file, package ID, versionCode, versionName and exactly the one manifest-bound verified APK +signer must all pass. A missing verifier, incomplete bundle, local replacement APK or failed +signature check stops before target selection, installation or any model-specific setting change. + For the one-time 3070300-to-3070301 compatibility bridge, a non-creating Android run-as probe must first read only the existing public Device ID from app-private preferences. If run-as is unavailable, the operator must wake/unlock and manually open the existing RadioShell Ready screen. The updater @@ -48,7 +54,8 @@ Requirements - Windows 10 or Windows 11 - Android Platform Tools (adb.exe) available in PATH - Android Build Tools apksigner available in PATH, ANDROID_HOME/ANDROID_SDK_ROOT, or the standard - local Android SDK; the updater refuses installation when full signature verification is absent + local Android SDK; provisioning and updating both refuse installation when full signature + verification is absent - Internet access to https://minimum.vra.or.th/ - A Minimum Portal administrator account - USB debugging enabled and authorized on the radio @@ -100,12 +107,26 @@ Security and safety - PASS requires managed config activation and Ready both before and after reboot. - Ready messages before reboot are checkpoints only; the sole final PASS is emitted after the returning unit is identified and reaches Ready with the same Device ID. -- Verify the separately published ZIP checksum before extraction. The updater also verifies the - exact in-bundle manifest/allowlist/checksums and APK identity/signer. An existing operator - workstation is supported; verification does not require wiping or rebuilding it. +- Verify the separately published ZIP checksum before extraction. That external comparison is the + pre-extraction trust anchor for every bundled file, including the verification scripts. Only + after it matches should the updater perform its in-bundle manifest/allowlist/checksum and APK + identity/signer consistency checks. Provisioning repeats those checks before resolving ADB and + immediately before installation. An in-bundle verifier cannot authenticate itself or replace + the separately published outer ZIP checksum. + An existing operator workstation is supported; verification does not require wiping or rebuilding it. - The updater never uninstalls Minimum, clears app data, transmits PTT, exports app data, or stores Android/USB/subscriber identifiers in its sanitized reports. +Source-development APKs +----------------------- + +The repository-only `-BuildApk` and explicit `-ApkPath` development paths remain available outside +an extracted Release bundle. They require a valid Minimum package and exactly one cryptographically +verified APK signer, but there is no Release manifest or reviewed Release-signer trust anchor. The +script labels that result `DEVELOPMENT APK` and does not claim Release provenance. An extracted +Release bundle refuses `-BuildApk` and refuses any `-ApkPath` other than its exact manifest-bound +`minimum-foss.apk`. + An existing debug-signed Minimum APK cannot be upgraded in place by the release-signed APK. The installer stops on a signature mismatch rather than clearing app data automatically. Preserve any required device identity/config information and perform an explicitly approved uninstall before diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 4c83d074..08bcbf59 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -2,11 +2,12 @@ | Area | Current result | Evidence / next action | |---|---|---| -| FOSS debug unit tests | PASS | `:app:testFossDebugUnitTest` | +| FOSS debug unit tests | PASS | 121 app tests and 11 Humla tests; zero failures/errors/skips. | | FOSS debug APK build | PASS | `:app:assembleFossDebug` | | FOSS release APK assembly | PASS LOCALLY / UNSIGNED | `:app:assembleFossRelease`; signing and tagged GitHub provenance remain open. | -| Android full Lint | FAIL (PRE-EXISTING BASELINE) | Clean `lintFossDebug` and `lintFossRelease` each report 32 errors/343 warnings across legacy permissions, receiver flags, layouts, locale plurals and other existing code. The first is the unchanged `AprsTrackingManager.removeUpdates` permission finding. No Lint baseline is committed. | -| Existing-device updater integration | PASS IN STATIC/AUTOMATED TESTS / PHYSICAL OPEN | PowerShell 5.1 AST and 30 policy/fixture/state-machine tests cover non-creating legacy run-as and focused Ready-UI proof, wrong-package/not-Ready/unfocused refusal before receivers, signer/no-mutation, Linux/Windows `apksigner` discovery, recovery, model routing, reboot correlation and partial sessions; cellular verifier, exact workflow allowlists and full `apksigner` contract also pass. E7ROW7 same-debug-signer update and T99/RYKS physical acceptance remain open. | +| Android full Lint | TARGETED CORRECTNESS PASS / PRE-EXISTING BASELINE REMAINS | Fresh `lintFossDebug` reports 21 errors/330 warnings. The requested permission, lifecycle/wakelock, receiver-flag, locale-quantity, MissingSuperCall and dedicated-device export findings are resolved or narrowly documented/suppressed; remaining errors are legacy GestureBackNavigation (1), database Range (6) and UseAppTint (14). No Lint baseline is committed and Lint was intentionally not driven to zero. | +| Existing-device updater integration | PASS IN STATIC/AUTOMATED TESTS / PHYSICAL OPEN | PowerShell AST and 33 policy/fixture/state-machine tests (including the real built debug APK) cover non-creating legacy run-as and focused Ready-UI proof, wrong-package/not-Ready/unfocused refusal before receivers, signer/no-mutation, Linux/Windows `apksigner` discovery, recovery, model routing, reboot correlation and partial sessions; cellular verifier, exact workflow allowlists and full `apksigner` contract also pass. E7ROW7 same-debug-signer update and T99/RYKS physical acceptance remain open. | +| Fresh provisioner Release preflight | PASS IN 16 AUTOMATED TESTS / PHYSICAL OPEN | Extracted Release mode verifies the exact allowlist/hashes, APK checksum/package/version and exactly one manifest-bound signer before ADB resolution, then repeats the complete binding immediately before native `adb install`; local build, out-of-bundle paths and post-preflight replacement fail closed. Physical install remains deferred until hardware is connected. | | GitHub Actions integrated CI | PASS | Run `31306714812` on commit `6ee5c5e6`: Android unit tests/debug APK/unsigned release assembly and Portal tests/type-check/production build all passed. | | T99 ADB install | PASS | T99 serial `12344321` | | T99 Device ID format/persistence | PASS | `DeviceIdentityManagerTest`; startup integration added | @@ -16,8 +17,9 @@ | T99 physical screen-off PTT | PASS (operator observed) | F1 path works with screen off; exact foreground Android metadata and release captured subsequently | | T99 F2 EXIT isolation | PASS IN CODE/BEHAVIOR | T99 forcibly defaults to F1, rejects F2 as PTT and routes F2 to recovery dashboard | | Media/headset screen-off PTT | IMPLEMENTED ALTERNATE | MediaSession remains active for headset/media PTT alternatives; not the labelled T99 PTT button | -| PTT watchdog | IMPLEMENTED | 120-second service safety path; add long manual test | +| Configured PTT watchdog | PASS IN JVM / PHYSICAL TIMING OPEN | Validated `maximumTxSeconds` 1..120 reaches the service, arms on hold/toggle and exported legacy TALK transitions, captures each transmission limit and releases safely on policy change. Repeated TALK-on cannot extend the deadline, and timeout lockout requires a release edge; add a short-limit and default-120 physical timing run. | | Disconnect releases TX | IMPLEMENTED | Service lifecycle path; add manual screen-off test | +| Service/audio/wakelock teardown | PASS IN JVM/STATIC / PHYSICAL OPEN | Best-effort cleanup runner preserves later actions after failures; Humla destroy blocks reconnect, disconnects handlers/audio/SCO and releases partial/proximity/screen wake locks through idempotent paths. Validate dumpsys/logcat after a physical destroy/reconnect cycle. | | Boot receiver registration | PASS | Manifest and receiver present | | T99 simulated boot launch | PASS | Activity appeared after valid simulated broadcast | | T99 radio dashboard pages | PASS | Installed APK: Minimum -> Settings swipe path | @@ -35,7 +37,7 @@ | Zello repeat script dry run | PASS | `remove-zello-t99.ps1 -WhatIf` | | Static backend JSON | PASS | Parsed with PowerShell `ConvertFrom-Json` | | GitHub Pages workflow | CONFIGURED / RECOVERY ONLY | Deploy occurs after workflow reaches `main`; managed devices use the Vercel device endpoint | -| Vercel/Cloudflare admin portal | PASS IN WEB / PRODUCTION SMOKE | First-run admin, pending-device queue, device CRUD, Schema-3 structured editor, Device-ID lookup endpoint and KV persistence; admin/Cloudflare secrets stay server-side | +| Vercel/Cloudflare admin portal | PASS IN WEB / EXISTING PRODUCTION SMOKE / D1 ACTIVATION OPEN | First-run admin, pending-device queue, device CRUD, Schema-3 structured editor, Device-ID lookup endpoint and KV persistence; distributed login admission uses atomic D1 fixed-window buckets and fails closed, with Vercel secret provisioning/deployment smoke still pending; admin/Cloudflare secrets stay server-side | | Web `radio.defaultChannel` editor | PASS IN WEB | **Channels & default** selector and per-channel **Set default** persist `radio.defaultChannel` and advance `configVersion`; handset Last Selected Channel still wins when valid | | Android config embedded fallback | PASS | Asset + validation in repository | | Android remote config fetch/cache | PASS IN JVM / RYKS PHYSICAL | Startup/six-hour/network-return refresh, in-flight guard, pending staging and LKG fallback; RYKS tokenless OTA activated portal v12 and a real reboot returned Ready with v12 active and `pending=false` | @@ -79,6 +81,7 @@ | T56 live APRS Object report | PASS ON DEVICE | Open-sky T56 fix produced a `VR-` Device ID Object report and the APRS-IS endpoint returned a positive packet receipt; APRS.fi indexed the Object position | | T56 APRS health comment | PASS ON DEVICE / JVM | Position comment carries battery, charging, battery temperature, Wi-Fi RSSI and storage; mobile type/RSSI is included when exposed and otherwise marked `NA` | | Configurable APRS Object name | PASS IN JVM | Optional `tracking.aprs.objectName` is validated, uppercased and padded to nine bytes; omission retains `VR-` and identity changes reset duplicate state | +| APRS stale callback invalidation | PASS IN JVM / DEVICE REGRESSION OPEN | Generation tickets invalidate in-flight results synchronously on stop/reconfiguration; stale receipt/failure callbacks cannot persist state or retry, and throwing/rejected transport paths are released. Physical stop/reconfigure regression awaits T56 attachment. | | A-GPS assistance | OPEN | Both advertise Qualcomm A-GPS capability; XTRA is disabled and T56's SUPL host is malformed, so the successful T56 GPS/network fixes do not prove assisted-GPS operation | ## Release gate diff --git a/docs/UPDATER_RUNBOOK.md b/docs/UPDATER_RUNBOOK.md index 617977d7..ceccbbe6 100644 --- a/docs/UPDATER_RUNBOOK.md +++ b/docs/UPDATER_RUNBOOK.md @@ -11,10 +11,13 @@ removes OEM apps, reopens Location consent or reapplies unrelated device setting The updater finds `apksigner` in `PATH`, `ANDROID_HOME`/`ANDROID_SDK_ROOT`, or the standard local Android SDK. This fail-closed dependency is required for full APK signature verification. - One complete, extracted `minimum-provisioning-.zip` from a reviewed GitHub Release. -- The separately published ZIP `.sha256` must match before extraction. The updater then verifies +- The separately published ZIP `.sha256` must match before extraction. This external checksum is + the pre-extraction trust anchor for every bundled file, including the updater itself; an + in-bundle verifier cannot authenticate itself. After that comparison passes, the updater checks the exact file allowlist and hashes in `RELEASE-MANIFEST.json`, the APK checksum file, binary APK - package/version, and APK signer. This makes bundle verification repeatable on an operator's - existing workstation; a freshly installed or otherwise "clean" workstation is not required. + package/version, and APK signer for internal consistency and post-extraction tampering. This is + repeatable on an operator's existing workstation; a freshly installed or otherwise "clean" + workstation is not required. - USB debugging must be enabled and authorized. The normal path accepts exactly one device in the Android `device` state. Offline, unauthorized, recovery, unknown and ambiguous targets stop before mutation. diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index aca2a855..627e8efe 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -3,6 +3,27 @@ This short log records meaningful project milestones. Detailed code truth remains in `PROJECT_STATUS.md` and the source files. +## 2026-08-24 - Correctness, teardown, provisioning and portal admission hardening + +- Wired validated `ptt.maximumTxSeconds` into the service-owned watchdog for hold and toggle PTT, + with per-transmission policy capture and fail-safe release on live policy changes. The accepted + exported TALK interface now enters through the same readiness/release gates and cannot bypass or + extend that watchdog. +- Added generation-gated APRS callbacks so stop/reconfiguration invalidates stale transport results, + plus best-effort Mumla/Humla audio, SCO, connection and wake-lock teardown that cannot be aborted by + one resource failure. +- Resolved the requested Android correctness-lint categories and documented the dedicated-device + exported service/receiver design without changing that deployment behavior. Unrelated legacy Lint + findings remain visible rather than being hidden behind a baseline. +- Made fresh Release provisioning verify the manifest-bound APK identity and signer before ADB and + repeat the complete binding immediately before installation, so post-preflight replacement fails + closed. The signed-release workflow now authorizes only a tag whose commit exactly equals current + reviewed `origin/main`, before tag code or signing secrets are used. Added CI/release coverage for + both gates and documented the separately published ZIP checksum as the pre-extraction trust anchor. +- Implemented staged distributed login admission with Cloudflare D1 atomic buckets, HMAC-only keys, + bounded configured/decoy account state, expiry triggers and fail-closed Vercel behavior. Production + activation remains pending server-side secret provisioning and deployment smoke acceptance. + ## 2026-08-12 - T56 update provisioning and RYKS location recheck - Rebuilt the FOSS debug APK from current `main`, then updated the connected T56 with diff --git a/libraries/humla b/libraries/humla index b5a33a68..fc09c489 160000 --- a/libraries/humla +++ b/libraries/humla @@ -1 +1 @@ -Subproject commit b5a33a68c3bc0d8c7864adabe650fb9ec3f3a959 +Subproject commit fc09c4895a12bdd1366e82ad636c947b2589f47d diff --git a/scripts/provision-minimum-device.ps1 b/scripts/provision-minimum-device.ps1 index e4872686..f0770fa7 100644 --- a/scripts/provision-minimum-device.ps1 +++ b/scripts/provision-minimum-device.ps1 @@ -4,8 +4,9 @@ .DESCRIPTION This is the operator-facing one-shot workflow for known T99, T56 and RYKS hardware. It selects one - authorized ADB target, verifies the hardware model, optionally builds the FOSS debug APK, - installs the APK without clearing app data, runs the guarded model preparation, waits for the + Release artifact before opening ADB, then selects one authorized target and verifies its hardware + model. Source-only development APKs are signature-checked without claiming Release trust. It + installs the selected APK without clearing app data, runs the guarded model preparation, waits for the Device ID profile to become available from the portal, waits for Ready, reboots the radio, and waits for Ready again. @@ -40,7 +41,8 @@ param( [string]$LabWifiCredentialPath = "", [ValidateRange(30, 900)][int]$ReadyTimeoutSeconds = 180, [ValidateRange(30, 900)][int]$BootTimeoutSeconds = 180, - [switch]$SkipReboot + [switch]$SkipReboot, + [Parameter(DontShow = $true)][switch]$LibraryOnly ) $ErrorActionPreference = "Stop" @@ -59,11 +61,7 @@ $DefaultApkPath = if (Test-Path -LiteralPath $BundledApkPath -PathType Leaf) { } else { $SourceBuildApkPath } -try { - $adbPath = (Get-Command adb -ErrorAction Stop).Source -} catch { - throw "ADB was not found. Install Android Platform Tools or add adb.exe to PATH, then double-click the launcher again." -} +$adbPath = "" $serverArgs = @() $script:targetArgs = @() $script:targetLabel = "" @@ -624,6 +622,175 @@ function Wait-AndroidBootCompleted { throw "Android did not finish booting within $TimeoutSeconds seconds." } +function Test-ReleaseBundleLayout { + param([Parameter(Mandatory)][string]$Root) + + foreach ($relative in @("RELEASE-MANIFEST.json", "VERSION.txt", "minimum-foss.apk")) { + if (Test-Path -LiteralPath (Join-Path $Root $relative)) { + return $true + } + } + return $false +} + +function Test-SameCanonicalPath { + param([Parameter(Mandatory)][string]$Left, [Parameter(Mandatory)][string]$Right) + + $leftFull = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $Left).Path) + $rightFull = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $Right).Path) + return $leftFull.Equals($rightFull, [StringComparison]::OrdinalIgnoreCase) +} + +function Get-VerifiedReleaseProvisioningArtifact { + param( + [Parameter(Mandatory)][string]$Root, + [string]$RequestedApkPath = "" + ) + + $manifestPath = Join-Path $Root "RELEASE-MANIFEST.json" + $bundledApk = Join-Path $Root "minimum-foss.apk" + $updaterPath = Join-Path $Root "scripts\update-minimum-device.ps1" + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf) -or + -not (Test-Path -LiteralPath $bundledApk -PathType Leaf)) { + throw "[BUNDLE_INCOMPLETE] Release bundle markers are present, but the manifest or bundled APK is missing. No ADB command or device change was attempted." + } + if (-not (Test-Path -LiteralPath $updaterPath -PathType Leaf)) { + throw "[BUNDLE_VERIFIER_MISSING] The bundle verifier is missing. Authenticate the outer ZIP with its separately published checksum before trusting any extracted verifier. No ADB command or device change was attempted." + } + if ($RequestedApkPath) { + if (-not (Test-Path -LiteralPath $RequestedApkPath -PathType Leaf) -or + -not (Test-SameCanonicalPath -Left $RequestedApkPath -Right $bundledApk)) { + throw "[APK_PATH_OUTSIDE_BUNDLE] Release provisioning accepts only the APK bound by the extracted Release manifest. No ADB command or device change was attempted." + } + } + + # Invoke the updater as a library only inside a child scope. Dot-sourcing it in this script's + # scope would overwrite provisioning parameters such as Serial, TransportId and AdbPort. + return & { + param($VerifierPath, $BundleRoot) + . $VerifierPath -LibraryOnly + + $bundle = Read-ReleaseBundle -Root $BundleRoot + $identity = Get-ApkManifestIdentity -ApkPath $bundle.ApkPath + if ($identity.ApplicationId -cne [string]$bundle.Manifest.applicationId -or + $identity.VersionCode -ne [long]$bundle.Manifest.versionCode -or + $identity.VersionName -cne [string]$bundle.Manifest.versionName) { + Throw-UpdateError "APK_IDENTITY_BINDING" "The APK package/version does not match the exact Release manifest." + } + $targetSigners = @(Get-ApkSignerDigests -ApkPath $bundle.ApkPath) + $reviewedSigner = ([string]$bundle.Manifest.signerSha256).ToUpperInvariant() + if ($targetSigners.Count -ne 1 -or $targetSigners[0] -cne $reviewedSigner) { + Throw-UpdateError "APK_SIGNER_BINDING" "The APK must contain exactly the one reviewed signer from the Release manifest; missing or extra signers are refused." + } + $apkSha256 = (Get-FileHash -LiteralPath $bundle.ApkPath -Algorithm SHA256).Hash.ToUpperInvariant() + return [pscustomobject]@{ + ApkPath = $bundle.ApkPath + ApplicationId = $identity.ApplicationId + VersionCode = $identity.VersionCode + VersionName = $identity.VersionName + ApkSha256 = $apkSha256 + SignerSha256 = $targetSigners[0] + Trust = "RELEASE_MANIFEST_AND_SIGNER_VERIFIED" + } + } $updaterPath $Root +} + +function Get-VerifiedDevelopmentProvisioningArtifact { + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$DevelopmentApkPath + ) + + $updaterPath = Join-Path $Root "scripts\update-minimum-device.ps1" + if (-not (Test-Path -LiteralPath $updaterPath -PathType Leaf)) { + throw "[DEVELOPMENT_VERIFIER_MISSING] The APK verifier is missing. No installation was attempted." + } + if (-not (Test-Path -LiteralPath $DevelopmentApkPath -PathType Leaf)) { + throw "[DEVELOPMENT_APK_MISSING] The requested development APK is missing. No installation was attempted." + } + + return & { + param($VerifierPath, $CandidatePath, $ExpectedApplicationId) + . $VerifierPath -LibraryOnly + + $identity = Get-ApkManifestIdentity -ApkPath $CandidatePath + if ($identity.ApplicationId -cne $ExpectedApplicationId) { + Throw-UpdateError "DEVELOPMENT_APK_IDENTITY" "The development APK package is not Minimum." + } + $signers = @(Get-ApkSignerDigests -ApkPath $CandidatePath) + if ($signers.Count -ne 1) { + Throw-UpdateError "DEVELOPMENT_APK_SIGNATURE" "The development APK must have exactly one cryptographically verified signer." + } + $resolvedCandidate = (Resolve-Path -LiteralPath $CandidatePath).Path + return [pscustomobject]@{ + ApkPath = $resolvedCandidate + ApplicationId = $identity.ApplicationId + VersionCode = $identity.VersionCode + VersionName = $identity.VersionName + ApkSha256 = (Get-FileHash -LiteralPath $resolvedCandidate -Algorithm SHA256).Hash.ToUpperInvariant() + SignerSha256 = $signers[0] + Trust = "DEVELOPMENT_SIGNATURE_VALID_NOT_RELEASE_BOUND" + } + } $updaterPath $DevelopmentApkPath $MinimumPackage +} + +function Confirm-ProvisioningArtifactUnchanged { + param( + [Parameter(Mandatory)]$ExpectedArtifact, + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][bool]$ReleaseBundleMode + ) + + # Check the immutable value captured by the initial, pre-ADB verification before loading any + # bundle code again. A replacement APK therefore fails even if another extracted bundle file + # was also changed after the operator's initial checksum validation. + if (-not (Test-Path -LiteralPath $ExpectedArtifact.ApkPath -PathType Leaf)) { + throw "[APK_CHANGED_AFTER_VERIFICATION] The verified APK disappeared before installation. No installation was attempted." + } + $currentSha256 = (Get-FileHash -LiteralPath $ExpectedArtifact.ApkPath -Algorithm SHA256).Hash.ToUpperInvariant() + if ($currentSha256 -cne [string]$ExpectedArtifact.ApkSha256) { + throw "[APK_CHANGED_AFTER_VERIFICATION] The verified APK changed before installation. No installation was attempted." + } + + $currentArtifact = if ($ReleaseBundleMode) { + Get-VerifiedReleaseProvisioningArtifact -Root $Root ` + -RequestedApkPath $ExpectedArtifact.ApkPath + } else { + Get-VerifiedDevelopmentProvisioningArtifact -Root $Root ` + -DevelopmentApkPath $ExpectedArtifact.ApkPath + } + + foreach ($property in @( + "ApkPath", "ApplicationId", "VersionCode", "VersionName", + "ApkSha256", "SignerSha256", "Trust")) { + if ([string]$currentArtifact.$property -cne [string]$ExpectedArtifact.$property) { + throw "[APK_BINDING_CHANGED_AFTER_VERIFICATION] The verified APK $property binding changed before installation. No installation was attempted." + } + } + return $currentArtifact +} + +if ($LibraryOnly) { return } + +$releaseBundleMode = Test-ReleaseBundleLayout -Root $RepositoryRoot +$verifiedArtifact = $null +if ($releaseBundleMode) { + if ($BuildApk) { + throw "[RELEASE_BUNDLE_BUILD_REFUSED] A Release bundle cannot replace its manifest-bound APK with a local build. No ADB command or device change was attempted." + } + $verifiedArtifact = Get-VerifiedReleaseProvisioningArtifact -Root $RepositoryRoot ` + -RequestedApkPath $ApkPath + $resolvedApkPath = $verifiedArtifact.ApkPath + Write-Host ("Release artifact verified before ADB: {0}, versionCode {1}." -f ` + $verifiedArtifact.VersionName, $verifiedArtifact.VersionCode) +} + +try { + $adbPath = (Get-Command adb -ErrorAction Stop).Source +} catch { + throw "ADB was not found. Install Android Platform Tools or add adb.exe to PATH, then double-click the launcher again." +} + $Host.UI.RawUI.WindowTitle = "Minimum One-Shot Provisioning" if ($GuidedMode) { Write-Host "============================================================" @@ -649,11 +816,17 @@ if ($GuidedMode) { Show-GuidedSetupMenu -Profile $target.Profile } -if (-not $ApkPath) { $ApkPath = $DefaultApkPath } -if ($BuildApk -or -not (Test-Path -LiteralPath $ApkPath -PathType Leaf)) { - Build-MinimumApk +if (-not $releaseBundleMode) { + if (-not $ApkPath) { $ApkPath = $DefaultApkPath } + if ($BuildApk -or -not (Test-Path -LiteralPath $ApkPath -PathType Leaf)) { + Build-MinimumApk + } + $verifiedArtifact = Get-VerifiedDevelopmentProvisioningArtifact -Root $RepositoryRoot ` + -DevelopmentApkPath $ApkPath + $resolvedApkPath = $verifiedArtifact.ApkPath + Write-Warning ("DEVELOPMENT APK: package and signature are valid, but no Release manifest or reviewed Release signer trust is claimed ({0}, versionCode {1})." -f ` + $verifiedArtifact.VersionName, $verifiedArtifact.VersionCode) } -$resolvedApkPath = (Resolve-Path -LiteralPath $ApkPath).Path if ($target.Profile -eq "RYKS") { # ELINK's PackageManager accepts third-party APKs only when this documented build-policy # property is 1. The property is absent on factory ym_258 images and resets on reboot. @@ -669,7 +842,18 @@ if ($target.Profile -eq "RYKS") { } function Install-MinimumApk { - param([Parameter(Mandatory)][string]$Path) + param( + [Parameter(Mandatory)]$ExpectedArtifact, + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][bool]$ReleaseBundleMode + ) + + # Target selection, operator prompts and model checks may take an arbitrary amount of time. + # Re-run the complete identity/hash/signer/trust verification here, immediately before adb + # receives the path, so an APK changed after preflight is never installed. + $finalArtifact = Confirm-ProvisioningArtifactUnchanged -ExpectedArtifact $ExpectedArtifact ` + -Root $Root -ReleaseBundleMode $ReleaseBundleMode + $Path = $finalArtifact.ApkPath # Capture only the exit/result needed to classify installation failures. In particular, do not # invoke `pm clear` or uninstall here: a failed signature upgrade must preserve app data. @@ -700,7 +884,8 @@ function Install-MinimumApk { } } Write-Host "Installing Minimum APK without clearing app data..." -Install-MinimumApk -Path $resolvedApkPath +Install-MinimumApk -ExpectedArtifact $verifiedArtifact -Root $RepositoryRoot ` + -ReleaseBundleMode $releaseBundleMode $installed = Invoke-TargetAdb -Arguments @("shell", "pm", "path", $MinimumPackage) if (-not $installed) { throw "Minimum package verification failed after APK installation." diff --git a/tests/provision-minimum-device.Tests.ps1 b/tests/provision-minimum-device.Tests.ps1 new file mode 100644 index 00000000..b152dda3 --- /dev/null +++ b/tests/provision-minimum-device.Tests.ps1 @@ -0,0 +1,325 @@ +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "..\scripts\provision-minimum-device.ps1") -LibraryOnly + +$script:Passed = 0 +$script:Failed = 0 + +function Assert-Equal { + param($Expected, $Actual, [string]$Name) + if ($Expected -cne $Actual) { throw "$Name expected '$Expected' but got '$Actual'." } +} + +function Assert-True { + param([bool]$Value, [string]$Name) + if (-not $Value) { throw "$Name expected true." } +} + +function Assert-ThrowsCode { + param([scriptblock]$Action, [string]$Code, [string]$Name) + try { + & $Action + throw "$Name did not throw." + } catch { + if ($_.Exception.Message -notmatch "^\[$([regex]::Escape($Code))\]") { + throw "$Name threw unexpected error: $($_.Exception.Message)" + } + } +} + +function Test-Case { + param([string]$Name, [scriptblock]$Action) + try { + & $Action + $script:Passed++ + Write-Host "PASS $Name" + } catch { + $script:Failed++ + Write-Host "FAIL $Name - $($_.Exception.Message)" + } +} + +function New-VerificationFixture { + $root = Join-Path ([IO.Path]::GetTempPath()) ( + "minimum provisioning verification " + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path (Join-Path $root "scripts") -Force | Out-Null + Set-Content -LiteralPath (Join-Path $root "minimum-foss.apk") -Value "fixture" -Encoding ASCII + Set-Content -LiteralPath (Join-Path $root "VERSION.txt") -Value "3.7.3-minimum.3" -Encoding ASCII + [ordered]@{ + applicationId = "se.lublin.mumla" + versionCode = 3070302 + versionName = "3.7.3-minimum.3" + signerSha256 = "A" * 64 + } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $root "RELEASE-MANIFEST.json") -Encoding UTF8 + [ordered]@{ + ApplicationId = "se.lublin.mumla" + VersionCode = 3070302 + VersionName = "3.7.3-minimum.3" + } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $root "identity.json") -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $root "signers.txt") -Value ("A" * 64) -Encoding ASCII + + $fakeUpdater = @' +param( + [string]$Serial = "UPDATER-SERIAL", + [int]$TransportId = 999, + [int]$AdbPort = 65535, + [switch]$LibraryOnly +) +$script:CurrentTarget = "UPDATER-TARGET" +function Throw-UpdateError { + param([string]$Code, [string]$Message) + throw "[$Code] $Message" +} +function Read-ReleaseBundle { + param([string]$Root) + Add-Content -LiteralPath (Join-Path $Root "transcript.txt") -Value "READ_BUNDLE" + if (Test-Path -LiteralPath (Join-Path $Root "read-error")) { + Throw-UpdateError "BUNDLE_CHECKSUM" "fixture checksum failure" + } + $manifest = Get-Content -LiteralPath (Join-Path $Root "RELEASE-MANIFEST.json") -Raw | ConvertFrom-Json + [pscustomobject]@{ + Root = $Root + Manifest = $manifest + ApkPath = (Resolve-Path -LiteralPath (Join-Path $Root "minimum-foss.apk")).Path + } +} +function Get-ApkManifestIdentity { + param([string]$ApkPath) + $root = Split-Path -Parent $ApkPath + Add-Content -LiteralPath (Join-Path $root "transcript.txt") -Value "IDENTITY" + Get-Content -LiteralPath (Join-Path $root "identity.json") -Raw | ConvertFrom-Json +} +function Get-ApkSignerDigests { + param([string]$ApkPath) + $root = Split-Path -Parent $ApkPath + Add-Content -LiteralPath (Join-Path $root "transcript.txt") -Value "SIGNERS" + @((Get-Content -LiteralPath (Join-Path $root "signers.txt")) | Where-Object { $_ }) +} +if ($LibraryOnly) { return } +throw "Fake updater must only be loaded as a library." +'@ + Set-Content -LiteralPath (Join-Path $root "scripts\update-minimum-device.ps1") ` + -Value $fakeUpdater -Encoding UTF8 + return $root +} + +function Remove-VerificationFixture { + param([string]$Root) + if ($Root -and (Test-Path -LiteralPath $Root)) { + Remove-Item -LiteralPath $Root -Recurse -Force + } +} + +Test-Case "release markers distinguish source and extracted bundle layouts" { + $root = Join-Path ([IO.Path]::GetTempPath()) ("minimum-source-layout-" + [guid]::NewGuid().ToString("N")) + try { + New-Item -ItemType Directory -Path $root | Out-Null + Assert-True (-not (Test-ReleaseBundleLayout -Root $root)) "empty source layout" + Set-Content -LiteralPath (Join-Path $root "minimum-foss.apk") -Value "fixture" -Encoding ASCII + Assert-True (Test-ReleaseBundleLayout -Root $root) "release marker" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "valid Release artifact is bound before any ADB operation" { + $root = New-VerificationFixture + try { + $Serial = "KEEP-SERIAL" + $TransportId = 7 + $AdbPort = 5041 + $script:targetRecord = "KEEP-TARGET" + $artifact = Get-VerifiedReleaseProvisioningArtifact -Root $root + Assert-Equal "RELEASE_MANIFEST_AND_SIGNER_VERIFIED" $artifact.Trust "release trust" + Assert-Equal "se.lublin.mumla" $artifact.ApplicationId "package" + Assert-Equal ([long]3070302) ([long]$artifact.VersionCode) "version code" + Assert-Equal ((Get-FileHash -LiteralPath $artifact.ApkPath -Algorithm SHA256).Hash) ` + $artifact.ApkSha256 "bound APK hash" + Assert-Equal ("A" * 64) $artifact.SignerSha256 "bound signer" + Assert-Equal "KEEP-SERIAL" $Serial "provisioning Serial scope" + Assert-Equal 7 $TransportId "provisioning TransportId scope" + Assert-Equal 5041 $AdbPort "provisioning AdbPort scope" + Assert-Equal "KEEP-TARGET" $script:targetRecord "provisioning target scope" + $transcript = @(Get-Content -LiteralPath (Join-Path $root "transcript.txt")) + Assert-Equal "READ_BUNDLE|IDENTITY|SIGNERS" ($transcript -join "|") "verification transcript" + Assert-True (-not (($transcript -join "|") -match "ADB|INSTALL|SETPROP|RECEIVER")) "no device operation" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "Release verification propagates bundle checksum refusal" { + $root = New-VerificationFixture + try { + Set-Content -LiteralPath (Join-Path $root "read-error") -Value "1" -Encoding ASCII + Assert-ThrowsCode { Get-VerifiedReleaseProvisioningArtifact -Root $root } ` + "BUNDLE_CHECKSUM" "checksum refusal" + $transcript = @(Get-Content -LiteralPath (Join-Path $root "transcript.txt")) + Assert-Equal "READ_BUNDLE" ($transcript -join "|") "fail-fast transcript" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "incomplete Release layout fails closed" { + $root = New-VerificationFixture + try { + Remove-Item -LiteralPath (Join-Path $root "minimum-foss.apk") -Force + Assert-ThrowsCode { Get-VerifiedReleaseProvisioningArtifact -Root $root } ` + "BUNDLE_INCOMPLETE" "missing APK" + Assert-True (-not (Test-Path -LiteralPath (Join-Path $root "transcript.txt"))) "verifier not executed" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "missing Release verifier fails before executing bundle code" { + $root = New-VerificationFixture + try { + Remove-Item -LiteralPath (Join-Path $root "scripts\update-minimum-device.ps1") -Force + Assert-ThrowsCode { Get-VerifiedReleaseProvisioningArtifact -Root $root } ` + "BUNDLE_VERIFIER_MISSING" "missing verifier" + Assert-True (-not (Test-Path -LiteralPath (Join-Path $root "transcript.txt"))) "no verifier transcript" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "Release mode refuses an APK outside the manifest bundle" { + $root = New-VerificationFixture + try { + $outside = Join-Path (Split-Path -Parent $root) ("outside-" + [guid]::NewGuid().ToString("N") + ".apk") + try { + Set-Content -LiteralPath $outside -Value "fixture" -Encoding ASCII + Assert-ThrowsCode { Get-VerifiedReleaseProvisioningArtifact -Root $root -RequestedApkPath $outside } ` + "APK_PATH_OUTSIDE_BUNDLE" "outside APK" + Assert-True (-not (Test-Path -LiteralPath (Join-Path $root "transcript.txt"))) "verifier not executed" + } finally { if (Test-Path -LiteralPath $outside) { Remove-Item -LiteralPath $outside -Force } } + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "Release mode accepts the exact canonical APK path" { + $root = New-VerificationFixture + try { + $requested = Join-Path $root ".\minimum-foss.apk" + $artifact = Get-VerifiedReleaseProvisioningArtifact -Root $root -RequestedApkPath $requested + Assert-Equal (Resolve-Path -LiteralPath (Join-Path $root "minimum-foss.apk")).Path ` + $artifact.ApkPath "canonical APK" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "APK identity mismatch is refused" { + $root = New-VerificationFixture + try { + $identity = Get-Content -LiteralPath (Join-Path $root "identity.json") -Raw | ConvertFrom-Json + $identity.VersionCode = 1 + $identity | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $root "identity.json") -Encoding UTF8 + Assert-ThrowsCode { Get-VerifiedReleaseProvisioningArtifact -Root $root } ` + "APK_IDENTITY_BINDING" "identity mismatch" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "missing or extra APK signers are refused" { + $root = New-VerificationFixture + try { + Set-Content -LiteralPath (Join-Path $root "signers.txt") -Value @(("A" * 64), ("B" * 64)) -Encoding ASCII + Assert-ThrowsCode { Get-VerifiedReleaseProvisioningArtifact -Root $root } ` + "APK_SIGNER_BINDING" "extra signer" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "wrong reviewed APK signer is refused" { + $root = New-VerificationFixture + try { + Set-Content -LiteralPath (Join-Path $root "signers.txt") -Value ("B" * 64) -Encoding ASCII + Assert-ThrowsCode { Get-VerifiedReleaseProvisioningArtifact -Root $root } ` + "APK_SIGNER_BINDING" "wrong signer" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "development artifact verifies signature without claiming Release trust" { + $root = New-VerificationFixture + try { + $artifact = Get-VerifiedDevelopmentProvisioningArtifact -Root $root ` + -DevelopmentApkPath (Join-Path $root "minimum-foss.apk") + Assert-Equal "DEVELOPMENT_SIGNATURE_VALID_NOT_RELEASE_BOUND" $artifact.Trust "development trust" + Assert-True ($artifact.Trust -notmatch '^RELEASE_') "no Release claim" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "development artifact still refuses wrong package and multiple signers" { + $root = New-VerificationFixture + try { + $identity = Get-Content -LiteralPath (Join-Path $root "identity.json") -Raw | ConvertFrom-Json + $identity.ApplicationId = "example.not.minimum" + $identity | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $root "identity.json") -Encoding UTF8 + Assert-ThrowsCode { + Get-VerifiedDevelopmentProvisioningArtifact -Root $root ` + -DevelopmentApkPath (Join-Path $root "minimum-foss.apk") + } "DEVELOPMENT_APK_IDENTITY" "wrong development package" + $identity.ApplicationId = "se.lublin.mumla" + $identity | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $root "identity.json") -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $root "signers.txt") -Value @(("A" * 64), ("B" * 64)) -Encoding ASCII + Assert-ThrowsCode { + Get-VerifiedDevelopmentProvisioningArtifact -Root $root ` + -DevelopmentApkPath (Join-Path $root "minimum-foss.apk") + } "DEVELOPMENT_APK_SIGNATURE" "multiple development signers" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "final verification repeats complete Release binding when APK is unchanged" { + $root = New-VerificationFixture + try { + $artifact = Get-VerifiedReleaseProvisioningArtifact -Root $root + $confirmed = Confirm-ProvisioningArtifactUnchanged -ExpectedArtifact $artifact ` + -Root $root -ReleaseBundleMode $true + Assert-Equal $artifact.ApkSha256 $confirmed.ApkSha256 "final hash" + Assert-Equal $artifact.SignerSha256 $confirmed.SignerSha256 "final signer" + Assert-Equal $artifact.Trust $confirmed.Trust "final trust" + $transcript = @(Get-Content -LiteralPath (Join-Path $root "transcript.txt")) + Assert-Equal "READ_BUNDLE|IDENTITY|SIGNERS|READ_BUNDLE|IDENTITY|SIGNERS" ` + ($transcript -join "|") "complete verification repeated" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "APK replacement after preflight fails before final verifier or install" { + $root = New-VerificationFixture + try { + $artifact = Get-VerifiedReleaseProvisioningArtifact -Root $root + Set-Content -LiteralPath $artifact.ApkPath -Value "tampered after operator wait" -Encoding ASCII + Assert-ThrowsCode { + Confirm-ProvisioningArtifactUnchanged -ExpectedArtifact $artifact ` + -Root $root -ReleaseBundleMode $true + } "APK_CHANGED_AFTER_VERIFICATION" "post-preflight replacement" + $transcript = @(Get-Content -LiteralPath (Join-Path $root "transcript.txt")) + Assert-Equal "READ_BUNDLE|IDENTITY|SIGNERS" ($transcript -join "|") ` + "tamper refusal before loading verifier again" + } finally { Remove-VerificationFixture -Root $root } +} + +Test-Case "Release verification is ordered before ADB and every device mutation" { + $source = Get-Content -LiteralPath (Join-Path $PSScriptRoot "..\scripts\provision-minimum-device.ps1") -Raw + $preflight = $source.IndexOf('$verifiedArtifact = Get-VerifiedReleaseProvisioningArtifact') + $adb = $source.IndexOf('$adbPath = (Get-Command adb') + $target = $source.IndexOf('$AdbPort = Select-AdbServerPort') + $setprop = $source.IndexOf('"shell", "setprop", "ro.build.install"') + $install = $source.IndexOf('Install-MinimumApk -ExpectedArtifact $verifiedArtifact') + $finalVerification = $source.IndexOf('$finalArtifact = Confirm-ProvisioningArtifactUnchanged') + $nativeInstall = $source.IndexOf('$output = @(& $adbPath @installArgs 2>&1)') + Assert-True ($preflight -ge 0) "preflight call present" + Assert-True ($preflight -lt $adb) "preflight before ADB resolution" + Assert-True ($preflight -lt $target) "preflight before target selection" + Assert-True ($preflight -lt $setprop) "preflight before RYKS mutation" + Assert-True ($preflight -lt $install) "preflight before install" + Assert-True ($finalVerification -ge 0) "final verification present inside installer" + Assert-True ($finalVerification -lt $nativeInstall) "final verification before native adb install" + Assert-True ($source.IndexOf('[RELEASE_BUNDLE_BUILD_REFUSED]') -lt $adb) "BuildApk refusal before ADB" +} + +Test-Case "Release workflow authorizes exact reviewed main before tag checkout or secrets" { + $source = Get-Content -LiteralPath (Join-Path $PSScriptRoot ` + "..\.github\workflows\release-apk.yml") -Raw + $reviewedCheckout = $source.IndexOf('name: Checkout reviewed main for release authorization') + $authorization = $source.IndexOf('name: Bind release tag to current reviewed main') + $exactBinding = $source.IndexOf('if [[ "$tag_sha" != "$main_sha" ]]') + $tagCheckout = $source.IndexOf('name: Checkout authorized release commit with Humla') + $secretAccess = $source.IndexOf('${{ secrets.MINIMUM_RELEASE_KEYSTORE_BASE64 }}') + Assert-True ($reviewedCheckout -ge 0) "reviewed main checkout present" + Assert-True ($reviewedCheckout -lt $authorization) "reviewed main checked out before authorization" + Assert-True ($authorization -lt $exactBinding) "authorization step contains exact-SHA binding" + Assert-True ($exactBinding -lt $tagCheckout) "exact binding before tag checkout" + Assert-True ($tagCheckout -lt $secretAccess) "authorized checkout before signing secret access" + Assert-True ($source -match 'git checkout --detach "\$RELEASE_SHA"') "checkout uses authorized SHA" +} + +Write-Host "Provisioning verification tests: $($script:Passed) passed, $($script:Failed) failed" +if ($script:Failed -gt 0) { exit 1 } diff --git a/web/app/api/login/route.test.ts b/web/app/api/login/route.test.ts new file mode 100644 index 00000000..9e1ecd25 --- /dev/null +++ b/web/app/api/login/route.test.ts @@ -0,0 +1,161 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { POST } from './route'; +import * as loginRateLimit from '../../../lib/login-rate-limit'; +import * as storage from '../../../lib/storage'; +import { hashSecret, sessionCookieName } from '../../../lib/security'; +import { resetLoginRateLimitMemoryStore } from '../../../lib/login-rate-limit-storage'; + +const previousBotIdEnforce = process.env.BOTID_ENFORCE; + +function request(username = 'admin', password = 'correct horse battery staple') { + return new Request('http://localhost:3000/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + origin: 'http://localhost:3000', + host: 'localhost:3000', + 'x-vercel-forwarded-for': '203.0.113.8' + }, + body: JSON.stringify({ username, password }) + }); +} + +describe('POST /api/login', () => { + beforeEach(async () => { + process.env.BOTID_ENFORCE = 'false'; + vi.restoreAllMocks(); + storage.resetMemoryStore(); + resetLoginRateLimitMemoryStore(); + await storage.putAdmin({ + username: 'admin', + passwordHash: hashSecret('correct horse battery staple'), + createdAt: '2026-08-12T00:00:00.000Z', + updatedAt: '2026-08-12T00:00:00.000Z' + }); + }); + + afterAll(() => { + vi.restoreAllMocks(); + if (previousBotIdEnforce === undefined) delete process.env.BOTID_ENFORCE; + else process.env.BOTID_ENFORCE = previousBotIdEnforce; + }); + + it('consumes admission quota for every valid success before setting the signed session cookie', async () => { + const clientAdmission = vi.spyOn(loginRateLimit, 'checkLoginClientRateLimit').mockResolvedValue({ allowed: true }); + const accountAdmission = vi.spyOn(loginRateLimit, 'checkLoginAccountRateLimit').mockResolvedValue({ allowed: true }); + const first = await POST(request()); + const second = await POST(request()); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(await first.json()).toEqual({ ok: true, username: 'admin' }); + expect(first.headers.get('set-cookie')).toMatch(new RegExp(`^${sessionCookieName()}=`)); + expect(clientAdmission).toHaveBeenCalledTimes(2); + expect(accountAdmission).toHaveBeenCalledTimes(2); + expect(clientAdmission).toHaveBeenNthCalledWith(1, expect.any(Request)); + expect(clientAdmission).toHaveBeenNthCalledWith(2, expect.any(Request)); + expect(accountAdmission).toHaveBeenNthCalledWith(1, true); + expect(accountAdmission).toHaveBeenNthCalledWith(2, true); + }); + + it('admits before returning the generic invalid-credentials response', async () => { + const clientAdmission = vi.spyOn(loginRateLimit, 'checkLoginClientRateLimit').mockResolvedValueOnce({ allowed: true }); + const accountAdmission = vi.spyOn(loginRateLimit, 'checkLoginAccountRateLimit').mockResolvedValueOnce({ allowed: true }); + const response = await POST(request('other-user', 'wrong password')); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Invalid username or password' }); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(clientAdmission).toHaveBeenCalledWith(expect.any(Request)); + expect(accountAdmission).toHaveBeenCalledWith(false); + }); + + it('returns 429 before KV/account work when the client admission is denied', async () => { + vi.spyOn(loginRateLimit, 'checkLoginClientRateLimit').mockResolvedValueOnce({ + allowed: false, + retryAfterSeconds: 417 + }); + const accountAdmission = vi.spyOn(loginRateLimit, 'checkLoginAccountRateLimit'); + const getAdmin = vi.spyOn(storage, 'getAdmin'); + const response = await POST(request()); + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('417'); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(await response.json()).toEqual({ error: 'Too many attempts; try again later' }); + expect(getAdmin).not.toHaveBeenCalled(); + expect(accountAdmission).not.toHaveBeenCalled(); + }); + + it('returns 429 and Retry-After when the configured-account admission is denied', async () => { + vi.spyOn(loginRateLimit, 'checkLoginClientRateLimit').mockResolvedValueOnce({ allowed: true }); + const accountAdmission = vi.spyOn(loginRateLimit, 'checkLoginAccountRateLimit').mockResolvedValueOnce({ + allowed: false, + retryAfterSeconds: 318 + }); + const response = await POST(request()); + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('318'); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(await response.json()).toEqual({ error: 'Too many attempts; try again later' }); + expect(accountAdmission).toHaveBeenCalledWith(true); + }); + + it('returns the same 429 shape when the bounded decoy-account admission is denied', async () => { + vi.spyOn(loginRateLimit, 'checkLoginClientRateLimit').mockResolvedValueOnce({ allowed: true }); + const accountAdmission = vi.spyOn(loginRateLimit, 'checkLoginAccountRateLimit').mockResolvedValueOnce({ + allowed: false, + retryAfterSeconds: 318 + }); + const response = await POST(request('random-name', 'wrong password')); + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('318'); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(await response.json()).toEqual({ error: 'Too many attempts; try again later' }); + expect(accountAdmission).toHaveBeenCalledWith(false); + }); + + it('fails closed with a generic 503 and bounded Retry-After when admission is unavailable', async () => { + vi.spyOn(loginRateLimit, 'checkLoginClientRateLimit').mockRejectedValueOnce(new Error('private backend detail')); + const accountAdmission = vi.spyOn(loginRateLimit, 'checkLoginAccountRateLimit'); + const getAdmin = vi.spyOn(storage, 'getAdmin'); + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const response = await POST(request()); + expect(response.status).toBe(503); + expect(response.headers.get('retry-after')).toBe(String(loginRateLimit.LOGIN_RATE_LIMIT_UNAVAILABLE_RETRY_SECONDS)); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(await response.json()).toEqual({ error: 'Authentication service unavailable' }); + expect(log).toHaveBeenCalledWith('Login rate limiter unavailable'); + expect(JSON.stringify(log.mock.calls)).not.toMatch(/private backend detail|203\.0\.113\.8|private-token/); + expect(getAdmin).not.toHaveBeenCalled(); + expect(accountAdmission).not.toHaveBeenCalled(); + }); + + it('fails closed without account admission when administrator storage is unavailable', async () => { + vi.spyOn(loginRateLimit, 'checkLoginClientRateLimit').mockResolvedValueOnce({ allowed: true }); + const accountAdmission = vi.spyOn(loginRateLimit, 'checkLoginAccountRateLimit'); + vi.spyOn(storage, 'getAdmin').mockRejectedValueOnce(new Error('private KV detail')); + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await POST(request()); + + expect(response.status).toBe(503); + expect(response.headers.get('retry-after')).toBe(String(loginRateLimit.LOGIN_RATE_LIMIT_UNAVAILABLE_RETRY_SECONDS)); + expect(await response.json()).toEqual({ error: 'Authentication service unavailable' }); + expect(accountAdmission).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith('Login authentication storage unavailable'); + expect(JSON.stringify(log.mock.calls)).not.toMatch(/private KV detail|admin|private-token/); + }); + + it('fails closed with a generic log when the account stage is unavailable', async () => { + vi.spyOn(loginRateLimit, 'checkLoginClientRateLimit').mockResolvedValueOnce({ allowed: true }); + vi.spyOn(loginRateLimit, 'checkLoginAccountRateLimit') + .mockRejectedValueOnce(new Error('private account D1 detail')); + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await POST(request()); + + expect(response.status).toBe(503); + expect(response.headers.get('retry-after')).toBe(String(loginRateLimit.LOGIN_RATE_LIMIT_UNAVAILABLE_RETRY_SECONDS)); + expect(await response.json()).toEqual({ error: 'Authentication service unavailable' }); + expect(log).toHaveBeenCalledWith('Login rate limiter unavailable'); + expect(JSON.stringify(log.mock.calls)).not.toMatch(/private account D1 detail|203\.0\.113\.8|private-token/); + }); +}); diff --git a/web/app/api/login/route.ts b/web/app/api/login/route.ts index aed4c7f9..066e3632 100644 --- a/web/app/api/login/route.ts +++ b/web/app/api/login/route.ts @@ -1,19 +1,64 @@ -import { jsonResponse, errorResponse, readJson } from '@/lib/api'; -import { recordAdminActivity } from '@/lib/activity'; -import { requireHumanMutation } from '@/lib/botid'; -import { allowLoginAttempt, createSession, sameOrigin, sessionCookieOptions, verifySecret } from '@/lib/security'; -import { getAdmin } from '@/lib/storage'; +import { jsonResponse, errorResponse, readJson } from '../../../lib/api'; +import { recordAdminActivity } from '../../../lib/activity'; +import { requireHumanMutation } from '../../../lib/botid'; +import { + checkLoginAccountRateLimit, + checkLoginClientRateLimit, + LOGIN_RATE_LIMIT_UNAVAILABLE_RETRY_SECONDS +} from '../../../lib/login-rate-limit'; +import { createSession, sameOrigin, sessionCookieOptions, verifySecret } from '../../../lib/security'; +import { getAdmin } from '../../../lib/storage'; export const runtime = 'nodejs'; +function authenticationUnavailable() { + return errorResponse('Authentication service unavailable', 503, { + 'Retry-After': String(LOGIN_RATE_LIMIT_UNAVAILABLE_RETRY_SECONDS) + }); +} + +function tooManyAttempts(retryAfterSeconds: number) { + const safeRetryAfter = Number.isSafeInteger(retryAfterSeconds) && retryAfterSeconds > 0 + ? retryAfterSeconds + : 1; + return errorResponse('Too many attempts; try again later', 429, { + 'Retry-After': String(safeRetryAfter) + }); +} + export async function POST(request: Request) { if (!sameOrigin(request) || !(await requireHumanMutation())) return errorResponse('Browser verification required', 403); - const identifier = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'; - if (!allowLoginAttempt(identifier)) return errorResponse('Too many attempts; try again later', 429); + try { + const clientAdmission = await checkLoginClientRateLimit(request); + if (!clientAdmission.allowed) return tooManyAttempts(clientAdmission.retryAfterSeconds); + } catch { + // Do not attach the error, request, username, IP address or D1 configuration. + console.error('Login rate limiter unavailable'); + return authenticationUnavailable(); + } const body = await readJson(request); const username = typeof body?.username === 'string' ? body.username : ''; const password = typeof body?.password === 'string' ? body.password : ''; - const admin = await getAdmin(); + let admin; + try { + admin = await getAdmin(); + } catch { + console.error('Login authentication storage unavailable'); + return authenticationUnavailable(); + } + try { + // Admission precedes password verification and is never reset on success, so + // valid logins consume the same fixed-window quota as failed attempts. Unknown + // usernames share one bounded decoy bucket instead of creating per-name rows or + // locking the real administrator bucket. This lab treats the admin username as + // an identifier, not a secret; CONFIG_BACKEND.md records that deliberate tradeoff. + const accountAdmission = await checkLoginAccountRateLimit(admin?.username === username); + if (!accountAdmission.allowed) return tooManyAttempts(accountAdmission.retryAfterSeconds); + } catch { + // Do not attach the error, request, username, IP address or D1 configuration. + console.error('Login rate limiter unavailable'); + return authenticationUnavailable(); + } if (!admin || admin.username !== username || !verifySecret(password, admin.passwordHash)) { return errorResponse('Invalid username or password', 401); } diff --git a/web/cloudflare/d1/0001_login_rate_limit.sql b/web/cloudflare/d1/0001_login_rate_limit.sql new file mode 100644 index 00000000..c4ceb0d2 --- /dev/null +++ b/web/cloudflare/d1/0001_login_rate_limit.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS login_rate_limit_v1 ( + bucket_hash TEXT PRIMARY KEY, + attempts INTEGER NOT NULL CHECK (attempts >= 1), + reset_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX IF NOT EXISTS login_rate_limit_v1_reset_at + ON login_rate_limit_v1 (reset_at); + +CREATE TRIGGER IF NOT EXISTS login_rate_limit_v1_prune_after_insert +AFTER INSERT ON login_rate_limit_v1 +BEGIN + DELETE FROM login_rate_limit_v1 + WHERE reset_at < unixepoch() - 86400; +END; + +CREATE TRIGGER IF NOT EXISTS login_rate_limit_v1_prune_after_update +AFTER UPDATE ON login_rate_limit_v1 +BEGIN + DELETE FROM login_rate_limit_v1 + WHERE reset_at < unixepoch() - 86400; +END; diff --git a/web/lib/api.ts b/web/lib/api.ts index 72b515e8..48e9f828 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -13,8 +13,8 @@ export function jsonResponse(body: T, status = 200, headers?: HeadersInit) { return response; } -export function errorResponse(message: string, status = 400) { - return jsonResponse({ error: message }, status); +export function errorResponse(message: string, status = 400, headers?: HeadersInit) { + return jsonResponse({ error: message }, status, headers); } export async function readJson(request: Request) { diff --git a/web/lib/login-rate-limit-storage.test.ts b/web/lib/login-rate-limit-storage.test.ts new file mode 100644 index 00000000..8e421d64 --- /dev/null +++ b/web/lib/login-rate-limit-storage.test.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + LoginRateLimitUnavailableError, + reserveLoginRateLimitBucket, + resetLoginRateLimitMemoryStore +} from './login-rate-limit-storage'; + +const clientHash = `v1:${'a'.repeat(43)}`; + +describe('login rate limit storage', () => { + beforeEach(() => { + vi.restoreAllMocks(); + resetLoginRateLimitMemoryStore(); + }); + + afterEach(() => vi.unstubAllEnvs()); + + it('provides deterministic fixed-window records in non-production memory', async () => { + await expect(reserveLoginRateLimitBucket(clientHash, 900, { + backend: 'memory', + nowSeconds: 1_000 + })).resolves.toEqual({ + bucketHash: clientHash, + attempts: 1, + resetAt: 1_900, + observedAt: 1_000 + }); + await expect(reserveLoginRateLimitBucket(clientHash, 900, { + backend: 'memory', + nowSeconds: 1_899 + })).resolves.toEqual({ + bucketHash: clientHash, + attempts: 2, + resetAt: 1_900, + observedAt: 1_899 + }); + await expect(reserveLoginRateLimitBucket(clientHash, 900, { + backend: 'memory', + nowSeconds: 1_900 + })).resolves.toEqual({ + bucketHash: clientHash, + attempts: 1, + resetAt: 2_800, + observedAt: 1_900 + }); + }); + + it('uses one parameterized D1 statement and accepts a complete response', async () => { + const fetchImpl = vi.fn(async (_input, _init) => new Response(JSON.stringify({ + success: true, + result: [{ + success: true, + results: [{ bucket_hash: clientHash, attempts: 11, reset_at: 1_900, observed_at: 1_100 }] + }] + }), { status: 200 })); + + await expect(reserveLoginRateLimitBucket(clientHash, 900, { + backend: 'd1', + d1Config: { accountId: 'account-id', databaseId: 'database-id', apiToken: 'private-token' }, + fetchImpl + })).resolves.toEqual({ bucketHash: clientHash, attempts: 11, resetAt: 1_900, observedAt: 1_100 }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/account-id/d1/database/database-id/query'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toMatchObject({ + Authorization: 'Bearer private-token', + 'Content-Type': 'application/json' + }); + const requestBody = JSON.parse(String(init?.body)) as { sql: string; params: string[] }; + expect(requestBody.params).toEqual([clientHash]); + expect(requestBody.sql).toContain('INSERT INTO login_rate_limit_v1'); + expect(requestBody.sql).toContain('ON CONFLICT(bucket_hash) DO UPDATE'); + expect(requestBody.sql).toContain('RETURNING bucket_hash, attempts, reset_at, unixepoch() AS observed_at'); + expect(requestBody.sql.match(/INSERT INTO/g)).toHaveLength(1); + }); + + it.each([ + ['non-success HTTP response', async () => new Response('', { status: 503 })], + ['malformed response', async () => new Response(JSON.stringify({ success: true, result: [] }), { status: 200 })], + ['network rejection', async () => { throw new Error('network details'); }] + ])('fails closed on a %s without exposing backend details', async (_label, implementation) => { + const operation = reserveLoginRateLimitBucket(clientHash, 900, { + backend: 'd1', + d1Config: { accountId: 'account-id', databaseId: 'database-id', apiToken: 'private-token' }, + fetchImpl: vi.fn(implementation) + }); + await expect(operation).rejects.toBeInstanceOf(LoginRateLimitUnavailableError); + await expect(operation).rejects.not.toThrow(/network details|private-token/); + }); + + it('fails closed when the D1 resource configuration is incomplete', async () => { + await expect(reserveLoginRateLimitBucket(clientHash, 900, { + backend: 'd1', + d1Config: { accountId: '', databaseId: '', apiToken: '' } + })).rejects.toBeInstanceOf(LoginRateLimitUnavailableError); + }); + + it('selects D1 and fails closed by default when production is misconfigured', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', ''); + vi.stubEnv('CLOUDFLARE_D1_DATABASE_ID', ''); + vi.stubEnv('CLOUDFLARE_D1_API_TOKEN', ''); + await expect(reserveLoginRateLimitBucket(clientHash, 900)) + .rejects.toBeInstanceOf(LoginRateLimitUnavailableError); + }); + + it('prunes buckets that expired more than 24 hours ago after every insert or update', () => { + const migration = readFileSync( + new URL('../cloudflare/d1/0001_login_rate_limit.sql', import.meta.url), + 'utf8' + ); + expect(migration).toContain('CREATE INDEX IF NOT EXISTS login_rate_limit_v1_reset_at'); + expect(migration).toMatch(/AFTER INSERT ON login_rate_limit_v1[\s\S]*reset_at < unixepoch\(\) - 86400/); + expect(migration).toMatch(/AFTER UPDATE ON login_rate_limit_v1[\s\S]*reset_at < unixepoch\(\) - 86400/); + }); +}); diff --git a/web/lib/login-rate-limit-storage.ts b/web/lib/login-rate-limit-storage.ts new file mode 100644 index 00000000..41f12cfa --- /dev/null +++ b/web/lib/login-rate-limit-storage.ts @@ -0,0 +1,182 @@ +const D1_API_ROOT = 'https://api.cloudflare.com/client/v4'; +const D1_REQUEST_TIMEOUT_MS = 2_000; + +export interface LoginRateLimitRecord { + bucketHash: string; + attempts: number; + resetAt: number; + observedAt: number; +} + +export interface D1LoginRateLimitConfig { + accountId: string; + databaseId: string; + apiToken: string; +} + +export interface LoginRateLimitStorageOptions { + backend?: 'd1' | 'memory'; + nowSeconds?: number; + fetchImpl?: typeof fetch; + d1Config?: D1LoginRateLimitConfig; +} + +interface MemoryRecord { + attempts: number; + resetAt: number; +} + +interface D1Row { + bucket_hash?: unknown; + attempts?: unknown; + reset_at?: unknown; + observed_at?: unknown; +} + +const memoryRecords = new Map(); + +export class LoginRateLimitUnavailableError extends Error { + constructor() { + super('Login rate limit storage is unavailable'); + this.name = 'LoginRateLimitUnavailableError'; + } +} + +function unavailable(): never { + throw new LoginRateLimitUnavailableError(); +} + +function validateInputs(bucketHash: string, windowSeconds: number) { + if ( + !/^v1:[A-Za-z0-9_-]{43}$/.test(bucketHash) + || !Number.isSafeInteger(windowSeconds) + || windowSeconds < 1 + || windowSeconds > 24 * 60 * 60 + ) unavailable(); +} + +function productionD1Config(): D1LoginRateLimitConfig { + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; + const databaseId = process.env.CLOUDFLARE_D1_DATABASE_ID; + const apiToken = process.env.CLOUDFLARE_D1_API_TOKEN; + if (!accountId || !databaseId || !apiToken) unavailable(); + return { accountId, databaseId, apiToken }; +} + +function reserveInMemory( + bucketHash: string, + windowSeconds: number, + nowSeconds = Math.floor(Date.now() / 1_000) +) { + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) unavailable(); + const current = memoryRecords.get(bucketHash); + const next = !current || current.resetAt <= nowSeconds + ? { attempts: 1, resetAt: nowSeconds + windowSeconds } + : { attempts: current.attempts + 1, resetAt: current.resetAt }; + memoryRecords.set(bucketHash, next); + return { bucketHash, ...next, observedAt: nowSeconds }; +} + +function admissionSql(windowSeconds: number) { + return ` +INSERT INTO login_rate_limit_v1 (bucket_hash, attempts, reset_at) +VALUES (?, 1, unixepoch() + ${windowSeconds}) +ON CONFLICT(bucket_hash) DO UPDATE SET + attempts = CASE + WHEN login_rate_limit_v1.reset_at <= unixepoch() THEN 1 + ELSE login_rate_limit_v1.attempts + 1 + END, + reset_at = CASE + WHEN login_rate_limit_v1.reset_at <= unixepoch() THEN unixepoch() + ${windowSeconds} + ELSE login_rate_limit_v1.reset_at + END +RETURNING bucket_hash, attempts, reset_at, unixepoch() AS observed_at;`.trim(); +} + +function parseD1Record(value: unknown, expectedHash: string) { + if (!value || typeof value !== 'object') unavailable(); + const envelope = value as { + success?: unknown; + result?: unknown; + }; + if (envelope.success !== true || !Array.isArray(envelope.result) || envelope.result.length !== 1) unavailable(); + const query = envelope.result[0] as { success?: unknown; results?: unknown } | undefined; + if (!query || query.success !== true || !Array.isArray(query.results)) unavailable(); + + const records = (query.results as D1Row[]).map((row): LoginRateLimitRecord => { + if ( + typeof row.bucket_hash !== 'string' + || !Number.isSafeInteger(row.attempts) + || !Number.isSafeInteger(row.reset_at) + || !Number.isSafeInteger(row.observed_at) + ) unavailable(); + return { + bucketHash: row.bucket_hash, + attempts: row.attempts as number, + resetAt: row.reset_at as number, + observedAt: row.observed_at as number + }; + }); + + if ( + records.length !== 1 + || records.some((record) => record.attempts < 1 || record.resetAt <= record.observedAt) + || records[0]?.bucketHash !== expectedHash + ) unavailable(); + return records[0]; +} + +async function reserveInD1( + bucketHash: string, + windowSeconds: number, + config: D1LoginRateLimitConfig, + fetchImpl: typeof fetch +) { + if (!config.accountId || !config.databaseId || !config.apiToken) unavailable(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), D1_REQUEST_TIMEOUT_MS); + try { + const response = await fetchImpl( + `${D1_API_ROOT}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/query`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${config.apiToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + sql: admissionSql(windowSeconds), + params: [bucketHash] + }), + signal: controller.signal + } + ); + if (!response.ok) unavailable(); + return parseD1Record(await response.json(), bucketHash); + } catch (error) { + if (error instanceof LoginRateLimitUnavailableError) throw error; + unavailable(); + } finally { + clearTimeout(timeout); + } +} + +export async function reserveLoginRateLimitBucket( + bucketHash: string, + windowSeconds: number, + options: LoginRateLimitStorageOptions = {} +) { + validateInputs(bucketHash, windowSeconds); + const backend = options.backend ?? (process.env.NODE_ENV === 'production' ? 'd1' : 'memory'); + if (backend === 'memory') return reserveInMemory(bucketHash, windowSeconds, options.nowSeconds); + return reserveInD1( + bucketHash, + windowSeconds, + options.d1Config ?? productionD1Config(), + options.fetchImpl ?? fetch + ); +} + +export function resetLoginRateLimitMemoryStore() { + memoryRecords.clear(); +} diff --git a/web/lib/login-rate-limit.test.ts b/web/lib/login-rate-limit.test.ts new file mode 100644 index 00000000..98c48366 --- /dev/null +++ b/web/lib/login-rate-limit.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + checkLoginAccountRateLimit, + checkLoginClientRateLimit, + createLoginRateLimitBucketHash, + LOGIN_RATE_LIMIT_ACCOUNT_ATTEMPTS, + LOGIN_RATE_LIMIT_CLIENT_ATTEMPTS, + LOGIN_RATE_LIMIT_WINDOW_SECONDS +} from './login-rate-limit'; +import { LoginRateLimitUnavailableError, type LoginRateLimitRecord } from './login-rate-limit-storage'; + +const secret = 'test-login-rate-secret-012345678901234567890'; + +function request(headers: HeadersInit = {}) { + return new Request('https://minimum.example/api/login', { headers }); +} + +function record(bucketHash: string, attempts = 1): LoginRateLimitRecord { + return { bucketHash, attempts, resetAt: 1_900, observedAt: 1_000 }; +} + +describe('login rate limit policy', () => { + it('creates stable, scope-separated HMAC keys that do not contain identifiers', () => { + const client = createLoginRateLimitBucketHash('client', '203.0.113.8', secret); + const repeated = createLoginRateLimitBucketHash('client', '203.0.113.8', secret); + const account = createLoginRateLimitBucketHash('account', '203.0.113.8', secret); + expect(client).toBe(repeated); + expect(client).not.toBe(account); + expect(client).toMatch(/^v1:[A-Za-z0-9_-]{43}$/); + expect(client).not.toContain('203.0.113.8'); + }); + + it('trusts only the Vercel forwarding header for the client dimension', async () => { + const captured: string[] = []; + await checkLoginClientRateLimit(request({ + 'x-forwarded-for': '198.51.100.99', + 'x-vercel-forwarded-for': '203.0.113.8' + }), { + keySecret: secret, + reserveBucket: async (bucketHash) => { + captured.push(bucketHash); + return record(bucketHash); + } + }); + expect(captured).toEqual([createLoginRateLimitBucketHash('client', '203.0.113.8', secret)]); + }); + + it('maps invalid or multi-value trusted headers to one safe unknown bucket', async () => { + const captured: string[] = []; + const reserveBucket = async (bucketHash: string) => { + captured.push(bucketHash); + return record(bucketHash); + }; + await checkLoginClientRateLimit(request({ + 'x-vercel-forwarded-for': '203.0.113.8, 198.51.100.3' + }), { keySecret: secret, reserveBucket }); + await checkLoginClientRateLimit(request({ + 'x-vercel-forwarded-for': 'not-an-ip' + }), { keySecret: secret, reserveBucket }); + expect(captured[0]).toBe(captured[1]); + expect(captured[0]).toBe(createLoginRateLimitBucketHash('client', 'unknown', secret)); + }); + + it('allows ten client attempts and denies the eleventh for the fixed window', async () => { + let attempts = 0; + const reserveBucket = async (bucketHash: string) => record(bucketHash, ++attempts); + for (let count = 1; count <= LOGIN_RATE_LIMIT_CLIENT_ATTEMPTS; count += 1) { + await expect(checkLoginClientRateLimit(request({ + 'x-vercel-forwarded-for': '203.0.113.8' + }), { keySecret: secret, reserveBucket })).resolves.toEqual({ allowed: true }); + } + await expect(checkLoginClientRateLimit(request({ + 'x-vercel-forwarded-for': '203.0.113.8' + }), { keySecret: secret, reserveBucket })).resolves.toEqual({ + allowed: false, + retryAfterSeconds: LOGIN_RATE_LIMIT_WINDOW_SECONDS + }); + }); + + it('applies one bounded configured-account bucket independently', async () => { + let attempts = 0; + const reserved: string[] = []; + const reserveBucket = async (bucketHash: string) => { + reserved.push(bucketHash); + return record(bucketHash, ++attempts); + }; + for (let count = 1; count <= LOGIN_RATE_LIMIT_ACCOUNT_ATTEMPTS; count += 1) { + await expect(checkLoginAccountRateLimit(true, { + keySecret: secret, + reserveBucket + })).resolves.toEqual({ allowed: true }); + } + await expect(checkLoginAccountRateLimit(true, { + keySecret: secret, + reserveBucket + })).resolves.toEqual({ + allowed: false, + retryAfterSeconds: LOGIN_RATE_LIMIT_WINDOW_SECONDS + }); + expect(new Set(reserved).size).toBe(1); + expect(reserved[0]).toMatch(/^v1:[A-Za-z0-9_-]{43}$/); + }); + + it('uses one stable decoy bucket for unconfigured usernames without touching the admin bucket', async () => { + const reserved: string[] = []; + const reserveBucket = async (bucketHash: string) => { + reserved.push(bucketHash); + return record(bucketHash); + }; + await checkLoginAccountRateLimit(false, { keySecret: secret, reserveBucket }); + await checkLoginAccountRateLimit(false, { keySecret: secret, reserveBucket }); + await checkLoginAccountRateLimit(true, { keySecret: secret, reserveBucket }); + expect(reserved[0]).toBe(reserved[1]); + expect(reserved[0]).not.toBe(reserved[2]); + expect(new Set(reserved).size).toBe(2); + }); + + it('fails closed when the key secret is shorter than 32 bytes', async () => { + await expect(checkLoginClientRateLimit(request(), { + keySecret: 'too-short', + reserveBucket: async (bucketHash) => record(bucketHash) + })).rejects.toBeInstanceOf(LoginRateLimitUnavailableError); + }); +}); diff --git a/web/lib/login-rate-limit.ts b/web/lib/login-rate-limit.ts new file mode 100644 index 00000000..f34ad022 --- /dev/null +++ b/web/lib/login-rate-limit.ts @@ -0,0 +1,111 @@ +import { createHmac } from 'node:crypto'; +import { isIP } from 'node:net'; +import { + LoginRateLimitUnavailableError, + reserveLoginRateLimitBucket, + type LoginRateLimitRecord +} from './login-rate-limit-storage'; + +export const LOGIN_RATE_LIMIT_WINDOW_SECONDS = 15 * 60; +export const LOGIN_RATE_LIMIT_CLIENT_ATTEMPTS = 10; +export const LOGIN_RATE_LIMIT_ACCOUNT_ATTEMPTS = 30; +export const LOGIN_RATE_LIMIT_UNAVAILABLE_RETRY_SECONDS = 30; + +const DEVELOPMENT_KEY_SECRET = 'development-login-rate-key-01234567890123456789'; +const CLIENT_SCOPE = 'client'; +const ACCOUNT_SCOPE = 'account'; +const ADMIN_ACCOUNT_IDENTIFIER = 'configured-admin-account'; +const DECOY_ACCOUNT_IDENTIFIER = 'unconfigured-account'; + +interface BucketPolicy { + bucketHash: string; + limit: number; +} + +export type LoginRateLimitDecision = + | { allowed: true } + | { allowed: false; retryAfterSeconds: number }; + +export interface LoginRateLimitDependencies { + keySecret?: string; + reserveBucket?: ( + bucketHash: string, + windowSeconds: number + ) => Promise; +} + +function keySecret(override?: string) { + const value = override ?? process.env.LOGIN_RATE_LIMIT_KEY_SECRET + ?? (process.env.NODE_ENV === 'production' ? undefined : DEVELOPMENT_KEY_SECRET); + if (!value || Buffer.byteLength(value, 'utf8') < 32) throw new LoginRateLimitUnavailableError(); + return value; +} + +function canonicalClientIp(request: Request) { + const forwarded = request.headers.get('x-vercel-forwarded-for')?.trim(); + if (!forwarded || forwarded.includes(',')) return 'unknown'; + const version = isIP(forwarded); + if (version === 4) return forwarded; + if (version === 6) { + try { + const hostname = new URL(`http://[${forwarded}]/`).hostname; + return hostname.slice(1, -1).toLowerCase(); + } catch { + return 'unknown'; + } + } + return 'unknown'; +} + +export function createLoginRateLimitBucketHash(scope: 'client' | 'account', identifier: string, secret: string) { + return `v1:${createHmac('sha256', secret) + .update(`minimum-login-rate:v1\0${scope}\0${identifier}`) + .digest('base64url')}`; +} + +async function checkBucket( + policy: BucketPolicy, + reserveBucket: NonNullable +): Promise { + const record = await reserveBucket(policy.bucketHash, LOGIN_RATE_LIMIT_WINDOW_SECONDS); + if ( + record.bucketHash !== policy.bucketHash + || !Number.isSafeInteger(record.attempts) + || record.attempts < 1 + || !Number.isSafeInteger(record.resetAt) + || !Number.isSafeInteger(record.observedAt) + || record.resetAt <= record.observedAt + ) { + throw new LoginRateLimitUnavailableError(); + } + if (record.attempts <= policy.limit) return { allowed: true }; + return { allowed: false, retryAfterSeconds: Math.max(1, record.resetAt - record.observedAt) }; +} + +export async function checkLoginClientRateLimit( + request: Request, + dependencies: LoginRateLimitDependencies = {} +): Promise { + const secret = keySecret(dependencies.keySecret); + const reserveBucket = dependencies.reserveBucket ?? reserveLoginRateLimitBucket; + return checkBucket({ + bucketHash: createLoginRateLimitBucketHash(CLIENT_SCOPE, canonicalClientIp(request), secret), + limit: LOGIN_RATE_LIMIT_CLIENT_ATTEMPTS + }, reserveBucket); +} + +export async function checkLoginAccountRateLimit( + targetsConfiguredAccount: boolean, + dependencies: LoginRateLimitDependencies = {} +): Promise { + const secret = keySecret(dependencies.keySecret); + const reserveBucket = dependencies.reserveBucket ?? reserveLoginRateLimitBucket; + return checkBucket({ + bucketHash: createLoginRateLimitBucketHash( + ACCOUNT_SCOPE, + targetsConfiguredAccount ? ADMIN_ACCOUNT_IDENTIFIER : DECOY_ACCOUNT_IDENTIFIER, + secret + ), + limit: LOGIN_RATE_LIMIT_ACCOUNT_ATTEMPTS + }, reserveBucket); +} diff --git a/web/lib/security.ts b/web/lib/security.ts index 432b3ce7..839d811f 100644 --- a/web/lib/security.ts +++ b/web/lib/security.ts @@ -2,7 +2,6 @@ import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypt const SESSION_COOKIE = 'minimum_admin_session'; const SESSION_TTL_SECONDS = 60 * 60 * 8; -const loginAttempts = new Map(); function sessionSecret() { const value = process.env.SESSION_SECRET; @@ -112,15 +111,3 @@ export function sameOrigin(request: Request) { return false; } } - -export function allowLoginAttempt(identifier: string) { - const now = Date.now(); - const current = loginAttempts.get(identifier); - if (!current || current.resetAt <= now) { - loginAttempts.set(identifier, { count: 1, resetAt: now + 15 * 60 * 1000 }); - return true; - } - if (current.count >= 10) return false; - current.count += 1; - return true; -}