Files
SyncFlow/app/src/main/kotlin/com/syncflow/ui/auth/OAuthHelper.kt
T
amir be3f46287a 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>
2026-05-24 18:08:40 +00:00

127 lines
5.2 KiB
Kotlin

package com.syncflow.ui.auth
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Base64
import androidx.browser.customtabs.CustomTabsIntent
import com.syncflow.data.security.CredentialStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import java.security.MessageDigest
import java.security.SecureRandom
const val OAUTH_REDIRECT_ACTION = "com.syncflow.OAUTH_RESULT"
const val OAUTH_EXTRA_TOKEN = "token"
const val OAUTH_EXTRA_EMAIL = "email"
const val OAUTH_EXTRA_PROVIDER = "provider"
private val client = OkHttpClient()
private val random = SecureRandom()
private fun generateVerifier(): String {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}
private fun generateChallenge(verifier: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}
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" +
"&response_type=code" +
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" +
"&code_challenge=$challenge" +
"&code_challenge_method=S256" +
"&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" +
"?client_id=$clientId" +
"&response_type=code" +
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fonedrive" +
"&scope=$scopes" +
"&code_challenge=$challenge" +
"&code_challenge_method=S256" +
"&state=$state"
openCustomTab(context, url)
}
private fun openCustomTab(context: Context, url: String) {
try {
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
} catch (_: Exception) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK })
}
}
suspend fun exchangeDropboxCode(
credentialStore: CredentialStore,
code: String,
appKey: String,
): Pair<String, String>? = withContext(Dispatchers.IO) {
val verifier = credentialStore.getPkceVerifier("dropbox") ?: return@withContext null
credentialStore.removePkceVerifier("dropbox")
val body = FormBody.Builder()
.add("code", code)
.add("grant_type", "authorization_code")
.add("client_id", appKey)
.add("redirect_uri", "syncflow://oauth/dropbox")
.add("code_verifier", verifier)
.build()
val req = Request.Builder().url("https://api.dropboxapi.com/oauth2/token").post(body).build()
val resp = runCatching { client.newCall(req).execute() }.getOrNull() ?: return@withContext null
val text = resp.body?.string() ?: return@withContext null
val json = runCatching { Json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return@withContext null
val token = json["access_token"]?.jsonPrimitive?.content ?: return@withContext null
val email = json["account_id"]?.jsonPrimitive?.content ?: ""
Pair(token, email)
}
suspend fun exchangeOneDriveCode(
credentialStore: CredentialStore,
code: String,
clientId: String,
): Pair<String, String>? = withContext(Dispatchers.IO) {
val verifier = credentialStore.getPkceVerifier("onedrive") ?: return@withContext null
credentialStore.removePkceVerifier("onedrive")
val body = FormBody.Builder()
.add("code", code)
.add("grant_type", "authorization_code")
.add("client_id", clientId)
.add("redirect_uri", "syncflow://oauth/onedrive")
.add("code_verifier", verifier)
.add("scope", "Files.ReadWrite User.Read offline_access")
.build()
val req = Request.Builder().url("https://login.microsoftonline.com/common/oauth2/v2.0/token").post(body).build()
val resp = runCatching { client.newCall(req).execute() }.getOrNull() ?: return@withContext null
val text = resp.body?.string() ?: return@withContext null
val json = runCatching { Json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return@withContext null
val token = json["access_token"]?.jsonPrimitive?.content ?: return@withContext null
Pair(token, "")
}