Skip to content

Commit 2a603ae

Browse files
committed
Add client certificate support for Linkding (mTLS)
- Add `LinkdingClientCertKeyManager`: a `X509ExtendedKeyManager` implementation backed by `android.security.KeyChain` - Add `LinkdingSSLSocketFactory`: a `SSLSocketFactory` implementation to control the lifecycle of TLS sessions as the user certificate changes - Update the `NetworkModule` to provide a `HttpClientBuilder` instead of a base `HttpClient`, allowing proper customization - Update `LinkdingModule` to always register the new socket factory in Linkding's `HttpClient` - Update app storage to persist the selected client cert alias - Update `AuthScreen` and `AccountSwitcherScreen ` to allow selecting a certificate using `android.security.KeyChain`
1 parent cf22dd1 commit 2a603ae

File tree

24 files changed

+777
-132
lines changed

24 files changed

+777
-132
lines changed

app/src/androidTest/kotlin/com/fibelatti/pinboard/core/di/modules/TestLinkdingModule.kt

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.fibelatti.pinboard.core.di.modules
33
import com.fibelatti.pinboard.LinkdingMockServer
44
import com.fibelatti.pinboard.core.di.RestApi
55
import com.fibelatti.pinboard.core.di.RestApiProvider
6+
import com.fibelatti.pinboard.core.network.LinkdingSSLSocketFactory
67
import com.fibelatti.pinboard.core.network.UnauthorizedPluginProvider
78
import com.fibelatti.pinboard.features.user.domain.UserRepository
89
import dagger.Module
@@ -15,6 +16,8 @@ import io.ktor.client.request.accept
1516
import io.ktor.client.request.header
1617
import io.ktor.http.ContentType
1718
import io.ktor.http.URLProtocol
19+
import javax.inject.Singleton
20+
import okhttp3.ConnectionSpec
1821

1922
@Module
2023
@TestInstallIn(
@@ -24,15 +27,20 @@ import io.ktor.http.URLProtocol
2427
object TestLinkdingModule {
2528

2629
@Provides
30+
@Singleton
2731
@RestApi(RestApiProvider.LINKDING)
2832
fun linkdingHttpClient(
29-
@RestApi(RestApiProvider.BASE) httpClient: HttpClient,
33+
httpClientBuilder: HttpClientBuilder,
3034
userRepository: UserRepository,
3135
unauthorizedPluginProvider: UnauthorizedPluginProvider,
32-
): HttpClient {
33-
val mockServerUrl = LinkdingMockServer.instance.url("/")
36+
linkdingSSLSocketFactory: LinkdingSSLSocketFactory,
37+
): HttpClient = httpClientBuilder.build(
38+
extraHttpClientConfig = {
39+
install(unauthorizedPluginProvider.plugin)
40+
41+
expectSuccess = true
3442

35-
return httpClient.config {
43+
val mockServerUrl = LinkdingMockServer.instance.url("/")
3644
defaultRequest {
3745
val credentials = userRepository.userCredentials.value
3846

@@ -46,8 +54,22 @@ object TestLinkdingModule {
4654
}
4755
accept(ContentType.Application.Json)
4856
}
57+
},
58+
extraOkHttpClientConfig = {
59+
connectionSpecs(
60+
listOf(
61+
ConnectionSpec.COMPATIBLE_TLS,
62+
// Linkding instances can be self-hosted and use insecure connections
63+
ConnectionSpec.CLEARTEXT,
64+
),
65+
)
4966

50-
install(unauthorizedPluginProvider.plugin)
51-
}
52-
}
67+
// Always registering the factory to avoid having to recreate objects if the alias
68+
// changes. It uses the default behavior when there's no alias set by the user.
69+
sslSocketFactory(
70+
sslSocketFactory = linkdingSSLSocketFactory,
71+
trustManager = linkdingSSLSocketFactory.trustManager,
72+
)
73+
},
74+
)
5375
}

