Skip to content
Open
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
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@
android:autoRemoveFromRecents="true"
android:theme="@style/Theme.MaterialFiles.Translucent" />

<activity
android:name="me.zhanghai.android.files.storage.SftpHostKeyChangedDialogActivity"
android:autoRemoveFromRecents="true"
android:theme="@style/Theme.MaterialFiles.Translucent" />

<activity
android:name="me.zhanghai.android.files.viewer.saveas.SaveAsActivity"
android:autoRemoveFromRecents="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,14 @@ package me.zhanghai.android.files.provider.sftp.client

interface Authenticator {
fun getAuthentication(authority: Authority): Authentication?

fun getHostKey(authority: Authority): String?

fun putHostKey(authority: Authority, hostKey: String)

fun confirmChangedHostKey(
authority: Authority,
oldHostKey: String,
newHostKey: String
): Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import me.zhanghai.android.files.provider.common.LocalWatchService
import me.zhanghai.android.files.provider.common.NotifyEntryModifiedSeekableByteChannel
import me.zhanghai.android.files.util.closeSafe
import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.common.KeyType
import net.schmizz.sshj.common.SecurityUtils
import net.schmizz.sshj.sftp.FileAttributes
import net.schmizz.sshj.sftp.FileMode
import net.schmizz.sshj.sftp.OpenMode
Expand All @@ -18,9 +20,10 @@ import net.schmizz.sshj.sftp.Response
import net.schmizz.sshj.sftp.SFTPClient
import net.schmizz.sshj.sftp.SFTPException
import net.schmizz.sshj.transport.TransportException
import net.schmizz.sshj.transport.verification.PromiscuousVerifier
import net.schmizz.sshj.transport.verification.HostKeyVerifier
import net.schmizz.sshj.userauth.UserAuthException
import java.io.IOException
import java.security.PublicKey
import java.util.Collections
import java.util.WeakHashMap
import java8.nio.file.Path as Java8Path
Expand Down Expand Up @@ -237,12 +240,13 @@ object Client {
}
val authentication = authenticator.getAuthentication(authority)
?: throw ClientException("No authentication found for $authority")
val sshClient = SSHClient().apply { addHostKeyVerifier(PromiscuousVerifier()) }
val hostKeyVerifier = PersistentHostKeyVerifier(authority, authenticator)
val sshClient = SSHClient().apply { addHostKeyVerifier(hostKeyVerifier) }
try {
sshClient.connect(authority.host, authority.port)
} catch (e: IOException) {
sshClient.closeSafe()
throw ClientException(e)
throw ClientException(hostKeyVerifier.exception ?: e)
}
try {
sshClient.auth(authority.username, authentication.toAuthMethod())
Expand All @@ -259,6 +263,49 @@ object Client {
}
}

private class PersistentHostKeyVerifier(
private val authority: Authority,
private val authenticator: Authenticator
) : HostKeyVerifier {
var exception: IOException? = null
private set

override fun verify(hostname: String, port: Int, key: PublicKey): Boolean {
val hostKey = key.toHostKey()
val knownHostKey = authenticator.getHostKey(authority)

return when {
knownHostKey == null -> {
authenticator.putHostKey(authority, hostKey)
true
}

knownHostKey == hostKey -> true

authenticator.confirmChangedHostKey(
authority,
knownHostKey,
hostKey
) -> true

else -> {
exception = IOException(
"Host key for $authority has changed: expected $knownHostKey, got $hostKey. " +
"This may indicate a man-in-the-middle attack."
)
false
}
}
}

override fun findExistingAlgorithms(hostname: String, port: Int): List<String> =
authenticator.getHostKey(authority)?.substringBefore(' ')?.let { listOf(it) }
?: emptyList()

private fun PublicKey.toHostKey(): String =
"${KeyType.fromKey(this)} ${SecurityUtils.getFingerprint(this)}"
}

interface Path {
val authority: Authority
val remotePath: String
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Material Files contributors
* SPDX‑License‑Identifier: GPL-3.0-or-later
*/

package me.zhanghai.android.files.storage

import android.os.Bundle
import android.view.View
import androidx.fragment.app.commit
import me.zhanghai.android.files.app.AppActivity
import me.zhanghai.android.files.util.args
import me.zhanghai.android.files.util.putArgs

class SftpHostKeyChangedDialogActivity : AppActivity() {
private val args by args<SftpHostKeyChangedDialogFragment.Args>()

private lateinit var fragment: SftpHostKeyChangedDialogFragment

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Calls ensureSubDecor().
findViewById<View>(android.R.id.content)
if (savedInstanceState == null) {
fragment = SftpHostKeyChangedDialogFragment().putArgs(args)
supportFragmentManager.commit {
add(fragment, SftpHostKeyChangedDialogFragment::class.java.name)
}
} else {
fragment = supportFragmentManager.findFragmentByTag(
SftpHostKeyChangedDialogFragment::class.java.name
) as SftpHostKeyChangedDialogFragment
}
}

override fun onDestroy() {
super.onDestroy()

if (isFinishing) {
fragment.onFinish()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2026 Material Files contributors
* SPDX‑License‑Identifier: GPL-3.0-or-later
*/

package me.zhanghai.android.files.storage

import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.os.Parcel
import androidx.appcompat.app.AppCompatDialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.WriteWith
import me.zhanghai.android.files.R
import me.zhanghai.android.files.util.ParcelableArgs
import me.zhanghai.android.files.util.RemoteCallback
import me.zhanghai.android.files.util.args
import me.zhanghai.android.files.util.finish
import me.zhanghai.android.files.util.getArgs
import me.zhanghai.android.files.util.putArgs
import me.zhanghai.android.files.util.readParcelable

class SftpHostKeyChangedDialogFragment : AppCompatDialogFragment() {
private val args by args<Args>()

private var isListenerNotified = false

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
MaterialAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.storage_sftp_host_key_changed_title)
.setMessage(
getMessage(args.authority, args.oldHostKey, args.newHostKey, requireContext())
)
.setPositiveButton(R.string.storage_sftp_host_key_changed_trust) { _, _ ->
notifyListenerOnce(true)
finish()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
notifyListenerOnce(false)
finish()
}
.create()
.apply { setCanceledOnTouchOutside(false) }

override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)

notifyListenerOnce(false)
finish()
}

fun onFinish() {
notifyListenerOnce(false)
}

private fun notifyListenerOnce(trust: Boolean) {
if (isListenerNotified) {
return
}
args.listener(trust)
isListenerNotified = true
}

companion object {
fun getMessage(authority: String, context: Context): String =
context.getString(R.string.storage_sftp_host_key_changed_message_format, authority)

private fun getMessage(
authority: String,
oldHostKey: String,
newHostKey: String,
context: Context
): String = context.getString(
R.string.storage_sftp_host_key_changed_message_with_keys_format,
authority,
oldHostKey,
newHostKey
)
}

@Parcelize
class Args(
val authority: String,
val oldHostKey: String,
val newHostKey: String,
val listener: @WriteWith<ListenerParceler>() (Boolean) -> Unit
) : ParcelableArgs {
object ListenerParceler : Parceler<(Boolean) -> Unit> {
override fun create(parcel: Parcel): (Boolean) -> Unit =
parcel.readParcelable<RemoteCallback>()!!.let {
{ trust -> it.sendResult(Bundle().putArgs(ListenerArgs(trust))) }
}

override fun ((Boolean) -> Unit).write(parcel: Parcel, flags: Int) {
parcel.writeParcelable(RemoteCallback {
val args = it.getArgs<ListenerArgs>()
this(args.trust)
}, flags)
}

@Parcelize
private class ListenerArgs(val trust: Boolean) : ParcelableArgs
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,27 @@

package me.zhanghai.android.files.storage

import androidx.core.content.edit
import kotlinx.coroutines.runBlocking
import me.zhanghai.android.files.R
import me.zhanghai.android.files.app.BackgroundActivityStarter
import me.zhanghai.android.files.app.application
import me.zhanghai.android.files.app.defaultSharedPreferences
import me.zhanghai.android.files.provider.sftp.client.Authentication
import me.zhanghai.android.files.provider.sftp.client.Authenticator
import me.zhanghai.android.files.provider.sftp.client.Authority
import me.zhanghai.android.files.settings.Settings
import me.zhanghai.android.files.util.createIntent
import me.zhanghai.android.files.util.putArgs
import me.zhanghai.android.files.util.valueCompat
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

object SftpServerAuthenticator : Authenticator {
private const val HOST_KEY_PREFIX = "sftp_host_key_"

private val transientServers = mutableSetOf<SftpServer>()
private val hostKeyLock = Any()

override fun getAuthentication(authority: Authority): Authentication? {
val server = synchronized(transientServers) {
Expand All @@ -23,11 +36,50 @@ object SftpServerAuthenticator : Authenticator {
return server?.authentication
}

override fun getHostKey(authority: Authority): String? = synchronized(hostKeyLock) {
defaultSharedPreferences.getString(authority.toHostKeyPreferenceKey(), null)
}

override fun putHostKey(authority: Authority, hostKey: String) {
synchronized(hostKeyLock) {
defaultSharedPreferences.edit { putString(authority.toHostKeyPreferenceKey(), hostKey) }
}
}

override fun confirmChangedHostKey(
authority: Authority,
oldHostKey: String,
newHostKey: String
): Boolean = runBlocking {
suspendCoroutine { continuation ->
val authorityString = authority.toString()
val onTrustDecision: (Boolean) -> Unit = { trust ->
if (trust) {
putHostKey(authority, newHostKey)
}
continuation.resume(trust)
}

BackgroundActivityStarter.startActivity(
SftpHostKeyChangedDialogActivity::class.createIntent().putArgs(
SftpHostKeyChangedDialogFragment.Args(
authorityString, oldHostKey, newHostKey, onTrustDecision
)
),
application.getString(R.string.storage_sftp_host_key_changed_title),
SftpHostKeyChangedDialogFragment.getMessage(authorityString, application),
application
)
}
}

fun addTransientServer(server: SftpServer) {
synchronized(transientServers) { transientServers += server }
}

fun removeTransientServer(server: SftpServer) {
synchronized(transientServers) { transientServers -= server }
}

private fun Authority.toHostKeyPreferenceKey(): String = "$HOST_KEY_PREFIX$this"
}
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,10 @@
<string name="storage_edit_sftp_server_private_key_password_error_invalid">Invalid private key password</string>
<string name="storage_edit_sftp_server_connect_and_add">Connect and add</string>
<string name="storage_edit_sftp_server_add">Add</string>
<string name="storage_sftp_host_key_changed_title">SFTP host key changed</string>
<string name="storage_sftp_host_key_changed_message_format">The host key for %1$s has changed. This may indicate a man-in-the-middle attack. Only trust the new key if you expected this change.</string>
<string name="storage_sftp_host_key_changed_message_with_keys_format">The host key for %1$s has changed. This may indicate a man-in-the-middle attack. Only trust the new key if you expected this change.\n\nOld key:\n%2$s\n\nNew key:\n%3$s</string>
<string name="storage_sftp_host_key_changed_trust">Trust</string>
<string name="storage_add_lan_smb_server_title" translatable="false">@string/storage_edit_smb_server_title_add</string>
<string name="storage_add_lan_smb_server_loading">Searching for SMB servers…</string>
<string name="storage_add_lan_smb_server_add">Add manually</string>
Expand Down