security: fix all review findings, bump to 1.0.19 (build 20)

CRITICAL
- SftpProvider: replace PromiscuousVerifier with TofuHostKeyVerifier
  (trust-on-first-use; stores SHA-256 fingerprints in EncryptedSharedPreferences;
  rejects key changes on subsequent connections)

HIGH
- GoogleDriveProvider: replace raw string interpolation with buildJsonObject
  in uploadFile, createDirectory, and moveFile to prevent JSON injection
- DropboxProvider: replace all raw JSON strings and Dropbox-API-Arg headers
  with buildJsonObject for the same reason
- OAuthHelper: add cryptographically random state parameter to Dropbox and
  OneDrive authorization URLs (stored alongside the PKCE verifier)
- OAuthRedirectActivity: validate returned state against stored value before
  exchanging the authorization code (CSRF protection)

MEDIUM
- WebDavProvider: block cross-host redirects in the manual redirect interceptor
  so Authorization headers are never forwarded to a different server
- AccountSetupScreen: set FLAG_SECURE on the window while credential fields
  are visible to prevent screenshots and screen-recording capture
- libs.versions.toml: security-crypto alpha06 → stable 1.0.0;
  biometric-ktx alpha05 → biometric 1.1.0 (stable, non-ktx artifact matches
  the BiometricManager/BiometricPrompt API actually used in MainActivity)
- CredentialStore: migrate to security-crypto 1.0.0 API (MasterKeys.getOrCreate
  + positional create() args); add saveHostKey/getHostFingerprint for SFTP TOFU

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 18:08:40 +00:00
parent 894c2ffe78
commit be3f46287a
12 changed files with 128 additions and 38 deletions
@@ -2,6 +2,7 @@ package com.syncflow.ui.auth
import android.accounts.AccountManager
import android.app.Activity
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
@@ -201,6 +202,15 @@ private fun CredentialContent(
) {
val provider = state.providerType ?: return
// Prevent screenshots and screen recording while credentials are visible
val activity = LocalContext.current as? Activity
DisposableEffect(Unit) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
Column(
modifier = modifier
.padding(horizontal = 20.dp)
@@ -38,7 +38,9 @@ private fun generateChallenge(verifier: String): String {
fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKey: String) {
val verifier = generateVerifier()
val state = generateVerifier()
credentialStore.savePkceVerifier("dropbox", verifier)
credentialStore.savePkceVerifier("dropbox_state", state)
val challenge = generateChallenge(verifier)
val url = "https://www.dropbox.com/oauth2/authorize" +
"?client_id=$appKey" +
@@ -46,13 +48,16 @@ fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKe
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" +
"&code_challenge=$challenge" +
"&code_challenge_method=S256" +
"&token_access_type=offline"
"&token_access_type=offline" +
"&state=$state"
openCustomTab(context, url)
}
fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clientId: String) {
val verifier = generateVerifier()
val state = generateVerifier()
credentialStore.savePkceVerifier("onedrive", verifier)
credentialStore.savePkceVerifier("onedrive_state", state)
val challenge = generateChallenge(verifier)
val scopes = "Files.ReadWrite+User.Read+offline_access"
val url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +
@@ -61,7 +66,8 @@ fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clie
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fonedrive" +
"&scope=$scopes" +
"&code_challenge=$challenge" +
"&code_challenge_method=S256"
"&code_challenge_method=S256" +
"&state=$state"
openCustomTab(context, url)
}
@@ -28,11 +28,22 @@ class OAuthRedirectActivity : ComponentActivity() {
private fun handleIntent(intent: Intent) {
val uri = intent.data ?: run { finish(); return }
val code = uri.getQueryParameter("code") ?: run { finish(); return }
val returnedState = uri.getQueryParameter("state") ?: run { finish(); return }
val provider = when {
uri.host == "oauth" && uri.path?.contains("dropbox") == true -> "dropbox"
uri.host == "oauth" && uri.path?.contains("onedrive") == true -> "onedrive"
else -> run { finish(); return }
}
// Validate state before doing anything with the code (CSRF protection)
val storedState = credentialStore.getPkceVerifier("${provider}_state")
if (storedState == null || returnedState != storedState) {
finish()
return
}
credentialStore.removePkceVerifier("${provider}_state")
val appKey = getString(com.syncflow.R.string.dropbox_app_key)
val odClientId = getString(com.syncflow.R.string.onedrive_client_id)
lifecycleScope.launch {