Compare commits

..

3 Commits

Author SHA1 Message Date
amir cfac742856 ci: add Gitea Actions workflow to build and attach APK on tag push
Triggers on v* tags — sets up Java 17 + Android SDK, builds a debug APK
(installable without a keystore), renames it SyncFlow-v<version>.apk, and
uploads it to the matching Gitea release via the API using the built-in token.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:51:37 +00:00
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
amir 894c2ffe78 v1.0.18: fix ON_CHANGE never starting, improve version display
- Start FileWatchService from SyncFlowApp.onCreate() for any existing
  enabled ON_CHANGE pairs — previously the watcher only started on
  device boot or explicit pair toggle, so existing pairs after an
  app update never got watched
- About screen now shows "Version X.Y.Z (build N)" updating
  automatically from BuildConfig on every release

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:11:47 +00:00
17 changed files with 201 additions and 41 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Build & Release APK
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- uses: android-actions/setup-android@v3
- name: Build debug APK
run: |
chmod +x gradlew
./gradlew assembleDebug --no-daemon
- name: Get version name
id: ver
run: echo "name=$(grep VERSION_NAME version.properties | cut -d= -f2)" >> $GITHUB_OUTPUT
- name: Rename APK
run: |
mkdir dist
cp app/build/outputs/apk/debug/app-debug.apk \
dist/SyncFlow-v${{ steps.ver.outputs.name }}.apk
- name: Attach APK to release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.ver.outputs.name }}
run: |
RELEASE_ID=$(curl -sf \
"https://gitea.khodak.me/api/v1/repos/amir/SyncFlow/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
curl -sf -X POST \
"https://gitea.khodak.me/api/v1/repos/amir/SyncFlow/releases/$RELEASE_ID/assets" \
-H "Authorization: token $TOKEN" \
-F "attachment=@dist/SyncFlow-v${VERSION}.apk"
echo "APK uploaded to release $TAG"
@@ -3,7 +3,13 @@ package com.syncflow
import android.app.Application import android.app.Application
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration import androidx.work.Configuration
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
import com.syncflow.worker.FileWatchService
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -11,10 +17,16 @@ import javax.inject.Inject
class SyncFlowApp : Application(), Configuration.Provider { class SyncFlowApp : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var syncPairDao: SyncPairDao
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
// Start file watcher on every app launch for any existing ON_CHANGE pairs
CoroutineScope(Dispatchers.IO).launch {
val hasOnChange = syncPairDao.getEnabled().any { it.scheduleType == ScheduleType.ON_CHANGE }
if (hasOnChange) FileWatchService.start(this@SyncFlowApp)
}
} }
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
@@ -7,19 +7,20 @@ import com.syncflow.data.providers.owncloud.OwnCloudProvider
import com.syncflow.data.providers.onedrive.OneDriveProvider import com.syncflow.data.providers.onedrive.OneDriveProvider
import com.syncflow.data.providers.sftp.SftpProvider import com.syncflow.data.providers.sftp.SftpProvider
import com.syncflow.data.providers.webdav.WebDavProvider import com.syncflow.data.providers.webdav.WebDavProvider
import com.syncflow.data.security.CredentialStore
import com.syncflow.domain.model.CloudAccount import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.ProviderType import com.syncflow.domain.model.ProviderType
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ProviderFactory @Inject constructor() { class ProviderFactory @Inject constructor(private val credentialStore: CredentialStore) {
fun create(account: CloudAccount): CloudProvider = when (account.providerType) { fun create(account: CloudAccount): CloudProvider = when (account.providerType) {
ProviderType.GOOGLE_DRIVE -> GoogleDriveProvider(account) ProviderType.GOOGLE_DRIVE -> GoogleDriveProvider(account)
ProviderType.DROPBOX -> DropboxProvider(account) ProviderType.DROPBOX -> DropboxProvider(account)
ProviderType.ONEDRIVE -> OneDriveProvider(account) ProviderType.ONEDRIVE -> OneDriveProvider(account)
ProviderType.WEBDAV -> WebDavProvider(account) ProviderType.WEBDAV -> WebDavProvider(account)
ProviderType.SFTP -> SftpProvider(account) ProviderType.SFTP -> SftpProvider(account, credentialStore)
ProviderType.NEXTCLOUD -> NextcloudProvider(account) ProviderType.NEXTCLOUD -> NextcloudProvider(account)
ProviderType.OWNCLOUD -> OwnCloudProvider(account) ProviderType.OWNCLOUD -> OwnCloudProvider(account)
ProviderType.SFTPGO -> WebDavProvider(account) // SFTPGo exposes WebDAV ProviderType.SFTPGO -> WebDavProvider(account) // SFTPGo exposes WebDAV
@@ -18,9 +18,9 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
} }
private val client = OkHttpClient() private val client = OkHttpClient()
private fun apiReq(url: String, bodyJson: String): Request = private fun apiReq(url: String, argJson: JsonObject): Request =
Request.Builder().url(url) Request.Builder().url(url)
.post(bodyJson.toRequestBody("application/json".toMediaType())) .post(argJson.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.build() .build()
@@ -33,7 +33,8 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching { override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
val path = if (remotePath == "/" || remotePath.isBlank()) "" else remotePath val path = if (remotePath == "/" || remotePath.isBlank()) "" else remotePath
val req = apiReq("https://api.dropboxapi.com/2/files/list_folder", """{"path":"$path","recursive":false}""") val arg = buildJsonObject { put("path", path); put("recursive", false) }
val req = apiReq("https://api.dropboxapi.com/2/files/list_folder", arg)
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}") val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body") if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body")
@@ -44,11 +45,15 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun uploadFile(localStream: InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result<RemoteFile> = runCatching { override suspend fun uploadFile(localStream: InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result<RemoteFile> = runCatching {
val bytes = localStream.readBytes() val bytes = localStream.readBytes()
val argHeader = """{"path":"${remotePath.normalizeDropbox()}","mode":"overwrite","autorename":false}""" val arg = buildJsonObject {
put("path", remotePath.normalizeDropbox())
put("mode", "overwrite")
put("autorename", false)
}
val req = Request.Builder().url("https://content.dropboxapi.com/2/files/upload") val req = Request.Builder().url("https://content.dropboxapi.com/2/files/upload")
.post(bytes.toRequestBody("application/octet-stream".toMediaType())) .post(bytes.toRequestBody("application/octet-stream".toMediaType()))
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.header("Dropbox-API-Arg", argHeader).build() .header("Dropbox-API-Arg", arg.toString()).build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}") val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body") if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body")
@@ -58,11 +63,11 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
} }
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching { override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
val argHeader = """{"path":"${remotePath.normalizeDropbox()}"}""" val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = Request.Builder().url("https://content.dropboxapi.com/2/files/download") val req = Request.Builder().url("https://content.dropboxapi.com/2/files/download")
.post("".toRequestBody()) .post("".toRequestBody())
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.header("Dropbox-API-Arg", argHeader).build() .header("Dropbox-API-Arg", arg.toString()).build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}")
var total = 0L var total = 0L
@@ -75,17 +80,20 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
} }
override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching { override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/delete_v2", """{"path":"${remotePath.normalizeDropbox()}"}""") val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = apiReq("https://api.dropboxapi.com/2/files/delete_v2", arg)
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") } client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
} }
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching { override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/create_folder_v2", """{"path":"${remotePath.normalizeDropbox()}"}""") val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = apiReq("https://api.dropboxapi.com/2/files/create_folder_v2", arg)
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful && resp.code != 409) throw Exception("HTTP ${resp.code}") } client.newCall(req).execute().use { resp -> if (!resp.isSuccessful && resp.code != 409) throw Exception("HTTP ${resp.code}") }
} }
override suspend fun getFileMetadata(remotePath: String): Result<RemoteFile> = runCatching { override suspend fun getFileMetadata(remotePath: String): Result<RemoteFile> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/get_metadata", """{"path":"${remotePath.normalizeDropbox()}"}""") val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = apiReq("https://api.dropboxapi.com/2/files/get_metadata", arg)
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}") val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
Json.parseToJsonElement(body).jsonObject.toRemoteFile() Json.parseToJsonElement(body).jsonObject.toRemoteFile()
@@ -93,8 +101,11 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
} }
override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching { override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/move_v2", val arg = buildJsonObject {
"""{"from_path":"${fromPath.normalizeDropbox()}","to_path":"${toPath.normalizeDropbox()}"}""") put("from_path", fromPath.normalizeDropbox())
put("to_path", toPath.normalizeDropbox())
}
val req = apiReq("https://api.dropboxapi.com/2/files/move_v2", arg)
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") } client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
} }
@@ -44,9 +44,11 @@ class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
val name = remotePath.substringAfterLast('/') val name = remotePath.substringAfterLast('/')
val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root" val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root"
// Multipart upload // Multipart upload — use JSON builder to avoid injection via filenames with special chars
val metaPart = """{"name":"$name","parents":["$parentId"]}""" val metaPart = buildJsonObject {
.toRequestBody("application/json".toMediaType()) put("name", name)
put("parents", buildJsonArray { add(parentId) })
}.toString().toRequestBody("application/json".toMediaType())
val dataPart = bytes.toRequestBody("application/octet-stream".toMediaType()) val dataPart = bytes.toRequestBody("application/octet-stream".toMediaType())
val multipart = MultipartBody.Builder() val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
@@ -86,8 +88,11 @@ class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching { override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
val name = remotePath.substringAfterLast('/') val name = remotePath.substringAfterLast('/')
val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root" val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root"
val body = """{"name":"$name","mimeType":"application/vnd.google-apps.folder","parents":["$parentId"]}""" val body = buildJsonObject {
.toRequestBody("application/json".toMediaType()) put("name", name)
put("mimeType", "application/vnd.google-apps.folder")
put("parents", buildJsonArray { add(parentId) })
}.toString().toRequestBody("application/json".toMediaType())
val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files").post(body)).build() val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files").post(body)).build()
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") } client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
} }
@@ -102,7 +107,8 @@ class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching { override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
val newName = toPath.substringAfterLast('/') val newName = toPath.substringAfterLast('/')
val body = """{"name":"$newName"}""".toRequestBody("application/json".toMediaType()) val body = buildJsonObject { put("name", newName) }.toString()
.toRequestBody("application/json".toMediaType())
val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files/$fromPath").patch(body)).build() val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files/$fromPath").patch(body)).build()
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") } client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
} }
@@ -1,6 +1,7 @@
package com.syncflow.data.providers.sftp package com.syncflow.data.providers.sftp
import com.syncflow.data.providers.CloudProvider import com.syncflow.data.providers.CloudProvider
import com.syncflow.data.security.CredentialStore
import com.syncflow.domain.model.CloudAccount import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile import com.syncflow.domain.model.RemoteFile
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -8,13 +9,12 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import net.schmizz.sshj.SSHClient import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.sftp.SFTPClient import net.schmizz.sshj.sftp.SFTPClient
import net.schmizz.sshj.transport.verification.PromiscuousVerifier
import net.schmizz.sshj.xfer.InMemorySourceFile import net.schmizz.sshj.xfer.InMemorySourceFile
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.time.Instant import java.time.Instant
class SftpProvider(private val account: CloudAccount) : CloudProvider { class SftpProvider(private val account: CloudAccount, private val credentialStore: CredentialStore) : CloudProvider {
private val creds = Json.parseToJsonElement(account.credentialJson).jsonObject private val creds = Json.parseToJsonElement(account.credentialJson).jsonObject
private val host = account.serverUrl ?: "localhost" private val host = account.serverUrl ?: "localhost"
@@ -25,7 +25,7 @@ class SftpProvider(private val account: CloudAccount) : CloudProvider {
private fun <T> withSftp(block: (SFTPClient) -> T): T { private fun <T> withSftp(block: (SFTPClient) -> T): T {
val ssh = SSHClient() val ssh = SSHClient()
ssh.addHostKeyVerifier(PromiscuousVerifier()) // TODO: replace with proper key pinning ssh.addHostKeyVerifier(TofuHostKeyVerifier(credentialStore))
ssh.connect(host, port) ssh.connect(host, port)
try { try {
if (!privateKey.isNullOrBlank()) { if (!privateKey.isNullOrBlank()) {
@@ -0,0 +1,31 @@
package com.syncflow.data.providers.sftp
import com.syncflow.data.security.CredentialStore
import net.schmizz.sshj.transport.verification.HostKeyVerifier
import java.security.MessageDigest
import java.security.PublicKey
/**
* Trust-On-First-Use SSH host key verifier.
*
* First connection to a host: fingerprint is stored in EncryptedSharedPreferences and accepted.
* Subsequent connections: stored fingerprint must match — mismatch aborts (possible MITM).
*/
class TofuHostKeyVerifier(private val credentialStore: CredentialStore) : HostKeyVerifier {
override fun verify(hostname: String, port: Int, key: PublicKey): Boolean {
val fingerprint = sha256Fingerprint(key)
val stored = credentialStore.getHostFingerprint(hostname, port)
return if (stored == null) {
credentialStore.saveHostKey(hostname, port, fingerprint)
true
} else {
stored == fingerprint
}
}
private fun sha256Fingerprint(key: PublicKey): String {
val digest = MessageDigest.getInstance("SHA-256").digest(key.encoded)
return digest.joinToString(":") { "%02x".format(it) }
}
}
@@ -10,6 +10,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.* import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
@@ -38,9 +39,14 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
.header("Authorization", Credentials.basic(user, pass)) .header("Authorization", Credentials.basic(user, pass))
.build() .build()
val resp = chain.proceed(req) val resp = chain.proceed(req)
// follow redirect manually for WebDAV methods (OkHttp skips non-GET/HEAD redirects) // Follow redirects for WebDAV methods (OkHttp skips non-GET/HEAD redirects).
// Only follow same-host redirects to prevent credential leakage to a different server.
if (resp.code in 301..308) { if (resp.code in 301..308) {
val location = resp.header("Location") ?: return@addInterceptor resp val location = resp.header("Location") ?: return@addInterceptor resp
val redirectHost = location.toHttpUrlOrNull()?.host
if (redirectHost == null || redirectHost != req.url.host) {
return@addInterceptor resp
}
resp.close() resp.close()
val redirectReq = req.newBuilder().url(location).build() val redirectReq = req.newBuilder().url(location).build()
chain.proceed(redirectReq) chain.proceed(redirectReq)
@@ -3,7 +3,7 @@ package com.syncflow.data.security
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKeys
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -12,13 +12,11 @@ import javax.inject.Singleton
class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) { class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) {
private val prefs: SharedPreferences by lazy { private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context) val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create( EncryptedSharedPreferences.create(
context,
"syncflow_credentials", "syncflow_credentials",
masterKey, masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
@@ -37,7 +35,7 @@ class CredentialStore @Inject constructor(@ApplicationContext private val contex
prefs.edit().remove(credKey(accountId)).apply() prefs.edit().remove(credKey(accountId)).apply()
} }
// ── PKCE verifiers (OAuth flow) ─────────────────────────────────────────── // ── PKCE verifiers and OAuth state (OAuth flow) ───────────────────────────
fun savePkceVerifier(provider: String, verifier: String) { fun savePkceVerifier(provider: String, verifier: String) {
prefs.edit().putString(pkceKey(provider), verifier).apply() prefs.edit().putString(pkceKey(provider), verifier).apply()
@@ -49,8 +47,18 @@ class CredentialStore @Inject constructor(@ApplicationContext private val contex
prefs.edit().remove(pkceKey(provider)).apply() prefs.edit().remove(pkceKey(provider)).apply()
} }
// ── SFTP host key fingerprints (TOFU) ─────────────────────────────────────
fun saveHostKey(host: String, port: Int, fingerprint: String) {
prefs.edit().putString(hostKey(host, port), fingerprint).apply()
}
fun getHostFingerprint(host: String, port: Int): String? =
prefs.getString(hostKey(host, port), null)
// ── Key helpers ─────────────────────────────────────────────────────────── // ── Key helpers ───────────────────────────────────────────────────────────
private fun credKey(accountId: Long) = "cred_$accountId" private fun credKey(accountId: Long) = "cred_$accountId"
private fun pkceKey(provider: String) = "pkce_$provider" private fun pkceKey(provider: String) = "pkce_$provider"
private fun hostKey(host: String, port: Int) = "sshhost_${host}_$port"
} }
@@ -2,6 +2,7 @@ package com.syncflow.ui.auth
import android.accounts.AccountManager import android.accounts.AccountManager
import android.app.Activity import android.app.Activity
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@@ -201,6 +202,15 @@ private fun CredentialContent(
) { ) {
val provider = state.providerType ?: return 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( Column(
modifier = modifier modifier = modifier
.padding(horizontal = 20.dp) .padding(horizontal = 20.dp)
@@ -38,7 +38,9 @@ private fun generateChallenge(verifier: String): String {
fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKey: String) { fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKey: String) {
val verifier = generateVerifier() val verifier = generateVerifier()
val state = generateVerifier()
credentialStore.savePkceVerifier("dropbox", verifier) credentialStore.savePkceVerifier("dropbox", verifier)
credentialStore.savePkceVerifier("dropbox_state", state)
val challenge = generateChallenge(verifier) val challenge = generateChallenge(verifier)
val url = "https://www.dropbox.com/oauth2/authorize" + val url = "https://www.dropbox.com/oauth2/authorize" +
"?client_id=$appKey" + "?client_id=$appKey" +
@@ -46,13 +48,16 @@ fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKe
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" + "&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" +
"&code_challenge=$challenge" + "&code_challenge=$challenge" +
"&code_challenge_method=S256" + "&code_challenge_method=S256" +
"&token_access_type=offline" "&token_access_type=offline" +
"&state=$state"
openCustomTab(context, url) openCustomTab(context, url)
} }
fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clientId: String) { fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clientId: String) {
val verifier = generateVerifier() val verifier = generateVerifier()
val state = generateVerifier()
credentialStore.savePkceVerifier("onedrive", verifier) credentialStore.savePkceVerifier("onedrive", verifier)
credentialStore.savePkceVerifier("onedrive_state", state)
val challenge = generateChallenge(verifier) val challenge = generateChallenge(verifier)
val scopes = "Files.ReadWrite+User.Read+offline_access" val scopes = "Files.ReadWrite+User.Read+offline_access"
val url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + 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" + "&redirect_uri=syncflow%3A%2F%2Foauth%2Fonedrive" +
"&scope=$scopes" + "&scope=$scopes" +
"&code_challenge=$challenge" + "&code_challenge=$challenge" +
"&code_challenge_method=S256" "&code_challenge_method=S256" +
"&state=$state"
openCustomTab(context, url) openCustomTab(context, url)
} }
@@ -28,11 +28,22 @@ class OAuthRedirectActivity : ComponentActivity() {
private fun handleIntent(intent: Intent) { private fun handleIntent(intent: Intent) {
val uri = intent.data ?: run { finish(); return } val uri = intent.data ?: run { finish(); return }
val code = uri.getQueryParameter("code") ?: run { finish(); return } val code = uri.getQueryParameter("code") ?: run { finish(); return }
val returnedState = uri.getQueryParameter("state") ?: run { finish(); return }
val provider = when { val provider = when {
uri.host == "oauth" && uri.path?.contains("dropbox") == true -> "dropbox" uri.host == "oauth" && uri.path?.contains("dropbox") == true -> "dropbox"
uri.host == "oauth" && uri.path?.contains("onedrive") == true -> "onedrive" uri.host == "oauth" && uri.path?.contains("onedrive") == true -> "onedrive"
else -> run { finish(); return } 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 appKey = getString(com.syncflow.R.string.dropbox_app_key)
val odClientId = getString(com.syncflow.R.string.onedrive_client_id) val odClientId = getString(com.syncflow.R.string.onedrive_client_id)
lifecycleScope.launch { lifecycleScope.launch {
@@ -137,11 +137,17 @@ fun SettingsScreen(
) { ) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
"SyncFlow v${com.syncflow.BuildConfig.VERSION_NAME} — Free, no subscription.", "SyncFlow",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.titleSmall,
) )
Text( Text(
"Open source. No ads. No tracking.", "Version ${com.syncflow.BuildConfig.VERSION_NAME} (build ${com.syncflow.BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Text(
"Free, no subscription. No ads. No tracking.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
+3 -3
View File
@@ -28,8 +28,8 @@ localbroadcastmanager = "1.1.0"
coil = "2.7.0" coil = "2.7.0"
splashscreen = "1.0.1" splashscreen = "1.0.1"
timber = "5.0.1" timber = "5.0.1"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.0.0"
biometric = "1.2.0-alpha05" biometric = "1.1.0"
junit = "4.13.2" junit = "4.13.2"
androidxTestExt = "1.2.1" androidxTestExt = "1.2.1"
espresso = "3.6.1" espresso = "3.6.1"
@@ -106,7 +106,7 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi
# Security # Security
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
biometric = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometric" } biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
# Logging # Logging
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
Binary file not shown.
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.17 VERSION_NAME=1.0.19
VERSION_CODE=18 VERSION_CODE=20