Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
6 changes: 5 additions & 1 deletion android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String, String>): Map<String, String> =
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 <token>` → `Bearer [REDACTED]`
* Covers `Authorization: Bearer …` lines appearing in logged request dumps.
*
* - `x-nonce: <value>` → `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
}
}
Original file line number Diff line number Diff line change
@@ -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<Boolean> = 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()
}
}
Loading