diff options
| author | Joel Klinghed <the_jk@spawned.biz> | 2024-10-31 23:14:01 +0100 |
|---|---|---|
| committer | Joel Klinghed <the_jk@spawned.biz> | 2024-10-31 23:36:29 +0100 |
| commit | 6d185a2e7fca1e15008ef4906c57be5f42c3c6b3 (patch) | |
| tree | 3288c6e0767748f53072312c28d4468ff7ea53f8 | |
| parent | 77f2ab719c50b27b4aeca4d7cbd4b1398337ed78 (diff) | |
sftp: Verify server fingerprint
If no fingerprint is stored -> save whatever the server gives
If a fingerprint is stored -> error if fingerprint does not match
4 files changed, 110 insertions, 26 deletions
diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpConnection.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpConnection.kt index 43eb88a..ed5889a 100644 --- a/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpConnection.kt +++ b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpConnection.kt @@ -3,19 +3,24 @@ package org.the_jk.cleversync.io.sftp import android.net.Uri import org.the_jk.cleversync.PathUtils -internal class SftpConnection(uri: Uri, credentials: SftpCredentials) { +internal class SftpConnection(uri: Uri, credentials: SftpCredentials, hostsStorage: SftpHostsStorage) { val description = uri.toString() private val baseDir = uri.path ?: "" private val sshSession = NativeSftp.newSshSession() private var sftpSession: NativeSftp.SftpSession? = null private var destroyed = false + private var fingerprintMismatch = false - val connected = if (!destroyed) { login(uri, credentials) } else false + val connected = if (!destroyed) { login(uri, credentials, hostsStorage) } else false val error: String get() = if (destroyed) "[destroyed]" else { - val err = sftpSession?.lastError() - if (err.isNullOrEmpty()) sshSession.lastError() else err + if (fingerprintMismatch) { + "[fingerprint mismatch]" + } else { + val err = sftpSession?.lastError() + if (err.isNullOrEmpty()) sshSession.lastError() else err + } } protected fun finalize() { @@ -66,12 +71,20 @@ internal class SftpConnection(uri: Uri, credentials: SftpCredentials) { override fun toString() = description - private fun login(uri: Uri, credentials: SftpCredentials): Boolean { - if (!sshSession.connect(uri.host ?: "", if (uri.port == -1) DEFAULT_PORT else uri.port)) { + private fun login(uri: Uri, credentials: SftpCredentials, hostsStorage: SftpHostsStorage): Boolean { + val host = uri.host ?: "" + val port = if (uri.port == -1) DEFAULT_PORT else uri.port + if (!sshSession.connect(host, port)) { + return false + } + val expectedFingerprint = hostsStorage.get(host, port) + val fingerprint = sshSession.handshake() ?: return false + if (expectedFingerprint == null) { + hostsStorage.put(host, port, fingerprint) + } else if (expectedFingerprint != fingerprint) { + fingerprintMismatch = true return false } - // TODO: Check fingerprint against last one - if (sshSession.handshake() == null) return false when (credentials) { is SftpCredentials.SftpPasswordCredentials -> if (!sshSession.authenticate( diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpHostsStorage.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpHostsStorage.kt new file mode 100644 index 0000000..2fde819 --- /dev/null +++ b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpHostsStorage.kt @@ -0,0 +1,35 @@ +package org.the_jk.cleversync.io.sftp + +import android.content.Context +import android.util.Base64 + +class SftpHostsStorage(context: Context) { + private val prefs by lazy { + context.getSharedPreferences("sftp_hosts", Context.MODE_PRIVATE) + } + + internal fun size(): Int { + return prefs.all.size + } + + internal fun get(host: String, port: Int): NativeSftp.Fingerprint? { + val key = createKey(host, port) + val data = prefs.getString(key, null) ?: return null + return try { + NativeSftp.Fingerprint(Base64.decode(data, Base64.DEFAULT)) + } catch (_: IllegalArgumentException) { + null + } + } + + internal fun put(host: String, port: Int, fingerprint: NativeSftp.Fingerprint) { + val key = createKey(host, port) + val editor = prefs.edit() + editor.putString(key, Base64.encodeToString(fingerprint.data, Base64.NO_WRAP)) + editor.apply() + } + + private companion object { + fun createKey(host: String, port: Int) = "$host:$port" + } +} diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/sftp/SftpTreeFactory.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/sftp/SftpTreeFactory.kt index 7a45829..08894b9 100644 --- a/libs/sftp/src/main/java/org/the_jk/cleversync/sftp/SftpTreeFactory.kt +++ b/libs/sftp/src/main/java/org/the_jk/cleversync/sftp/SftpTreeFactory.kt @@ -5,15 +5,21 @@ import org.the_jk.cleversync.io.ModifiableTree import org.the_jk.cleversync.io.Tree import org.the_jk.cleversync.io.sftp.SftpConnection import org.the_jk.cleversync.io.sftp.SftpCredentials +import org.the_jk.cleversync.io.sftp.SftpHostsStorage import org.the_jk.cleversync.io.sftp.SftpTree object SftpTreeFactory { - fun tree(uri: String, credentials: SftpCredentials): Result<Tree> = modifiableTree(uri, credentials) + fun tree(uri: String, credentials: SftpCredentials, hostsStorage: SftpHostsStorage): Result<Tree> + = modifiableTree(uri, credentials, hostsStorage) - fun modifiableTree(uri: String, credentials: SftpCredentials): Result<ModifiableTree> { + fun modifiableTree( + uri: String, + credentials: SftpCredentials, + hostsStorage: SftpHostsStorage, + ): Result<ModifiableTree> { val url = Uri.parse(uri) if (url.scheme != "ssh") return Result.failure(IllegalArgumentException("Invalid url: $uri")) - val connection = SftpConnection(url, credentials) + val connection = SftpConnection(url, credentials, hostsStorage) if (!connection.connected) { val e = Exception(connection.error) connection.destroy() diff --git a/libs/sftp/src/test/java/org/the_jk/cleversync/sftp/SftpTreeTest.kt b/libs/sftp/src/test/java/org/the_jk/cleversync/sftp/SftpTreeTest.kt index da654a1..b254c4e 100644 --- a/libs/sftp/src/test/java/org/the_jk/cleversync/sftp/SftpTreeTest.kt +++ b/libs/sftp/src/test/java/org/the_jk/cleversync/sftp/SftpTreeTest.kt @@ -1,6 +1,7 @@ package org.the_jk.cleversync.sftp import android.content.Context +import android.net.Uri import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import org.junit.After @@ -16,7 +17,9 @@ import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowLooper import org.the_jk.cleversync.TreeAbstractTest import org.the_jk.cleversync.io.Link +import org.the_jk.cleversync.io.sftp.NativeSftp import org.the_jk.cleversync.io.sftp.SftpCredentials +import org.the_jk.cleversync.io.sftp.SftpHostsStorage import java.io.File import java.nio.charset.StandardCharsets import java.nio.file.Files @@ -28,26 +31,17 @@ class SftpTreeTest : TreeAbstractTest() { @get:Rule val testName = TestName() + private lateinit var hostsStorage: SftpHostsStorage + @Before fun setUpTest() { assertThat(shareDir.listFiles()).isEmpty() - val credentials: SftpCredentials - // Test both password and key authentication - // "Stable" as it depends on the hashCode of the test method name - if (testName.methodName.hashCode() % 2 == 0) { - credentials = - SftpCredentials.SftpPasswordCredentials("user", "notverysecret") - } else { - val private = File(dockerDir, "user_private.pem") - credentials = SftpCredentials.SftpKeyCredentials( - "user", - private.readBytes(), - "notsecret", - ) - } + val credentials = getCredentials() - tree = SftpTreeFactory.modifiableTree(uri, credentials).getOrThrow() + hostsStorage = SftpHostsStorage(ApplicationProvider.getApplicationContext()) + + tree = SftpTreeFactory.modifiableTree(uri, credentials, hostsStorage).getOrThrow() } @After @@ -143,12 +137,48 @@ class SftpTreeTest : TreeAbstractTest() { assertThat(File(shareDir, "foo").isDirectory).isTrue() } + @Test + fun matchFingerprint() { + assertThat(hostsStorage.size()).isEqualTo(1) + + val credentials = getCredentials() + // Connect again, this time with a cached fingerprint + SftpTreeFactory.tree(uri, credentials, hostsStorage).getOrThrow() + + assertThat(hostsStorage.size()).isEqualTo(1) + } + + @Test + fun wrongFingerprint() { + val actualUri = Uri.parse(uri) + hostsStorage.put(actualUri.host!!, actualUri.port, NativeSftp.Fingerprint(ByteArray(0))) + + val credentials = getCredentials() + assertThat(SftpTreeFactory.tree(uri, credentials, hostsStorage) + .exceptionOrNull()?.message).isEqualTo("[fingerprint mismatch]") + } + override fun supportSymlinks() = true override fun idle() { ShadowLooper.idleMainLooper(10, TimeUnit.SECONDS) } + private fun getCredentials(): SftpCredentials { + // Test both password and key authentication + // "Stable" as it depends on the hashCode of the test method name + return if (testName.methodName.hashCode() % 2 == 0) { + SftpCredentials.SftpPasswordCredentials("user", "notverysecret") + } else { + val private = File(dockerDir, "user_private.pem") + SftpCredentials.SftpKeyCredentials( + "user", + private.readBytes(), + "notsecret", + ) + } + } + companion object { private lateinit var uri: String private lateinit var dockerDir: File |
