From 78ad0b32d898ea831dc729ebbbc9b064dd5caf43 Mon Sep 17 00:00:00 2001 From: villadel Date: Sun, 30 Aug 2026 02:15:20 +0000 Subject: [PATCH] feat(security): implement issues #276 #277 #278 #279 #276 - Add automatic session lock after configurable inactivity timeout - iOS: SessionLockService with scenePhase lifecycle hooks - Android: SessionLockManager + AppLifecycleObserver via ProcessLifecycleOwner #277 - Prevent sensitive data in app switcher / recent apps preview - iOS: PrivacyOverlayModifier hides content on background/inactive scenePhase - Android: FLAG_SECURE set in MainActivity.onCreate - Docs: manual QA checklist updated #278 - Anti-tampering APK signature verification - Android: SignatureVerifier checks signing cert SHA-256 at runtime - Non-blocking TamperWarningDialog shown on mismatch (consistent with #118) #279 - Redact bearer tokens and nonces from debug/crash logs - iOS: LogRedactor utility + applied to DecodingFailureLogger and APIClient - Android: LogRedactor object + applied to API logging call sites - Tests added on both platforms Updated .gitignore: added test snapshots, IDE files, build artifacts, secrets --- .gitignore | 47 ++++- android/.gitignore | 15 ++ .../java/com/ethosprotocol/api/ApiClient.kt | 6 +- .../security/AppLifecycleObserver.kt | 41 +++++ .../com/ethosprotocol/security/LogRedactor.kt | 75 ++++++++ .../security/SessionLockManager.kt | 73 ++++++++ .../security/SignatureVerifier.kt | 120 +++++++++++++ .../security/TamperWarningDialog.kt | 45 +++++ .../java/com/ethosprotocol/ui/MainActivity.kt | 30 ++++ .../ethosprotocol/security/LogRedactorTest.kt | 136 ++++++++++++++ .../ethosprotocol/security/SecureFlagTest.kt | 56 ++++++ .../security/SessionLockManagerTest.kt | 166 ++++++++++++++++++ .../security/SignatureVerifierTest.kt | 160 +++++++++++++++++ docs/manual-qa-checklist.md | 33 ++++ ios/EthosProtocol/.gitignore | 13 ++ .../Sources/App/EthosProtocolApp.swift | 18 ++ .../Sources/Services/APIClient.swift | 8 +- .../Services/DecodingFailureLogger.swift | 6 +- .../Sources/Services/LogRedactor.swift | 76 ++++++++ .../Sources/Services/SessionLockService.swift | 76 ++++++++ .../Views/PrivacyOverlayModifier.swift | 58 ++++++ .../Tests/LogRedactorTests.swift | 117 ++++++++++++ .../Tests/SessionLockServiceTests.swift | 115 ++++++++++++ 23 files changed, 1486 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/java/com/ethosprotocol/security/AppLifecycleObserver.kt create mode 100644 android/app/src/main/java/com/ethosprotocol/security/LogRedactor.kt create mode 100644 android/app/src/main/java/com/ethosprotocol/security/SessionLockManager.kt create mode 100644 android/app/src/main/java/com/ethosprotocol/security/SignatureVerifier.kt create mode 100644 android/app/src/main/java/com/ethosprotocol/security/TamperWarningDialog.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/security/LogRedactorTest.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/security/SecureFlagTest.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/security/SessionLockManagerTest.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/security/SignatureVerifierTest.kt create mode 100644 ios/EthosProtocol/Sources/Services/LogRedactor.swift create mode 100644 ios/EthosProtocol/Sources/Services/SessionLockService.swift create mode 100644 ios/EthosProtocol/Sources/Views/PrivacyOverlayModifier.swift create mode 100644 ios/EthosProtocol/Tests/LogRedactorTests.swift create mode 100644 ios/EthosProtocol/Tests/SessionLockServiceTests.swift diff --git a/.gitignore b/.gitignore index 2703fc5..9d88d06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,49 @@ - # Byte-compiled CI helper scripts __pycache__/ *.pyc + +# OS +.DS_Store +Thumbs.db +*.swp +*.swo + +# Test snapshots +**/__Snapshots__/ +**/snapshots/ +**/*.png.snap +**/ReferenceImages/ +android/app/src/test/snapshots/ + +# Build artifacts +build/ +*.o +*.class +*.jar +!gradle-wrapper.jar + +# IDE +.idea/ +*.iml +.vscode/ + +# Android +android/local.properties +android/app/google-services.json +android/.gradle/ + +# iOS Xcode generated +ios/EthosProtocol/Xcode/ +*.xcuserstate +*.xcworkspace/xcuserdata/ +DerivedData/ + +# Secrets / credentials +*.p8 +*.p12 +*.mobileprovision +.env +.env.* +keystore.jks +google-services.json +GoogleService-Info.plist diff --git a/android/.gitignore b/android/.gitignore index 030212e..a2f7fd5 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -10,3 +10,18 @@ local.properties **/build/ /dependency-check-data/ + +# Test snapshots (Paparazzi / Shot) +**/snapshots/ +**/__Snapshots__/ +**/*.png.snap +**/ReferenceImages/ +app/src/test/snapshots/ + +# Secrets +google-services.json +*.p12 +*.jks +*.keystore +.env +.env.* diff --git a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt index e063aaf..78c6ee8 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt @@ -83,12 +83,16 @@ class ApiClient( json(Json { ignoreUnknownKeys = true; isLenient = true }) } install(Logging) { - // Logging Redaction Policy (#111) — see shared/api-contract.md §Logging Redaction Policy. + // Logging Redaction Policy (#111, #279) — see shared/api-contract.md §Logging Redaction Policy. // Full request/response bodies (bearer token, 2FA secrets, vault balances, beneficiary // addresses, acceptance tokens) must never be written to logcat in any build. // LogLevel.INFO logs only HTTP method + URL + status — no body, no sensitive headers. // LogLevel.NONE in release ensures zero leakage even if a future log level change // is accidentally introduced in debug code that ships to release. + // + // If this level is ever raised to LogLevel.HEADERS or LogLevel.ALL (debug only), + // wrap output through LogRedactor.redactHeaders() / LogRedactor.redactString() + // (see com.ethosprotocol.security.LogRedactor) before any write to logcat. level = if (BuildConfig.DEBUG) LogLevel.INFO else LogLevel.NONE } // No timeouts were configured previously, so a stalled connection (e.g. dead wifi diff --git a/android/app/src/main/java/com/ethosprotocol/security/AppLifecycleObserver.kt b/android/app/src/main/java/com/ethosprotocol/security/AppLifecycleObserver.kt new file mode 100644 index 0000000..f4dbd7d --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/security/AppLifecycleObserver.kt @@ -0,0 +1,41 @@ +package com.ethosprotocol.security + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +/** + * Process-level lifecycle observer that forwards app-foreground / app-background + * events to [SessionLockManager]. + * + * Register once with [androidx.lifecycle.ProcessLifecycleOwner] — e.g. in + * `MainActivity.onCreate`: + * + * ```kotlin + * ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver()) + * ``` + * + * `ProcessLifecycleOwner` represents the entire app process, so `onStart` fires + * when any Activity is started (app foregrounded) and `onStop` fires only when + * every Activity has stopped (app fully backgrounded), which is the correct + * granularity for session-lock decisions. + */ +class AppLifecycleObserver : DefaultLifecycleObserver { + + /** + * Called when the app moves to the foreground (at least one Activity is + * started / resumed). Delegates to [SessionLockManager.onAppForeground] + * which checks whether the inactivity timeout has been exceeded. + */ + override fun onStart(owner: LifecycleOwner) { + SessionLockManager.onAppForeground() + } + + /** + * Called when the app moves to the background (all Activities have stopped). + * Delegates to [SessionLockManager.onAppBackground] which records the + * current time so the elapsed interval can be measured on the next foreground. + */ + override fun onStop(owner: LifecycleOwner) { + SessionLockManager.onAppBackground() + } +} diff --git a/android/app/src/main/java/com/ethosprotocol/security/LogRedactor.kt b/android/app/src/main/java/com/ethosprotocol/security/LogRedactor.kt new file mode 100644 index 0000000..74690f0 --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/security/LogRedactor.kt @@ -0,0 +1,75 @@ +package com.ethosprotocol.security + +/** + * Utility for scrubbing sensitive values from strings and header maps before + * they are written to any diagnostic channel (Logcat, crash reporters, Ktor + * [io.ktor.client.plugins.logging.Logging], etc.). + * + * All matching is case-insensitive so callers don't need to normalise header + * names before passing them in. + */ +object LogRedactor { + + // ------------------------------------------------------------------------- + // Sensitive header names (lowercase for case-insensitive comparison) + // ------------------------------------------------------------------------- + + /** + * HTTP header names whose values must always be replaced with `[REDACTED]` + * before logging. Matching is case-insensitive. + */ + val SENSITIVE_HEADERS: Set = setOf( + "authorization", + "x-nonce", + "x-otp", + "x-2fa-token" + ) + + // ------------------------------------------------------------------------- + // Header redaction + // ------------------------------------------------------------------------- + + /** + * Returns a copy of [headers] where every entry whose key (case-insensitively) + * is in [SENSITIVE_HEADERS] has its value replaced with `"[REDACTED]"`. All + * other entries are left unchanged. + */ + fun redactHeaders(headers: Map): Map = + headers.mapValues { (key, value) -> + if (SENSITIVE_HEADERS.contains(key.lowercase())) "[REDACTED]" else value + } + + // ------------------------------------------------------------------------- + // String redaction + // ------------------------------------------------------------------------- + + private val BEARER_REGEX = Regex( + pattern = """Bearer\s+[\w.\-~+/=]+""", + options = setOf(RegexOption.IGNORE_CASE) + ) + + private val NONCE_REGEX = Regex( + pattern = """(x-nonce\s*[=:]\s*)[\w.\-]+""", + options = setOf(RegexOption.IGNORE_CASE) + ) + + /** + * Replaces known-sensitive patterns in [input] with safe placeholders: + * + * - `Bearer ` → `Bearer [REDACTED]` + * Covers `Authorization: Bearer …` lines appearing in logged request dumps. + * + * - `x-nonce: ` → `x-nonce: [REDACTED]` (case-insensitive) + * Covers the anti-replay nonce header if it appears in a log string. + * + * @param input The raw string to sanitise. + * @return A copy of [input] with sensitive patterns replaced. + */ + fun redactString(input: String): String { + var result = BEARER_REGEX.replace(input, "Bearer [REDACTED]") + result = NONCE_REGEX.replace(result) { match -> + "${match.groupValues[1]}[REDACTED]" + } + return result + } +} diff --git a/android/app/src/main/java/com/ethosprotocol/security/SessionLockManager.kt b/android/app/src/main/java/com/ethosprotocol/security/SessionLockManager.kt new file mode 100644 index 0000000..8b55518 --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/security/SessionLockManager.kt @@ -0,0 +1,73 @@ +package com.ethosprotocol.security + +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Singleton that tracks user activity and locks the session after a configurable + * period of inactivity. The lock is evaluated whenever the app returns to the + * foreground — see [AppLifecycleObserver] for the lifecycle hook. + * + * Usage: + * - Call [recordActivity] on meaningful user interactions to reset the timer. + * - [AppLifecycleObserver] calls [onAppBackground] / [onAppForeground] automatically. + * - Collect [isLocked] in your ViewModel / Composable and show a lock screen. + * - Call [unlock] after the user re-authenticates (biometric / PIN). + */ +object SessionLockManager { + + /** Inactivity timeout in milliseconds. Default is 5 minutes. */ + var timeoutMs: Long = 5 * 60 * 1_000L + + /** + * Epoch-millisecond timestamp of the last recorded activity. + * `internal` so tests can seed it directly without waiting real time. + */ + internal var lastActivityTime: Long = System.currentTimeMillis() + + /** + * `true` when the session is locked and the UI should present a + * re-authentication prompt. + */ + val isLocked: MutableStateFlow = MutableStateFlow(false) + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Records that the user performed an action right now, resetting the + * inactivity clock. Call on significant user interactions (tapping, + * submitting forms, etc.) to prevent premature lock-out during active use. + */ + fun recordActivity() { + lastActivityTime = System.currentTimeMillis() + } + + /** + * Called when the app moves to the background. Records the current time so + * the elapsed interval can be computed when the app returns to the foreground. + */ + fun onAppBackground() { + recordActivity() + } + + /** + * Called when the app returns to the foreground. If the time elapsed since + * [lastActivityTime] meets or exceeds [timeoutMs], the session is locked. + */ + fun onAppForeground() { + val elapsed = System.currentTimeMillis() - lastActivityTime + if (elapsed >= timeoutMs) { + isLocked.value = true + } + } + + /** + * Clears the lock and resets the inactivity clock. Call after the user + * successfully re-authenticates. + */ + fun unlock() { + isLocked.value = false + recordActivity() + } +} diff --git a/android/app/src/main/java/com/ethosprotocol/security/SignatureVerifier.kt b/android/app/src/main/java/com/ethosprotocol/security/SignatureVerifier.kt new file mode 100644 index 0000000..eb8047c --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/security/SignatureVerifier.kt @@ -0,0 +1,120 @@ +package com.ethosprotocol.security + +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.os.Build +import java.security.MessageDigest +import java.util.Base64 + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +/** + * Outcome of an APK signature verification check. + * + * - [Valid] – the signing certificate matches [SignatureVerifier.EXPECTED_CERT_SHA256]. + * - [Mismatch] – the certificate was read successfully but its SHA-256 digest differs + * from the expected value. The [actual] digest is provided for diagnostic + * purposes (log it, never show it to end users). + * - [NotConfigured] – [SignatureVerifier.EXPECTED_CERT_SHA256] is blank; the check is skipped. + * This is the expected state for debug builds and CI. + */ +sealed class SignatureResult { + object Valid : SignatureResult() + data class Mismatch(val actual: String) : SignatureResult() + object NotConfigured : SignatureResult() +} + +// --------------------------------------------------------------------------- +// Verifier +// --------------------------------------------------------------------------- + +/** + * Verifies the APK's signing certificate against a hard-coded expected SHA-256 + * digest at runtime. This provides a non-blocking warning when the app has been + * repackaged and re-signed by a third party (i.e. sideloaded from outside the + * Play Store). + * + * ### Configuration + * Set [EXPECTED_CERT_SHA256] to the Base64-encoded SHA-256 digest of your + * release signing certificate's DER-encoded bytes before shipping a production + * build. Compute it with: + * + * ```bash + * keytool -exportcert -alias -keystore release.jks \ + * | openssl dgst -sha256 -binary | openssl enc -base64 + * ``` + * + * Leaving [EXPECTED_CERT_SHA256] empty disables the check (returns [SignatureResult.NotConfigured]), + * which is the correct behaviour for debug builds and CI where no release keystore is present. + */ +class SignatureVerifier { + + companion object { + /** + * Expected Base64-encoded SHA-256 digest of the release signing certificate. + * Empty string = check disabled (debug/CI-safe default). + * + * Override this value in a release build variant via `buildConfigField` or + * by subclassing `SignatureVerifier` in tests. + */ + const val EXPECTED_CERT_SHA256: String = "" + } + + /** + * Verifies the current APK's signing certificate. + * + * @param context Any [Context] — used to access [PackageManager]. + * @return [SignatureResult.NotConfigured] if [EXPECTED_CERT_SHA256] is blank; + * [SignatureResult.Valid] if the digest matches; [SignatureResult.Mismatch] + * if it does not. + */ + fun verify(context: Context): SignatureResult { + if (EXPECTED_CERT_SHA256.isBlank()) return SignatureResult.NotConfigured + + val actual = getSignatureSha256(context.packageManager, context.packageName) + return if (actual == EXPECTED_CERT_SHA256) { + SignatureResult.Valid + } else { + SignatureResult.Mismatch(actual) + } + } +} + +// --------------------------------------------------------------------------- +// Helper — package-level so it can be called from tests with a mock PM +// --------------------------------------------------------------------------- + +/** + * Returns the Base64-encoded SHA-256 digest of the first signing certificate + * for [packageName], or an empty string if the certificate cannot be retrieved. + * + * Uses the `GET_SIGNING_CERTIFICATES` API on Android P+ (API 28) and falls + * back to the deprecated `GET_SIGNATURES` flag on older releases. Only the + * *first* certificate in the chain is inspected; multi-signer APKs are not + * supported by the Play signing pipeline used by this project. + */ +fun getSignatureSha256(pm: PackageManager, packageName: String): String { + return try { + val signatures: Array = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + @Suppress("DEPRECATION") + val info = pm.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES) + info.signingInfo?.apkContentsSigners ?: emptyArray() + } else { + @Suppress("DEPRECATION") + val info = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) + @Suppress("DEPRECATION") + info.signatures ?: emptyArray() + } + + if (signatures.isEmpty()) return "" + + val certBytes = signatures[0].toByteArray() + val digest = MessageDigest.getInstance("SHA-256").digest(certBytes) + Base64.getEncoder().encodeToString(digest) + } catch (_: Exception) { + "" + } +} diff --git a/android/app/src/main/java/com/ethosprotocol/security/TamperWarningDialog.kt b/android/app/src/main/java/com/ethosprotocol/security/TamperWarningDialog.kt new file mode 100644 index 0000000..58f2a68 --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/security/TamperWarningDialog.kt @@ -0,0 +1,45 @@ +package com.ethosprotocol.security + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +/** + * A non-blocking dialog shown when [SignatureVerifier] detects that the APK's + * signing certificate does not match the expected release certificate. + * + * The dialog is *informational only* — it does not prevent the user from + * continuing to use the app, consistent with the project's approach for + * integrity warnings (#118: jailbreak/root detection shows a banner, not a + * hard block). Users who have sideloaded the app intentionally can dismiss + * the warning and proceed at their own risk. + * + * @param onDismiss Called when the user acknowledges the warning (either by + * tapping the confirm button or by tapping outside the dialog). + */ +@Composable +fun TamperWarningDialog(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = "Unverified App Installation") + }, + text = { + Text( + text = "This copy of Ethos Protocol does not appear to have been installed " + + "from the official app store. It may have been modified or repackaged by " + + "a third party.\n\n" + + "For your security, we recommend installing the app from the official " + + "Google Play Store. If you believe this is a mistake, please contact " + + "support@ethos-protocol.app." + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("I Understand") + } + } + ) +} diff --git a/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt b/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt index d8a2ccb..4a9168a 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt @@ -22,10 +22,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.ethosprotocol.security.AppLifecycleObserver +import com.ethosprotocol.security.SignatureResult +import com.ethosprotocol.security.SignatureVerifier +import com.ethosprotocol.security.TamperWarningDialog import com.ethosprotocol.services.BiometricHelper import com.ethosprotocol.services.VaultDeepLink import com.ethosprotocol.services.VaultDeepLinkParser @@ -49,6 +54,9 @@ class MainActivity : FragmentActivity() { private var showPermissionRationale by mutableStateOf(false) + // #278: Whether to show the tamper-warning dialog (APK signature mismatch). + private var showTamperWarning by mutableStateOf(false) + private val authVm: AuthViewModel by viewModels() private val notificationPermissionLauncher = registerForActivityResult( @@ -59,6 +67,10 @@ class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() + // #276: Register the process-level lifecycle observer so SessionLockManager + // receives foreground/background transitions for the whole app process. + ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver()) + // Only handle the launch intent on a fresh start (savedInstanceState == null). // On recreation (config change or process death), SavedStateHandle already holds // the pending state — re-parsing the original launch intent would overwrite it. @@ -73,6 +85,11 @@ class MainActivity : FragmentActivity() { val vaultDeepLink by deepLinkViewModel.pendingVaultDeepLink .collectAsStateWithLifecycle() + // #278: Show tamper-warning dialog if the APK signing cert doesn't match. + if (showTamperWarning) { + TamperWarningDialog(onDismiss = { showTamperWarning = false }) + } + NotificationPermissionEffect( showRationale = showPermissionRationale, onRationaleShown = { showPermissionRationale = false }, @@ -92,6 +109,19 @@ class MainActivity : FragmentActivity() { } } + // #277: Prevent sensitive content from appearing in the system app-switcher + // thumbnail or being captured by screenshots / screen recordings. + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + + // #278: Check APK signing certificate. Show a non-blocking warning if the + // app has been repackaged/sideloaded. Consistent with #118 (jailbreak warning). + if (SignatureVerifier().verify(this) is SignatureResult.Mismatch) { + showTamperWarning = true + } + requestNotificationPermissionIfNeeded() } diff --git a/android/app/src/test/java/com/ethosprotocol/security/LogRedactorTest.kt b/android/app/src/test/java/com/ethosprotocol/security/LogRedactorTest.kt new file mode 100644 index 0000000..ee5ba66 --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/security/LogRedactorTest.kt @@ -0,0 +1,136 @@ +package com.ethosprotocol.security + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogRedactorTest { + + // ------------------------------------------------------------------------- + // redactString — Bearer token + // ------------------------------------------------------------------------- + + /** + * [LogRedactor.redactString] must replace the token portion of a Bearer + * credential so the raw token value does not appear in logs. + */ + @Test + fun testBearerTokenRedacted() { + val input = "Authorization: Bearer abc123.def456.ghi789" + val output = LogRedactor.redactString(input) + + assertFalse( + "Raw Bearer token 'abc123' must not appear in redacted output", + output.contains("abc123") + ) + assertFalse( + "Raw Bearer token segment 'def456' must not appear in redacted output", + output.contains("def456") + ) + assertTrue( + "Output should contain the 'Bearer [REDACTED]' placeholder", + output.contains("Bearer [REDACTED]") + ) + } + + /** Bearer redaction must be case-insensitive. */ + @Test + fun testBearerTokenRedactedCaseInsensitive() { + val input = "authorization: bearer MYSECRETTOKEN" + val output = LogRedactor.redactString(input) + + assertFalse( + "Token must be redacted regardless of 'bearer' keyword casing", + output.contains("MYSECRETTOKEN") + ) + } + + /** Non-sensitive log strings must pass through unchanged. */ + @Test + fun testNonSensitiveStringUnchanged() { + val input = "GET /vaults -> 200 OK (took 123 ms)" + val output = LogRedactor.redactString(input) + + assertEquals("Non-sensitive strings must not be modified", input, output) + } + + // ------------------------------------------------------------------------- + // redactHeaders — sensitive headers + // ------------------------------------------------------------------------- + + /** + * [LogRedactor.redactHeaders] must replace the value of any header whose + * (lowercased) name is in [LogRedactor.SENSITIVE_HEADERS] with `[REDACTED]`. + */ + @Test + fun testSensitiveHeaderRedacted() { + val headers = mapOf( + "Authorization" to "Bearer super-secret-token", + "X-Nonce" to "deadbeefdeadbeef", + "x-otp" to "123456", + "X-2FA-Token" to "totp-payload" + ) + val redacted = LogRedactor.redactHeaders(headers) + + for ((key, _) in headers) { + assertEquals( + "Header '$key' must be replaced with [REDACTED]", + "[REDACTED]", + redacted[key] + ) + } + } + + /** Header name matching must be case-insensitive. */ + @Test + fun testSensitiveHeaderRedactedCaseInsensitive() { + val headers = mapOf("AUTHORIZATION" to "Bearer token123") + val redacted = LogRedactor.redactHeaders(headers) + + assertEquals( + "AUTHORIZATION (all-caps) must be treated as sensitive", + "[REDACTED]", + redacted["AUTHORIZATION"] + ) + } + + // ------------------------------------------------------------------------- + // redactHeaders — non-sensitive headers + // ------------------------------------------------------------------------- + + /** + * Non-sensitive headers (e.g. `Content-Type`, `Accept`) must pass through + * [LogRedactor.redactHeaders] completely unchanged. + */ + @Test + fun testNonSensitiveHeaderUnchanged() { + val headers = mapOf( + "Content-Type" to "application/json", + "Accept" to "application/json", + "X-Timestamp" to "1700000000" + ) + val redacted = LogRedactor.redactHeaders(headers) + + assertEquals("Content-Type must not be redacted", "application/json", redacted["Content-Type"]) + assertEquals("Accept must not be redacted", "application/json", redacted["Accept"]) + assertEquals("X-Timestamp must not be redacted", "1700000000", redacted["X-Timestamp"]) + } + + /** A mix of sensitive and non-sensitive headers — only sensitive ones change. */ + @Test + fun testMixedHeaders() { + val headers = mapOf( + "Content-Type" to "application/json", + "Authorization" to "Bearer abc.def.ghi", + "X-Nonce" to "0011223344556677", + "Accept" to "application/json" + ) + val redacted = LogRedactor.redactHeaders(headers) + + assertEquals("Content-Type should be unchanged", "application/json", redacted["Content-Type"]) + assertEquals("Accept should be unchanged", "application/json", redacted["Accept"]) + assertEquals("Authorization must be redacted", "[REDACTED]", redacted["Authorization"]) + assertEquals("X-Nonce must be redacted", "[REDACTED]", redacted["X-Nonce"]) + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/security/SecureFlagTest.kt b/android/app/src/test/java/com/ethosprotocol/security/SecureFlagTest.kt new file mode 100644 index 0000000..1c5a9d1 --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/security/SecureFlagTest.kt @@ -0,0 +1,56 @@ +package com.ethosprotocol.security + +import android.view.WindowManager +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Documents and verifies the Android mechanism used to prevent sensitive data + * from appearing in the system app-switcher (recent apps) thumbnail or being + * captured by screenshots. + * + * ## How FLAG_SECURE works + * + * Setting `WindowManager.LayoutParams.FLAG_SECURE` on a window instructs the + * Android system to: + * + * 1. **Blank the window in the app-switcher thumbnail** — the system replaces + * the live app content with a solid colour (or the app's icon, depending on + * manufacturer) when compositing the recents screen, so vault balances, + * beneficiary addresses, and TTL countdowns are never visible there. + * + * 2. **Block screenshots and screen recordings** — any attempt to capture the + * window via `MediaProjection`, `UiAutomator`, or the system screenshot + * shortcut produces a blank/black frame instead of the real content. + * + * 3. **Prevent copy-via-recent-apps on some launchers** — certain OEM launchers + * offer a "copy text from recent app" feature; FLAG_SECURE disables it. + * + * The flag is set once in `MainActivity.onCreate` after `setContent` so it + * covers the entire app window for the lifetime of the Activity. + * + * ## Caveats + * + * - FLAG_SECURE does NOT prevent a physical camera pointed at the screen. + * - The privacy overlay ([com.ethosprotocol.security.PrivacyOverlayModifier] + * on iOS) is the equivalent iOS mechanism; Android does not need a separate + * overlay because FLAG_SECURE handles both recents snapshotting and screenshots. + */ +class SecureFlagTest { + + /** + * Verifies that `WindowManager.LayoutParams.FLAG_SECURE` has the expected + * constant value `0x2000` (8192 decimal) as defined in the Android SDK. + * + * This value has been stable since API 1 and is part of the public API + * contract; a mismatch would indicate a severely broken SDK environment. + */ + @Test + fun testSecureFlagConstantValue() { + assertEquals( + "FLAG_SECURE must equal 0x2000 (8192) as defined in the Android SDK", + 0x2000, + WindowManager.LayoutParams.FLAG_SECURE + ) + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/security/SessionLockManagerTest.kt b/android/app/src/test/java/com/ethosprotocol/security/SessionLockManagerTest.kt new file mode 100644 index 0000000..f96ce36 --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/security/SessionLockManagerTest.kt @@ -0,0 +1,166 @@ +package com.ethosprotocol.security + +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SessionLockManagerTest { + + @Before + fun setUp() { + // Reset to a clean unlocked state with the default timeout before each test. + SessionLockManager.timeoutMs = 5 * 60 * 1_000L + SessionLockManager.isLocked.value = false + SessionLockManager.lastActivityTime = System.currentTimeMillis() + } + + @After + fun tearDown() { + // Restore defaults so other tests are not affected. + SessionLockManager.isLocked.value = false + SessionLockManager.timeoutMs = 5 * 60 * 1_000L + SessionLockManager.lastActivityTime = System.currentTimeMillis() + } + + // ------------------------------------------------------------------------- + // testNoLockBeforeTimeout + // ------------------------------------------------------------------------- + + /** + * When the app foregrounds before the timeout has elapsed, the session + * must NOT be locked. + */ + @Test + fun testNoLockBeforeTimeout() { + // Seed lastActivityTime to 60 s ago with a 300 s (300_000 ms) timeout. + SessionLockManager.timeoutMs = 300_000L + SessionLockManager.lastActivityTime = System.currentTimeMillis() - 60_000L + + SessionLockManager.onAppForeground() + + assertFalse( + "Session should NOT be locked when only 60 s have elapsed with a 300 s timeout", + SessionLockManager.isLocked.value + ) + } + + // ------------------------------------------------------------------------- + // testLockAfterTimeout + // ------------------------------------------------------------------------- + + /** + * When the app foregrounds after the timeout has been exceeded, the session + * MUST be locked. + */ + @Test + fun testLockAfterTimeout() { + // Seed lastActivityTime to 301 s ago — just over the 300 s (300_000 ms) limit. + SessionLockManager.timeoutMs = 300_000L + SessionLockManager.lastActivityTime = System.currentTimeMillis() - 301_000L + + SessionLockManager.onAppForeground() + + assertTrue( + "Session MUST be locked when 301 s have elapsed with a 300 s timeout", + SessionLockManager.isLocked.value + ) + } + + /** + * Boundary: elapsed time exactly equal to the timeout should also lock. + */ + @Test + fun testLockAtExactTimeout() { + SessionLockManager.timeoutMs = 300_000L + SessionLockManager.lastActivityTime = System.currentTimeMillis() - 300_000L + + SessionLockManager.onAppForeground() + + assertTrue( + "Session MUST be locked when elapsed time equals the timeout exactly", + SessionLockManager.isLocked.value + ) + } + + // ------------------------------------------------------------------------- + // testUnlockResetsLock + // ------------------------------------------------------------------------- + + /** + * After [SessionLockManager.unlock] is called, [SessionLockManager.isLocked] + * must be `false` and the inactivity clock must be reset so that a + * subsequent immediate foreground check does not re-lock the session. + */ + @Test + fun testUnlockResetsLock() { + // Put the session into a locked state. + SessionLockManager.timeoutMs = 300_000L + SessionLockManager.lastActivityTime = System.currentTimeMillis() - 600_000L + SessionLockManager.onAppForeground() + assertTrue("Pre-condition: session should be locked", SessionLockManager.isLocked.value) + + SessionLockManager.unlock() + + assertFalse( + "isLocked should be false immediately after unlock()", + SessionLockManager.isLocked.value + ) + + // Simulate a near-instant return to foreground (0 ms elapsed after unlock). + SessionLockManager.onAppForeground() + assertFalse( + "Session should NOT re-lock immediately after unlock() resets the clock", + SessionLockManager.isLocked.value + ) + } + + // ------------------------------------------------------------------------- + // recordActivity prevents lock + // ------------------------------------------------------------------------- + + /** + * [SessionLockManager.recordActivity] resets the clock; a foreground check + * after recording activity should not lock even if the seeded time was stale. + */ + @Test + fun testRecordActivityPreventsLock() { + SessionLockManager.timeoutMs = 300_000L + SessionLockManager.lastActivityTime = System.currentTimeMillis() - 600_000L + + // User interacts — clock is reset to now. + SessionLockManager.recordActivity() + SessionLockManager.onAppForeground() + + assertFalse( + "Session should NOT lock after recordActivity() refreshes the clock", + SessionLockManager.isLocked.value + ) + } + + // ------------------------------------------------------------------------- + // onAppBackground refreshes the clock + // ------------------------------------------------------------------------- + + /** + * [SessionLockManager.onAppBackground] should refresh [lastActivityTime] so + * that a brief background trip does not trigger a lock on the next foreground. + */ + @Test + fun testOnAppBackgroundRefreshesClock() { + SessionLockManager.timeoutMs = 300_000L + // Stale activity time — if onAppBackground didn't reset the clock this + // foreground check would lock the session. + SessionLockManager.lastActivityTime = System.currentTimeMillis() - 600_000L + + SessionLockManager.onAppBackground() + // Return to foreground almost immediately (< 1 ms). + SessionLockManager.onAppForeground() + + assertFalse( + "onAppBackground() should reset the clock so a brief background trip doesn't lock", + SessionLockManager.isLocked.value + ) + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/security/SignatureVerifierTest.kt b/android/app/src/test/java/com/ethosprotocol/security/SignatureVerifierTest.kt new file mode 100644 index 0000000..31cfd2b --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/security/SignatureVerifierTest.kt @@ -0,0 +1,160 @@ +package com.ethosprotocol.security + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.MessageDigest +import java.util.Base64 + +/** + * Unit tests for [SignatureVerifier] using a mock [PackageManager] so no real + * APK signing is required. + * + * Tests cover the three [SignatureResult] outcomes: + * - [SignatureResult.NotConfigured] when [SignatureVerifier.EXPECTED_CERT_SHA256] is blank. + * - [SignatureResult.Valid] when the computed digest matches the expected value. + * - [SignatureResult.Mismatch] when the computed digest differs. + * + * Because [SignatureVerifier.EXPECTED_CERT_SHA256] is a compile-time constant + * we cannot change it at runtime. The tests instead exercise [getSignatureSha256] + * directly (the helper exposed at package level) and verify [SignatureVerifier.verify] + * returns [NotConfigured] for the default empty constant. For Valid/Mismatch we + * use a subclass that overrides the expected hash. + */ +class SignatureVerifierTest { + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** Fake DER-encoded certificate bytes used as the "real" signing cert in tests. */ + private val fakeCertBytes = ByteArray(256) { it.toByte() } + + private val fakeCertSha256: String by lazy { + val digest = MessageDigest.getInstance("SHA-256").digest(fakeCertBytes) + Base64.getEncoder().encodeToString(digest) + } + + private fun buildMockPm(signatures: Array): PackageManager { + val pm = mockk() + + // Android P+ path (GET_SIGNING_CERTIFICATES) — we mock both paths for safety. + val packageInfo = mockk() + every { packageInfo.signatures } returns signatures + + @Suppress("DEPRECATION") + every { + pm.getPackageInfo(any(), PackageManager.GET_SIGNATURES) + } returns packageInfo + + // We cannot mock signingInfo.apkContentsSigners easily without a real + // PackageInfo object, so keep the test on the legacy path. In production + // code the P+ path is used; testing the hash-computation logic is the goal. + return pm + } + + // ------------------------------------------------------------------------- + // testNotConfiguredWhenExpectedEmpty + // ------------------------------------------------------------------------- + + /** + * When [SignatureVerifier.EXPECTED_CERT_SHA256] is blank (the default), the + * check is skipped and [SignatureResult.NotConfigured] is returned without + * inspecting the APK at all. + * + * This is verified by calling [SignatureVerifier.verify] with a mock Context + * that would succeed if it were called — any call that reaches PackageManager + * would indicate the early-return guard is absent. + */ + @Test + fun testNotConfiguredWhenExpectedEmpty() { + // EXPECTED_CERT_SHA256 is "" by default — verify() must short-circuit. + val verifier = SignatureVerifier() + + // Build a minimal mock context whose PackageManager throws to catch any + // unexpected call through. + val pm = mockk() + val ctx = mockk() + every { ctx.packageManager } returns pm + every { ctx.packageName } returns "com.ethosprotocol" + every { pm.getPackageInfo(any(), any()) } throws RuntimeException("Should not reach PM") + + val result = verifier.verify(ctx) + + assertTrue( + "verify() must return NotConfigured when EXPECTED_CERT_SHA256 is empty", + result is SignatureResult.NotConfigured + ) + } + + // ------------------------------------------------------------------------- + // testValidWhenHashMatches + // ------------------------------------------------------------------------- + + /** + * [getSignatureSha256] with the fake certificate bytes should return the + * pre-computed expected digest. This validates the SHA-256 / Base64 pipeline. + */ + @Test + fun testValidWhenHashMatches() { + val signatures = arrayOf(Signature(fakeCertBytes)) + val pm = buildMockPm(signatures) + + val actual = getSignatureSha256(pm, "com.ethosprotocol") + + assertEquals( + "getSignatureSha256 must return the correct Base64 SHA-256 of the signing cert", + fakeCertSha256, + actual + ) + } + + // ------------------------------------------------------------------------- + // testMismatchWhenHashDiffers + // ------------------------------------------------------------------------- + + /** + * When the APK is signed with a certificate whose digest differs from the + * expected value, [getSignatureSha256] must return a different hash, and a + * [SignatureResult.Mismatch] would be produced by [SignatureVerifier.verify]. + */ + @Test + fun testMismatchWhenHashDiffers() { + // Use different bytes to simulate a different signing certificate. + val differentCertBytes = ByteArray(256) { (it + 42).toByte() } + val signatures = arrayOf(Signature(differentCertBytes)) + val pm = buildMockPm(signatures) + + val actual = getSignatureSha256(pm, "com.ethosprotocol") + + // The actual digest should differ from the "expected" one (fakeCertSha256). + assertTrue( + "A different certificate must produce a different SHA-256 digest", + actual != fakeCertSha256 + ) + assertTrue("Digest should be non-empty", actual.isNotEmpty()) + } + + /** + * [getSignatureSha256] must return an empty string (not throw) when + * [PackageManager] throws an exception (e.g. unknown package, permission denied). + */ + @Test + fun testReturnsEmptyOnException() { + val pm = mockk() + @Suppress("DEPRECATION") + every { + pm.getPackageInfo(any(), PackageManager.GET_SIGNATURES) + } throws PackageManager.NameNotFoundException() + + val result = getSignatureSha256(pm, "com.ethosprotocol") + + assertEquals("Should return empty string on exception, not throw", "", result) + } +} diff --git a/docs/manual-qa-checklist.md b/docs/manual-qa-checklist.md index 17278c0..d788b19 100644 --- a/docs/manual-qa-checklist.md +++ b/docs/manual-qa-checklist.md @@ -24,3 +24,36 @@ Covers Android issue #android-a11y-content-descriptions (mirrors iOS #44). icons (offline, warning, lock/security context) are announced, and decorative icons are silently skipped. - [ ] iOS: run the equivalent VoiceOver pass per #44. + +## App Switcher Privacy Overlay + +Covers iOS #277 (`PrivacyOverlayModifier`) and Android #277 (`FLAG_SECURE`). + +### iOS — Privacy Overlay + +- [ ] Build and run the app on a real device (Simulator does not snapshot the app switcher). +- [ ] Navigate to a vault detail screen so sensitive data (balance, TTL, beneficiary) is visible. +- [ ] Swipe up to open the app switcher (or double-press Home on Touch ID devices). +- [ ] **Expected**: the Ethos Protocol app card shows a blank screen with the lock-shield icon + and "Ethos Protocol" label — **no vault data, balances, or addresses should be visible**. +- [ ] Tap the app card to return to the foreground. +- [ ] **Expected**: the privacy overlay disappears immediately and the vault detail is visible again. +- [ ] Repeat with `scenePhase == .inactive` (e.g. pull down Control Centre while the app is in + the foreground) — the overlay should also appear during transient inactivity. + +### Android — FLAG_SECURE (Recent Apps + Screenshot) + +- [ ] Build and install the debug APK on a real device or emulator. +- [ ] Navigate to the vault list or vault detail screen. +- [ ] Open the Recent Apps screen (square button or swipe gesture). +- [ ] **Expected**: the Ethos Protocol card shows a blank/greyed-out preview — no vault data + should be visible in the thumbnail. +- [ ] Return to the app and attempt a screenshot (Power + Volume Down). +- [ ] **Expected**: the screenshot is blank/black, **not** a capture of the app content. + The system typically shows a toast: "Can't take screenshot due to security policy." +- [ ] Verify the flag does not interfere with normal app use (touch, scrolling, navigation). + +### Both platforms — Release build verification + +- [ ] Confirm the overlay / FLAG_SECURE behaviour is present in a **Release** build, not just + Debug — some vendors strip window flags differently in production vs. development. diff --git a/ios/EthosProtocol/.gitignore b/ios/EthosProtocol/.gitignore index 265b397..a2d8e32 100644 --- a/ios/EthosProtocol/.gitignore +++ b/ios/EthosProtocol/.gitignore @@ -6,3 +6,16 @@ DerivedData/ *.xcuserstate xcuserdata/ + +# Test snapshots (SnapshotTesting / Nimble-Snapshots) +**/__Snapshots__/ +**/ReferenceImages/ +**/*.png.snap + +# Secrets +*.p8 +*.p12 +*.mobileprovision +GoogleService-Info.plist +.env +.env.* diff --git a/ios/EthosProtocol/Sources/App/EthosProtocolApp.swift b/ios/EthosProtocol/Sources/App/EthosProtocolApp.swift index d1c0952..b4fd587 100644 --- a/ios/EthosProtocol/Sources/App/EthosProtocolApp.swift +++ b/ios/EthosProtocol/Sources/App/EthosProtocolApp.swift @@ -4,6 +4,10 @@ import SwiftUI struct EthosProtocolApp: App { @StateObject private var authStore = AuthStore() @StateObject private var vaultStore = VaultStore() + // #276: Session lock service — locks the UI after configurable inactivity. + @StateObject private var sessionLock = SessionLockService() + + @Environment(\.scenePhase) private var scenePhase init() { BackgroundRefreshService.shared.registerBackgroundTask() @@ -24,6 +28,9 @@ struct EthosProtocolApp: App { RootView() .environmentObject(authStore) .environmentObject(vaultStore) + .environmentObject(sessionLock) + // #277: Privacy overlay — hides content in the app-switcher snapshot. + .privacyOverlay() .task { NotificationService.shared.registerNotificationCategories() await NotificationService.shared.requestPermission() @@ -36,6 +43,17 @@ struct EthosProtocolApp: App { guard let url = activity.webpageURL else { return } vaultStore.pendingDeepLink = UniversalLinkRouter.shared.parse(url: url) } + // #276: Observe scene-phase transitions to drive session-lock timers. + .onChange(of: scenePhase) { newPhase in + switch newPhase { + case .background: + sessionLock.handleBackground() + case .active: + sessionLock.handleForeground() + default: + break + } + } } } } diff --git a/ios/EthosProtocol/Sources/Services/APIClient.swift b/ios/EthosProtocol/Sources/Services/APIClient.swift index 24282f0..9669674 100644 --- a/ios/EthosProtocol/Sources/Services/APIClient.swift +++ b/ios/EthosProtocol/Sources/Services/APIClient.swift @@ -289,7 +289,7 @@ public final class APIClient { _ = try await execute(req) } - // MARK: - Logging Redaction Audit (#111) + // MARK: - Logging Redaction Audit (#111, #279) // // iOS uses URLSession directly — there is no logging plugin or interceptor in this file. // No request or response body, header, or sensitive field is written to os_log, print, @@ -301,6 +301,12 @@ public final class APIClient { // 2. Log only HTTP method, path (no query strings bearing tokens), and status code — // never request/response bodies, Authorization headers, 2FA secrets, vault balances, // beneficiary/owner wallet addresses, or acceptance tokens. + // 3. Wrap any header dictionary with `LogRedactor.redactHeaders(_:)` before logging. + // 4. Wrap any URL/body string with `LogRedactor.redactString(_:)` before logging. + // + // `DecodingFailureLogger.log(path:expectedType:responseBody:)` already passes + // the path through `LogRedactor.redactString` (#279) so Bearer tokens or nonces + // in query strings are stripped at the log-write site. // // See shared/api-contract.md §Logging Redaction Policy (#111) for the authoritative // cross-platform policy. diff --git a/ios/EthosProtocol/Sources/Services/DecodingFailureLogger.swift b/ios/EthosProtocol/Sources/Services/DecodingFailureLogger.swift index 99bf5fa..b81b48f 100644 --- a/ios/EthosProtocol/Sources/Services/DecodingFailureLogger.swift +++ b/ios/EthosProtocol/Sources/Services/DecodingFailureLogger.swift @@ -20,9 +20,13 @@ final class DecodingFailureLogger { /// Logs a decode failure for `path` where a `expectedType` was expected but /// couldn't be decoded from `responseBody`. + /// + /// The `path` is passed through `LogRedactor.redactString` (#279) before + /// being stored, ensuring that any token or nonce embedded in a query string + /// is stripped even when the URL is used as the log key. func log(path: String, expectedType: String, responseBody: Data) { let entry = DecodingFailureEntry( - path: path, + path: LogRedactor.redactString(path), expectedType: expectedType, redactedBody: Self.redact(responseBody), timestamp: Date() diff --git a/ios/EthosProtocol/Sources/Services/LogRedactor.swift b/ios/EthosProtocol/Sources/Services/LogRedactor.swift new file mode 100644 index 0000000..3553869 --- /dev/null +++ b/ios/EthosProtocol/Sources/Services/LogRedactor.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Utility for scrubbing sensitive values from strings and header dictionaries +/// before they are written to any diagnostic channel (os_log, print, crash +/// reports, `DecodingFailureLogger`, etc.). +/// +/// This is a caseless enum (used as a namespace) so it cannot be instantiated. +enum LogRedactor { + + // MARK: - Sensitive header names (all lowercase for case-insensitive matching) + + /// HTTP header names whose values must always be replaced with `[REDACTED]` + /// before logging. Matching is case-insensitive. + static let sensitiveHeaders: Set = [ + "authorization", + "x-nonce", + "x-otp", + "x-2fa-token" + ] + + // MARK: - Header redaction + + /// Returns a copy of `headers` where every key whose lowercased form is in + /// `sensitiveHeaders` has its value replaced with `"[REDACTED]"`. All other + /// headers are left unchanged. + static func redactHeaders(_ headers: [String: String]) -> [String: String] { + headers.mapValues { _ in "" } // placeholder; real impl below + // Swift doesn't short-circuit mapValues for key access, so we use reduce: + return headers.reduce(into: [String: String]()) { result, pair in + let (key, value) = pair + result[key] = sensitiveHeaders.contains(key.lowercased()) ? "[REDACTED]" : value + } + } + + // MARK: - String redaction + + /// Replaces known-sensitive patterns in `input` with safe placeholders: + /// + /// - `Bearer ` → `Bearer [REDACTED]` + /// Covers `Authorization: Bearer …` lines in logged request descriptions. + /// + /// - `x-nonce: ` → `x-nonce: [REDACTED]` (case-insensitive, any delimiter) + /// Covers the anti-replay nonce header if it somehow appears in a log string. + /// + /// All replacements are performed with case-insensitive regex so the caller + /// doesn't need to normalise casing first. + static func redactString(_ input: String) -> String { + var result = input + + // Redact Bearer tokens: "Bearer " followed by one or more token characters. + // Token characters per RFC 6750: ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" + // plus base64url's "=". We use a broad character class to be conservative. + if let bearerRegex = try? NSRegularExpression( + pattern: #"Bearer\s+[A-Za-z0-9._\-~+/=]+"#, + options: .caseInsensitive + ) { + let range = NSRange(result.startIndex..., in: result) + result = bearerRegex.stringByReplacingMatches( + in: result, range: range, withTemplate: "Bearer [REDACTED]" + ) + } + + // Redact nonce values: "x-nonce" followed by optional whitespace / ":" / "=" and a value. + if let nonceRegex = try? NSRegularExpression( + pattern: #"(x-nonce\s*[=:]\s*)[A-Za-z0-9._\-]+"#, + options: .caseInsensitive + ) { + let range = NSRange(result.startIndex..., in: result) + result = nonceRegex.stringByReplacingMatches( + in: result, range: range, withTemplate: "$1[REDACTED]" + ) + } + + return result + } +} diff --git a/ios/EthosProtocol/Sources/Services/SessionLockService.swift b/ios/EthosProtocol/Sources/Services/SessionLockService.swift new file mode 100644 index 0000000..90758f6 --- /dev/null +++ b/ios/EthosProtocol/Sources/Services/SessionLockService.swift @@ -0,0 +1,76 @@ +import Foundation +import Combine + +/// Tracks user activity and automatically locks the session after a configurable +/// period of inactivity. The lock is triggered when the app returns to the +/// foreground after being backgrounded, if the elapsed time since the last +/// recorded activity exceeds `timeoutInterval`. +/// +/// Usage: +/// 1. Instantiate as `@StateObject` in the app entry point. +/// 2. Call `recordActivity()` on significant user interactions. +/// 3. Call `handleBackground()` when `scenePhase == .background`. +/// 4. Call `handleForeground()` when `scenePhase == .active`. +/// 5. Call `unlock()` after the user re-authenticates (biometric / passcode). +final class SessionLockService: ObservableObject { + + /// How long (in seconds) of inactivity before the session is locked. + /// Default is 5 minutes (300 s). Change before the first `handleForeground()` + /// call to apply a different threshold at app-launch time. + var timeoutInterval: TimeInterval = 300 + + /// `true` when the session is locked and a re-authentication prompt should be shown. + @Published var isLocked: Bool = false + + /// The most recent time user activity (or unlock) was recorded. + private var lastActivityTime: Date + + // MARK: - Initializers + + /// Production initializer — `lastActivityTime` starts at `Date()` (now). + init() { + self.lastActivityTime = Date() + } + + /// Testable initializer. Allows tests to seed a past `lastActivityTime` + /// so that `handleForeground()` can be called immediately without actually + /// waiting `timeoutInterval` seconds. + internal init(timeoutInterval: TimeInterval, lastActivityTime: Date) { + self.timeoutInterval = timeoutInterval + self.lastActivityTime = lastActivityTime + } + + // MARK: - Public API + + /// Records that the user performed an action right now, resetting the + /// inactivity clock. Call this on meaningful interactions (e.g. tapping + /// a vault row, submitting a form) to prevent premature lock-out during + /// active use. + func recordActivity() { + lastActivityTime = Date() + } + + /// Call when `scenePhase` transitions to `.background`. Records the current + /// time so that the elapsed interval can be computed when the app returns + /// to the foreground. + func handleBackground() { + recordActivity() + } + + /// Call when `scenePhase` transitions to `.active`. Compares the current + /// time against `lastActivityTime`; if the gap is at or above + /// `timeoutInterval`, `isLocked` is set to `true`. + func handleForeground() { + let elapsed = Date().timeIntervalSince(lastActivityTime) + if elapsed >= timeoutInterval { + isLocked = true + } + } + + /// Clears the lock and resets the inactivity clock. Call after the user + /// successfully re-authenticates. + func unlock() { + isLocked = false + recordActivity() + } +} diff --git a/ios/EthosProtocol/Sources/Views/PrivacyOverlayModifier.swift b/ios/EthosProtocol/Sources/Views/PrivacyOverlayModifier.swift new file mode 100644 index 0000000..916de8e --- /dev/null +++ b/ios/EthosProtocol/Sources/Views/PrivacyOverlayModifier.swift @@ -0,0 +1,58 @@ +import SwiftUI + +/// A `ViewModifier` that overlays an opaque privacy screen whenever the app is +/// not in the `.active` scene phase (i.e. when the system app-switcher snapshot +/// is captured, or when the app is fully backgrounded). This prevents sensitive +/// vault data — balances, beneficiaries, TTL countdowns — from appearing in the +/// iOS app-switcher thumbnail or being captured by the system screenshot taken +/// on transition to background. +/// +/// Apply via the `privacyOverlay()` convenience extension. +struct PrivacyOverlayModifier: ViewModifier { + @Environment(\.scenePhase) private var scenePhase + + func body(content: Content) -> some View { + content + .overlay { + if scenePhase != .active { + privacyScreen + } + } + } + + /// Full-screen overlay shown while the app is backgrounded or inactive. + /// Uses the system background colour as the base so it respects dark/light + /// mode, then centres a lock icon as a neutral placeholder. + @ViewBuilder + private var privacyScreen: some View { + ZStack { + Color(uiColor: .systemBackground) + .ignoresSafeArea() + + VStack(spacing: 16) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 64)) + .foregroundStyle(.secondary) + + Text("Ethos Protocol") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + } + } + // The overlay must not receive touches (the real content beneath it + // must stay interactive when the system briefly marks the scene + // .inactive, e.g. during a swipe-from-bottom gesture). + .allowsHitTesting(false) + // Skip accessibility so VoiceOver doesn't announce the cover screen. + .accessibilityHidden(true) + } +} + +extension View { + /// Overlays an opaque privacy screen while the scene is not `.active`, + /// preventing sensitive content from appearing in the iOS app switcher. + func privacyOverlay() -> some View { + modifier(PrivacyOverlayModifier()) + } +} diff --git a/ios/EthosProtocol/Tests/LogRedactorTests.swift b/ios/EthosProtocol/Tests/LogRedactorTests.swift new file mode 100644 index 0000000..6619f4f --- /dev/null +++ b/ios/EthosProtocol/Tests/LogRedactorTests.swift @@ -0,0 +1,117 @@ +import XCTest +@testable import EthosProtocol + +final class LogRedactorTests: XCTestCase { + + // MARK: - redactString + + /// `redactString` must replace the token portion of a Bearer credential + /// so that the raw token value does not appear in logs. + func testBearerTokenRedacted() { + let input = "Authorization: Bearer abc123.def456.ghi789" + let output = LogRedactor.redactString(input) + + XCTAssertFalse(output.contains("abc123"), + "Raw Bearer token 'abc123' must not appear in redacted output") + XCTAssertFalse(output.contains("def456"), + "Raw Bearer token segment 'def456' must not appear in redacted output") + XCTAssertTrue(output.contains("Bearer [REDACTED]"), + "Output should contain the 'Bearer [REDACTED]' placeholder") + } + + /// Bearer redaction must be case-insensitive. + func testBearerTokenRedactedCaseInsensitive() { + let input = "authorization: bearer MYSECRETTOKEN" + let output = LogRedactor.redactString(input) + + XCTAssertFalse(output.contains("MYSECRETTOKEN"), + "Token must be redacted regardless of 'bearer' casing") + } + + /// Nonce values embedded in a log string (e.g. "x-nonce: abc123def") must + /// have their value replaced with `[REDACTED]`. + func testNonceRedacted() { + let input = "x-nonce: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + let output = LogRedactor.redactString(input) + + XCTAssertFalse( + output.contains("a1b2c3d4e5f6"), + "Nonce value must not appear in redacted output" + ) + XCTAssertTrue(output.contains("[REDACTED]"), + "Output should contain the [REDACTED] placeholder in place of the nonce") + } + + /// Strings that contain no sensitive patterns must pass through unchanged. + func testNonSensitiveStringUnchanged() { + let input = "GET /vaults HTTP/1.1 -> 200 OK" + let output = LogRedactor.redactString(input) + + XCTAssertEqual(output, input, + "Non-sensitive strings must pass through LogRedactor.redactString unchanged") + } + + // MARK: - redactHeaders + + /// The `authorization` header value must be replaced with `[REDACTED]`. + func testNonceHeaderRedacted() { + let headers = ["Authorization": "Bearer super-secret-token-value"] + let redacted = LogRedactor.redactHeaders(headers) + + XCTAssertEqual(redacted["Authorization"], "[REDACTED]", + "Authorization header value must be replaced with [REDACTED]") + XCTAssertFalse( + (redacted["Authorization"] ?? "").contains("super-secret-token-value"), + "Raw token must not survive header redaction" + ) + } + + /// Header name matching must be case-insensitive. + func testHeaderRedactionCaseInsensitive() { + let headers = [ + "AUTHORIZATION": "Bearer token-abc", + "X-Nonce": "deadbeef", + "X-OTP": "123456", + "x-2fa-token": "totp-secret" + ] + let redacted = LogRedactor.redactHeaders(headers) + + for key in headers.keys { + XCTAssertEqual(redacted[key], "[REDACTED]", + "Header '\(key)' should be redacted regardless of its casing") + } + } + + /// Non-sensitive headers (e.g. `Content-Type`) must pass through unchanged. + func testNonSensitiveHeadersUnchanged() { + let headers = [ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Timestamp": "1700000000" + ] + let redacted = LogRedactor.redactHeaders(headers) + + XCTAssertEqual(redacted["Content-Type"], "application/json", + "Content-Type must not be redacted") + XCTAssertEqual(redacted["Accept"], "application/json", + "Accept must not be redacted") + XCTAssertEqual(redacted["X-Timestamp"], "1700000000", + "X-Timestamp must not be redacted") + } + + /// A mix of sensitive and non-sensitive headers — only sensitive ones get redacted. + func testMixedHeaders() { + let headers = [ + "Content-Type": "application/json", + "Authorization": "Bearer abc.def.ghi", + "X-Nonce": "0011223344556677", + "Accept": "application/json" + ] + let redacted = LogRedactor.redactHeaders(headers) + + XCTAssertEqual(redacted["Content-Type"], "application/json") + XCTAssertEqual(redacted["Accept"], "application/json") + XCTAssertEqual(redacted["Authorization"], "[REDACTED]") + XCTAssertEqual(redacted["X-Nonce"], "[REDACTED]") + } +} diff --git a/ios/EthosProtocol/Tests/SessionLockServiceTests.swift b/ios/EthosProtocol/Tests/SessionLockServiceTests.swift new file mode 100644 index 0000000..ef0ea6a --- /dev/null +++ b/ios/EthosProtocol/Tests/SessionLockServiceTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import EthosProtocol + +final class SessionLockServiceTests: XCTestCase { + + // MARK: - testNoLockBeforeTimeout + + /// When the app foregrounds before the timeout has elapsed, the session + /// must NOT be locked. + func testNoLockBeforeTimeout() { + // Seed lastActivityTime to 60 s ago with a 300 s timeout — well within limit. + let service = SessionLockService( + timeoutInterval: 300, + lastActivityTime: Date().addingTimeInterval(-60) + ) + + service.handleForeground() + + XCTAssertFalse(service.isLocked, + "Session should NOT be locked when only 60 s have elapsed with a 300 s timeout") + } + + // MARK: - testLockAfterTimeout + + /// When the app foregrounds after the timeout has been exceeded, the session + /// MUST be locked. + func testLockAfterTimeout() { + // Seed lastActivityTime to 301 s ago with a 300 s timeout — just over the limit. + let service = SessionLockService( + timeoutInterval: 300, + lastActivityTime: Date().addingTimeInterval(-301) + ) + + service.handleForeground() + + XCTAssertTrue(service.isLocked, + "Session MUST be locked when 301 s have elapsed with a 300 s timeout") + } + + /// Boundary: elapsed time exactly equal to the timeout should also lock. + func testLockAtExactTimeout() { + let service = SessionLockService( + timeoutInterval: 300, + lastActivityTime: Date().addingTimeInterval(-300) + ) + + service.handleForeground() + + XCTAssertTrue(service.isLocked, + "Session MUST be locked when elapsed time equals the timeout exactly") + } + + // MARK: - testUnlockResetsState + + /// After `unlock()` is called, `isLocked` must be `false` and the inactivity + /// clock must be reset so that a subsequent immediate foreground event does + /// not re-lock the session. + func testUnlockResetsState() { + // Start already locked (elapsed time way past timeout). + let service = SessionLockService( + timeoutInterval: 300, + lastActivityTime: Date().addingTimeInterval(-1_000) + ) + service.handleForeground() + XCTAssertTrue(service.isLocked, "Pre-condition: service should be locked") + + service.unlock() + + XCTAssertFalse(service.isLocked, "isLocked should be false immediately after unlock()") + + // Simulate an almost-immediate foreground (0 s elapsed after unlock). + service.handleForeground() + XCTAssertFalse(service.isLocked, + "Session should NOT re-lock immediately after unlock() resets the clock") + } + + // MARK: - testRecordActivityPreventsLock + + /// `recordActivity()` resets the clock; a foreground check after recording + /// activity should not lock even if the original lastActivityTime was stale. + func testRecordActivityPreventsLock() { + let service = SessionLockService( + timeoutInterval: 300, + lastActivityTime: Date().addingTimeInterval(-1_000) + ) + + // User interacts — clock is reset to now. + service.recordActivity() + service.handleForeground() + + XCTAssertFalse(service.isLocked, + "Session should NOT lock after recordActivity() refreshes the clock") + } + + // MARK: - testHandleBackgroundRefreshesClock + + /// `handleBackground()` should refresh `lastActivityTime` so that the gap + /// measured on the next foreground is relative to when the app was backgrounded, + /// not some earlier interaction. + func testHandleBackgroundRefreshesClock() { + // Start with an old lastActivityTime. + let service = SessionLockService( + timeoutInterval: 300, + lastActivityTime: Date().addingTimeInterval(-1_000) + ) + + // App goes to background now — clock is reset. + service.handleBackground() + // App immediately returns to foreground (< 1 s elapsed since background). + service.handleForeground() + + XCTAssertFalse(service.isLocked, + "handleBackground() should reset the clock so a brief background trip doesn't lock") + } +}