app/src/androidTest/kotlin/com/fibelatti/pinboard/core/di/modules/TestPinboardModule.kt

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,25 @@ import com.fibelatti.pinboard.PinboardMockServer
44
import com.fibelatti.pinboard.core.di.RestApi
55
import com.fibelatti.pinboard.core.di.RestApiProvider
66
import com.fibelatti.pinboard.core.network.UnauthorizedPluginProvider
7+
import com.fibelatti.pinboard.features.posts.data.model.GenericResponseDto
8+
import com.fibelatti.pinboard.features.posts.data.model.isDone
79
import com.fibelatti.pinboard.features.user.domain.UserRepository
810
import dagger.Module
911
import dagger.Provides
1012
import dagger.hilt.components.SingletonComponent
1113
import dagger.hilt.testing.TestInstallIn
1214
import io.ktor.client.HttpClient
15+
import io.ktor.client.call.body
16+
import io.ktor.client.plugins.HttpResponseValidator
17+
import io.ktor.client.plugins.ResponseException
1318
import io.ktor.client.plugins.defaultRequest
1419
import io.ktor.client.request.accept
1520
import io.ktor.http.ContentType
21+
import io.ktor.http.HttpStatusCode
1622
import io.ktor.http.URLProtocol
23+
import javax.inject.Singleton
24+
import okhttp3.CipherSuite
25+
import okhttp3.ConnectionSpec
1726

