be3f46287a
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>
127 lines
5.2 KiB
Kotlin
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, "")
|
|
}
|