diff --git a/app/src/main/java/dev/lowrespalmtree/comet/Identities.kt b/app/src/main/java/dev/lowrespalmtree/comet/Identities.kt index 6f673d8..4424dbd 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/Identities.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/Identities.kt @@ -4,12 +4,16 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Log import androidx.room.* -import java.lang.IllegalArgumentException import java.security.KeyPairGenerator import java.security.KeyStore import javax.security.auth.x500.X500Principal object Identities { + const val PROVIDER = "AndroidKeyStore" + private const val TAG = "Identities" + + val keyStore by lazy { KeyStore.getInstance(PROVIDER).apply { load(null) } } + @Entity data class Identity( /** ID. */ @@ -49,6 +53,10 @@ object Identities { suspend fun getAll(): List = Database.INSTANCE.identityDao().getAll() + suspend fun getForUrl(url: String): Identity? = + Database.INSTANCE.identityDao().getAll() + .find { it.urls.any { usedUrl -> url.startsWith(usedUrl) } } + suspend fun update(vararg identities: Identity) = Database.INSTANCE.identityDao().update(*identities) @@ -62,7 +70,7 @@ object Identities { fun generateClientCert(alias: String, commonName: String) { val algo = KeyProperties.KEY_ALGORITHM_RSA - val kpg = KeyPairGenerator.getInstance(algo, "AndroidKeyStore") + val kpg = KeyPairGenerator.getInstance(algo, PROVIDER) val purposes = KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY val spec = KeyGenParameterSpec.Builder(alias, purposes) .apply { @@ -74,7 +82,9 @@ object Identities { } } } - .setDigests(KeyProperties.DIGEST_SHA256) + .setDigests(KeyProperties.DIGEST_NONE, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) .build() kpg.initialize(spec) kpg.generateKeyPair() @@ -82,7 +92,6 @@ object Identities { } private fun deleteClientCert(alias: String) { - val keyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) } if (keyStore.containsAlias(alias)) { keyStore.deleteEntry(alias) Log.i(TAG, "deleteClientCert: deleted entry with alias \"$alias\"") @@ -90,6 +99,4 @@ object Identities { Log.i(TAG, "deleteClientCert: no such alias \"$alias\"") } } - - private const val TAG = "Identities" } \ No newline at end of file diff --git a/app/src/main/java/dev/lowrespalmtree/comet/PageFragment.kt b/app/src/main/java/dev/lowrespalmtree/comet/PageFragment.kt index 0dfb564..af0e7b5 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/PageFragment.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/PageFragment.kt @@ -16,7 +16,6 @@ import androidx.activity.addCallback import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding import dev.lowrespalmtree.comet.utils.isConnectedToNetwork @@ -116,16 +115,7 @@ class PageFragment : Fragment(), PageAdapter.Listener { } when (uri.scheme) { - "gemini" -> { - val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val protocol = - prefs.getString("tls_version", Request.DEFAULT_TLS_VERSION)!! - val connectionTimeout = - prefs.getInt("connection_timeout", Request.DEFAULT_CONNECTION_TIMEOUT_SEC) - val readTimeout = - prefs.getInt("read_timeout", Request.DEFAULT_READ_TIMEOUT_SEC) - vm.sendGeminiRequest(uri, protocol, connectionTimeout, readTimeout) - } + "gemini" -> vm.sendGeminiRequest(uri, requireContext()) else -> openUnknownScheme(uri) } } @@ -163,6 +153,7 @@ class PageFragment : Fragment(), PageAdapter.Listener { when (event) { is PageViewModel.InputEvent -> { askForInput(event.prompt, event.uri) + updateState(PageViewModel.State.IDLE) } is PageViewModel.SuccessEvent -> { vm.currentUrl = event.uri @@ -200,7 +191,7 @@ class PageFragment : Fragment(), PageAdapter.Listener { val newUri = uri.buildUpon().query(text).build() openUrl(newUri.toString(), base = vm.currentUrl) }, - onDismiss = { updateState(PageViewModel.State.IDLE) } + onDismiss = {} ) } diff --git a/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt b/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt index ca0a4d1..843843f 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt @@ -1,11 +1,13 @@ package dev.lowrespalmtree.comet +import android.content.Context import android.net.Uri import android.util.Log import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager import dev.lowrespalmtree.comet.utils.joinUrls import kotlinx.coroutines.* import kotlinx.coroutines.channels.onSuccess @@ -18,18 +20,25 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState ViewModel() { /** Currently viewed page URL. */ var currentUrl: String = "" + /** Latest Uri requested using `sendGeminiRequest`. */ var loadingUrl: Uri? = null + /** Observable page viewer state. */ val state: MutableLiveData by lazy { MutableLiveData(State.IDLE) } + /** Observable page viewer lines (backed up by `linesList` but updated less often). Left element is associated URL. */ val lines: MutableLiveData>> by lazy { MutableLiveData>>() } + /** Observable page viewer latest event. */ val event: MutableLiveData by lazy { MutableLiveData() } + /** A non-saved list of visited URLs. Not an history, just used for going back. */ val visitedUrls = mutableListOf() + /** Latest request job created, stored to cancel it if needed. */ private var requestJob: Job? = null + /** Lines for the current page. */ private var linesList = ArrayList() @@ -53,14 +62,36 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState * The URI must be valid, absolute and with a gemini scheme. */ @ExperimentalCoroutinesApi - fun sendGeminiRequest(uri: Uri, protocol: String, connectionTimeout: Int, readTimeout: Int, redirects: Int = 0) { - Log.d(TAG, "sendRequest: URI \"$uri\"") + fun sendGeminiRequest( + uri: Uri, + context: Context, + redirects: Int = 0 + ) { + Log.i(TAG, "sendGeminiRequest: URI \"$uri\"") loadingUrl = uri + + // Retrieve various request parameters from user preferences. + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val protocol = + prefs.getString("tls_version", Request.DEFAULT_TLS_VERSION)!! + val connectionTimeout = + prefs.getInt("connection_timeout", Request.DEFAULT_CONNECTION_TIMEOUT_SEC) + val readTimeout = + prefs.getInt("read_timeout", Request.DEFAULT_READ_TIMEOUT_SEC) + state.postValue(State.CONNECTING) + requestJob?.apply { if (isActive) cancel() } requestJob = viewModelScope.launch(Dispatchers.IO) { + // Look for a suitable identity to use with this URL. + val keyManager = Identities.getForUrl(uri.toString())?.let { + Log.d(TAG, "sendGeminiRequest coroutine: using identity with key ${it.key}") + Request.KeyManager.fromAlias(it.key) + } + + // Connect to the server and proceed (no TOFU validation yet). val response = try { - val request = Request(uri) + val request = Request(uri, keyManager = keyManager) val socket = request.connect(protocol, connectionTimeout, readTimeout) val channel = request.proceed(socket, this) Response.from(channel, viewModelScope) @@ -80,8 +111,10 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState ) return@launch } + if (!isActive) return@launch + if (response == null) { signalError("Can't parse server response.") return@launch diff --git a/app/src/main/java/dev/lowrespalmtree/comet/Request.kt b/app/src/main/java/dev/lowrespalmtree/comet/Request.kt index 65f5d6f..823332a 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/Request.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/Request.kt @@ -8,20 +8,25 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import java.io.BufferedInputStream import java.net.InetSocketAddress +import java.net.Socket import java.net.SocketTimeoutException +import java.security.KeyStore +import java.security.Principal +import java.security.PrivateKey import java.security.cert.X509Certificate -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLProtocolException -import javax.net.ssl.SSLSocket -import javax.net.ssl.X509TrustManager +import javax.net.ssl.* -class Request(private val uri: Uri) { +class Request(private val uri: Uri, private val keyManager: KeyManager? = null) { private val port get() = if (uri.port > 0) uri.port else 1965 fun connect(protocol: String, connectionTimeout: Int, readTimeout: Int): SSLSocket { - Log.d(TAG, "connect: $protocol, c.to. $connectionTimeout, r.to. $readTimeout") + Log.d( + TAG, + "connect: $protocol, conn. timeout $connectionTimeout," + + " read timeout $readTimeout, key manager $keyManager" + ) val context = SSLContext.getInstance(protocol) - context.init(null, arrayOf(TrustManager()), null) + context.init(arrayOf(keyManager), arrayOf(TrustManager()), null) val socket = context.socketFactory.createSocket() as SSLSocket socket.soTimeout = readTimeout * 1000 socket.connect(InetSocketAddress(uri.host, port), connectionTimeout * 1000) @@ -58,6 +63,49 @@ class Request(private val uri: Uri) { return channel } + class KeyManager( + private val alias: String, + private val cert: X509Certificate, + private val privateKey: PrivateKey + ) : X509ExtendedKeyManager() { + companion object { + fun fromAlias(alias: String): KeyManager? { + val cert = Identities.keyStore.getCertificate(alias) as X509Certificate? + ?: return null.also { Log.e(TAG, "fromAlias: cert is null") } + val key = Identities.keyStore.getEntry(alias, null)?.let { entry -> + (entry as KeyStore.PrivateKeyEntry).privateKey + } ?: return null.also { Log.e(TAG, "fromAlias: private key is null") } + return KeyManager(alias, cert, key) + } + } + + override fun chooseClientAlias( + keyType: Array?, + issuers: Array?, + socket: Socket? + ): String = alias + + override fun getCertificateChain(alias: String?): Array = arrayOf(cert) + + override fun getPrivateKey(alias: String?): PrivateKey = privateKey + + override fun getServerAliases( + keyType: String?, issuers: Array? + ): Array = throw UnsupportedOperationException() + + override fun chooseServerAlias( + keyType: String?, + issuers: Array?, + socket: Socket? + ): String = throw UnsupportedOperationException() + + override fun getClientAliases( + keyType: String?, + issuers: Array? + ): Array = throw UnsupportedOperationException() + } + + /** TODO An X509TrustManager implementation for TOFU validation. */ @SuppressLint("CustomX509TrustManager") class TrustManager : X509TrustManager { @SuppressLint("TrustAllX509TrustManager") diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 3d92051..503995d 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -8,8 +8,6 @@ #073642 #2aa198 #073642 - #268bd2 - #2aa198 #fdf6e3 #586e75 \ No newline at end of file