1827
@Module
1928
@TestInstallIn(
@@ -23,15 +32,17 @@ import io.ktor.http.URLProtocol
2332
object TestPinboardModule {
2433

2534
@Provides
35+
@Singleton
2636
@RestApi(RestApiProvider.PINBOARD)
2737
fun pinboardHttpClient(
28-
@RestApi(RestApiProvider.BASE) httpClient: HttpClient,
38+
httpClientBuilder: HttpClientBuilder,
2939
userRepository: UserRepository,
3040
unauthorizedPluginProvider: UnauthorizedPluginProvider,
31-
): HttpClient {
32-
val mockServerUrl = PinboardMockServer.instance.url("/")
41+
): HttpClient = httpClientBuilder.build(
42+
extraHttpClientConfig = {
43+
install(unauthorizedPluginProvider.plugin)
3344

34-
return httpClient.config {
45+
val mockServerUrl = PinboardMockServer.instance.url("/")
3546
defaultRequest {
3647
val credentials = userRepository.userCredentials.value
3748

@@ -48,7 +59,41 @@ object TestPinboardModule {
4859
accept(ContentType.Application.Json)
4960
}
5061

51-
install(unauthorizedPluginProvider.plugin)
52-
}
53-
}
62+
HttpResponseValidator {
63+
validateResponse { response ->
64+
// Unfortunately nothing can be done if the server is acting up.
65+
if (response.status == HttpStatusCode.InternalServerError) {
66+
runCatching {
67+
// Although, the action may have succeeded despite the 500 status code.
68+
if (response.body<GenericResponseDto>().isDone) {
69+
// In that case, no need to abort.
70+
return@validateResponse
71+
}
72+
}
73+
74+
// A `ResponseException` is used when handling exceptions to notify users.
75+
throw ResponseException(response, "")
76+
}
77+
}
78+
}
79+
},
80+
extraOkHttpClientConfig = {
81+
// These are the server preferred Ciphers + all the ones included in COMPATIBLE_TLS
82+
val cipherSuites: List<CipherSuite> = listOf(
83+
CipherSuite.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256,
84+
CipherSuite.TLS_DHE_RSA_WITH_AES_256_GCM_SHA384,
85+
CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
86+
CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
87+
) + ConnectionSpec.COMPATIBLE_TLS.cipherSuites.orEmpty()
88+
89+
connectionSpecs(
90+
listOf(
91+
ConnectionSpec.Builder(ConnectionSpec.COMPATIBLE_TLS)
92+
.cipherSuites(*cipherSuites.toTypedArray())
93+
.build(),
94+
ConnectionSpec.CLEARTEXT,
95+
),
96+
)
97+
},
98+
)
5499
}

app/src/main/kotlin/com/fibelatti/pinboard/core/di/RestApi.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import javax.inject.Qualifier
88
annotation class RestApi(val restApi: RestApiProvider)
99

1010
enum class RestApiProvider {
11-
BASE,
11+
COMMON,
1212
PINBOARD,
1313
LINKDING,
1414
}

app/src/main/kotlin/com/fibelatti/pinboard/core/di/modules/LinkdingModule.kt

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,54 +2,64 @@ package com.fibelatti.pinboard.core.di.modules
22

33
import com.fibelatti.pinboard.core.di.RestApi
44
import com.fibelatti.pinboard.core.di.RestApiProvider
5+
import com.fibelatti.pinboard.core.network.LinkdingSSLSocketFactory
56
import com.fibelatti.pinboard.core.network.UnauthorizedPluginProvider
67
import com.fibelatti.pinboard.features.user.domain.UserRepository
78
import dagger.Module
89
import dagger.Provides
910
import dagger.hilt.InstallIn
1011
import dagger.hilt.components.SingletonComponent
1112
import io.ktor.client.HttpClient
12-
import io.ktor.client.engine.okhttp.OkHttpConfig
1313
import io.ktor.client.plugins.defaultRequest
1414
import io.ktor.client.request.accept
1515
import io.ktor.client.request.header
1616
import io.ktor.http.ContentType
17+
import javax.inject.Singleton
1718
import okhttp3.ConnectionSpec
1819

1920
@Module
2021
@InstallIn(SingletonComponent::class)
2122
object LinkdingModule {
2223

2324
@Provides
25+
@Singleton
2426
@RestApi(RestApiProvider.LINKDING)
2527
fun linkdingHttpClient(
26-
@RestApi(RestApiProvider.BASE) httpClient: HttpClient,
28+
httpClientBuilder: HttpClientBuilder,
2729
userRepository: UserRepository,
2830
unauthorizedPluginProvider: UnauthorizedPluginProvider,
29-
): HttpClient = httpClient.config {
30-
engine {
31-
(this as OkHttpConfig).config {
32-
connectionSpecs(
33-
listOf(
34-
ConnectionSpec.COMPATIBLE_TLS,
35-
ConnectionSpec.CLEARTEXT,
36-
),
37-
)
38-
}
39-
}
31+
linkdingSSLSocketFactory: LinkdingSSLSocketFactory,
32+
): HttpClient = httpClientBuilder.build(
33+
extraHttpClientConfig = {
34+
install(unauthorizedPluginProvider.plugin)
4035

41-
defaultRequest {
42-
val credentials = userRepository.userCredentials.value
36+
expectSuccess = true
4337

44-
url(requireNotNull(credentials.linkdingInstanceUrl))
45-
credentials.linkdingAuthToken?.let { token ->
46-
header("Authorization", "Token $token")
47-
}
48-
accept(ContentType.Application.Json)
49-
}
38+
defaultRequest {
39+
val credentials = userRepository.userCredentials.value
5040

51-
expectSuccess = true
41+
url(requireNotNull(credentials.linkdingInstanceUrl))
42+
credentials.linkdingAuthToken?.let { token ->
43+
header("Authorization", "Token $token")
44+
}
45+
accept(ContentType.Application.Json)
46+
}
47+
},
48+
extraOkHttpClientConfig = {
49+
connectionSpecs(
50+
listOf(
51+
ConnectionSpec.COMPATIBLE_TLS,
52+
// Linkding instances can be self-hosted and use insecure connections
53+
ConnectionSpec.CLEARTEXT,
54+
),
55+
)
5256

53-
install(unauthorizedPluginProvider.plugin)
54-
}
57+
// Always registering the factory to avoid having to recreate objects if the alias
58+
// changes. It uses the default behavior when there's no alias set by the user.
59+
sslSocketFactory(
60+
sslSocketFactory = linkdingSSLSocketFactory,
61+
trustManager = linkdingSSLSocketFactory.trustManager,
62+
)
63+
},
64+
)
5565
}

app/src/main/kotlin/com/fibelatti/pinboard/core/di/modules/NetworkModule.kt

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import dagger.hilt.InstallIn
2222
import dagger.hilt.android.qualifiers.ApplicationContext
2323
import dagger.hilt.components.SingletonComponent
2424
import io.ktor.client.HttpClient
25+
import io.ktor.client.HttpClientConfig
2526
import io.ktor.client.engine.okhttp.OkHttp
2627
import io.ktor.client.plugins.cache.HttpCache
2728
import io.ktor.client.plugins.cache.storage.FileStorage
@@ -64,46 +65,62 @@ object NetworkModule {
6465
}
6566

6667
@Provides
67-
@RestApi(RestApiProvider.BASE)
68-
fun baseHttpClient(
68+
@Singleton
69+
@RestApi(RestApiProvider.COMMON)
70+
fun commonHttpClient(builder: HttpClientBuilder): HttpClient = builder.build()
71+
72+
@Provides
73+
@Singleton
74+
fun httpClientBuilder(
6975
json: Json,
7076
threadStatsTagInterceptor: Interceptor,
7177
@ApplicationContext context: Context,
72-
): HttpClient = HttpClient(OkHttp) {
73-
engine {
74-
config {
75-
connectionPool(
76-
ConnectionPool(
77-
maxIdleConnections = 0,
78-
keepAliveDuration = 5,
79-
timeUnit = TimeUnit.MINUTES,
80-
),
81-
)
82-
83-
connectTimeout(60, TimeUnit.SECONDS)
84-
readTimeout(30, TimeUnit.SECONDS)
85-
writeTimeout(30, TimeUnit.SECONDS)
86-
87-
followRedirects(true)
88-
followSslRedirects(true)
89-
90-
addInterceptor(threadStatsTagInterceptor)
78+
): HttpClientBuilder = object : HttpClientBuilder {
79+
80+
override fun build(
81+
extraHttpClientConfig: HttpClientConfig<*>.() -> Unit,
82+
extraOkHttpClientConfig: OkHttpClient.Builder.() -> Unit,
83+
): HttpClient = HttpClient(OkHttp) {
84+
install(PinboardResponseFixerPlugin)
85+
86+
install(ContentNegotiation) {
87+
json(json, contentType = ContentType.Any)
9188
}
92-
}
9389

94-
install(PinboardResponseFixerPlugin)
95-
install(ContentNegotiation) {
96-
json(json, contentType = ContentType.Any)
97-
}
90+
install(HttpCache) {
91+
publicStorage(FileStorage(File("${context.cacheDir}/http-cache")))
92+
}
9893

99-
install(HttpCache) {
100-
publicStorage(FileStorage(File("${context.cacheDir}/http-cache")))
101-
}
94+
extraHttpClientConfig()
10295

103-
if (BuildConfig.DEBUG) {
104-
install(Logging) {
105-
level = LogLevel.INFO
106-
logger = Logger.ANDROID
96+
if (BuildConfig.DEBUG) {
97+
install(Logging) {
98+
level = LogLevel.INFO
99+
logger = Logger.ANDROID
100+
}
101+
}
102+
103+
engine {
104+
config {
105+
connectionPool(
106+
ConnectionPool(
107+
maxIdleConnections = 0,
108+
keepAliveDuration = 5,
109+
timeUnit = TimeUnit.MINUTES,
110+
),
111+
)
112+
113+
connectTimeout(60, TimeUnit.SECONDS)
114+
readTimeout(30, TimeUnit.SECONDS)
115+
writeTimeout(30, TimeUnit.SECONDS)
116+
117+
followRedirects(true)
118+
followSslRedirects(true)
119+
120+
addInterceptor(threadStatsTagInterceptor)
121+
122+
extraOkHttpClientConfig(this)
123+
}
107124
}
108125
}
109126
}
@@ -143,3 +160,11 @@ object NetworkModule {
143160
.crossfade(enable = true)
144161
.build()
145162
}
163+
164+
interface HttpClientBuilder {
165+
166+
fun build(
167+
extraHttpClientConfig: HttpClientConfig<*>.() -> Unit = {},
168+
extraOkHttpClientConfig: OkHttpClient.Builder.() -> Unit = {},
169+
): HttpClient
170+
}

0 commit comments

Comments
 (0)