Skip to content

Commit c7cc4f8

Browse files
authored
[ok_http] Add support for client certificates using Java PrivateKeys (#1444)
1 parent 6d99ff5 commit c7cc4f8

File tree

23 files changed

+29094
-7011
lines changed

23 files changed

+29094
-7011
lines changed

.github/workflows/okhttp.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
- uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b
3333
with:
3434
distribution: 'zulu'
35-
java-version: '17'
35+
java-version: '21'
3636
- uses: subosito/flutter-action@44ac965b96f18d999802d4b807e3256d5a3f9fa1
3737
with:
3838
channel: 'stable'
@@ -50,7 +50,7 @@ jobs:
5050
if: always() && steps.install.outcome == 'success'
5151
with:
5252
# api-level/minSdkVersion should be help in sync in:
53-
# - .github/workflows/ok.yml
53+
# - .github/workflows/okhttp.yml
5454
# - pkgs/ok_http/android/build.gradle
5555
# - pkgs/ok_http/example/android/app/build.gradle
5656
api-level: 21

pkgs/cronet_http/android/build.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ buildscript {
99
}
1010

1111
dependencies {
12-
classpath 'com.android.tools.build:gradle:8.1.0'
12+
classpath 'com.android.tools.build:gradle:8.9.0'
1313
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
1414
}
1515
}

pkgs/flutter_http_example/android/gradle/wrapper/gradle-wrapper.properties

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
4+
networkTimeout=10000
5+
validateDistributionUrl=true
36
zipStoreBase=GRADLE_USER_HOME
47
zipStorePath=wrapper/dists
5-
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip

pkgs/http_client_conformance_tests/lib/src/response_headers_tests.dart

+7-5
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ void testResponseHeaders(Client client,
163163
response.headers['foo'],
164164
anyOf(
165165
'1 2', // RFC-specified behavior
166-
'1' // Common client behavior.
167-
));
166+
// Common client behavior (Cronet, Apple URL Loading System).
167+
'1'));
168168
} on ClientException {
169169
// The client rejected the response, which is allowed per RFC-9110.
170170
}
@@ -178,9 +178,11 @@ void testResponseHeaders(Client client,
178178
expect(
179179
response.headers['foo'],
180180
anyOf(
181-
'1 2', // RFC-specified behavior
182-
'1' // Common client behavior.
183-
));
181+
'1 2', // RFC-specified behavior
182+
// Common client behavior (Cronet, Apple URL Loading System).
183+
'1',
184+
'1\r2', // Common client behavior (Java).
185+
));
184186
} on ClientException {
185187
// The client rejected the response, which is allowed per RFC-9110.
186188
}

pkgs/ok_http/CHANGELOG.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
- `OkHttpClient` now receives an `OkHttpClientConfiguration` to configure the client on a per-call basis.
44
- `OkHttpClient` supports setting four types of timeouts: [`connectTimeout`](https://square.github.io/okhttp/5.x/okhttp/okhttp3/-ok-http-client/-builder/connect-timeout.html), [`readTimeout`](https://square.github.io/okhttp/5.x/okhttp/okhttp3/-ok-http-client/-builder/read-timeout.html), [`writeTimeout`](https://square.github.io/okhttp/5.x/okhttp/okhttp3/-ok-http-client/-builder/write-timeout.html), and [`callTimeout`](https://square.github.io/okhttp/5.x/okhttp/okhttp3/-ok-http-client/-builder/call-timeout.html), using the `OkHttpClientConfiguration`.
5-
- Update to `jnigen` 0.12.2
5+
- Upgrade to `jni` 0.13.0
6+
- Upgrade to `jnigen` 0.13.1
7+
- `OKHttpClient` supports client certificates.
68

79
## 0.1.0
810

pkgs/ok_http/android/build.gradle

+5-6
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,15 @@ group = "com.example.ok_http"
44
version = "1.0"
55

66
buildscript {
7-
// Required to support `okhttp:4.12.0`.
8-
ext.kotlin_version = '1.9.23'
7+
ext.kotlin_version = '2.1.10'
98
repositories {
109
google()
1110
mavenCentral()
1211
}
1312

1413
dependencies {
1514
// The Android Gradle Plugin knows how to build native code with the NDK.
16-
classpath("com.android.tools.build:gradle:7.3.0")
15+
classpath("com.android.tools.build:gradle:8.1.2")
1716
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
1817
}
1918
}
@@ -34,7 +33,7 @@ android {
3433
}
3534

3635
kotlinOptions {
37-
jvmTarget = '1.8'
36+
jvmTarget = JavaVersion.VERSION_21
3837
}
3938

4039
sourceSets {
@@ -52,8 +51,8 @@ android {
5251
ndkVersion = android.ndkVersion
5352

5453
compileOptions {
55-
sourceCompatibility = JavaVersion.VERSION_1_8
56-
targetCompatibility = JavaVersion.VERSION_1_8
54+
sourceCompatibility = JavaVersion.VERSION_21
55+
targetCompatibility = JavaVersion.VERSION_21
5756
}
5857

5958
defaultConfig {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
package com.example.ok_http
6+
7+
import java.net.Socket
8+
import java.security.Principal
9+
import java.security.PrivateKey
10+
import java.security.cert.X509Certificate
11+
import javax.net.ssl.SSLEngine
12+
import javax.net.ssl.X509ExtendedKeyManager
13+
14+
/**
15+
* A `X509ExtendedKeyManager` that always responds with the configured
16+
* private key, certificate chain, and alias.
17+
*/
18+
class FixedResponseX509ExtendedKeyManager(
19+
private val certificateChain: Array<X509Certificate>,
20+
private val privateKey: PrivateKey,
21+
private val alias: String,
22+
) : X509ExtendedKeyManager() {
23+
24+
override fun getClientAliases(keyType: String, issuers: Array<Principal>?) = arrayOf(alias)
25+
26+
override fun chooseClientAlias(
27+
keyType: Array<String>,
28+
issuers: Array<Principal>?,
29+
socket: Socket?,
30+
) = alias
31+
32+
override fun getServerAliases(keyType: String, issuers: Array<Principal>?) = arrayOf(alias)
33+
34+
override fun chooseServerAlias(
35+
keyType: String,
36+
issuers: Array<Principal>?,
37+
socket: Socket?,
38+
) = alias
39+
40+
override fun getCertificateChain(alias: String) = certificateChain
41+
42+
override fun getPrivateKey(alias: String) = privateKey
43+
44+
override fun chooseEngineClientAlias(
45+
keyType: Array<String?>?,
46+
issuers: Array<Principal?>?,
47+
engine: SSLEngine?,
48+
) = alias
49+
50+
override fun chooseEngineServerAlias(
51+
keyType: String?,
52+
issuers: Array<Principal?>?,
53+
engine: SSLEngine?
54+
) = alias
55+
}

pkgs/ok_http/example/android/app/build.gradle

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ android {
2929
ndkVersion = flutter.ndkVersion
3030

3131
compileOptions {
32-
sourceCompatibility = JavaVersion.VERSION_1_8
33-
targetCompatibility = JavaVersion.VERSION_1_8
32+
sourceCompatibility = JavaVersion.VERSION_21
33+
targetCompatibility = JavaVersion.VERSION_21
3434
}
3535

3636
defaultConfig {

pkgs/ok_http/example/android/gradle/wrapper/gradle-wrapper.properties

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
33
zipStoreBase=GRADLE_USER_HOME
44
zipStorePath=wrapper/dists
5-
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
5+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip

pkgs/ok_http/example/android/settings.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pluginManagement {
1818

1919
plugins {
2020
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
21-
id "com.android.application" version "7.3.0" apply false
21+
id "com.android.application" version "8.2.1" apply false
2222
id "org.jetbrains.kotlin.android" version "1.9.23" apply false
2323
}
2424

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:async';
6+
import 'dart:io' as io;
7+
import 'dart:typed_data';
8+
9+
import 'package:flutter/services.dart' show rootBundle;
10+
import 'package:http/http.dart';
11+
import 'package:integration_test/integration_test.dart';
12+
import 'package:ok_http/ok_http.dart';
13+
import 'package:ok_http/src/jni/bindings.dart' as bindings;
14+
15+
import 'package:test/test.dart';
16+
17+
Future<Uint8List> loadCertificateBytes(String path) async {
18+
return (await rootBundle.load(path)).buffer.asUint8List();
19+
}
20+
21+
void main() async {
22+
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
23+
24+
group('TLS', () {
25+
group('loadPrivateKeyAndCertificateChainFromPKCS12', () {
26+
test('success', () async {
27+
final certBytes =
28+
await loadCertificateBytes('test_certs/test-combined.p12');
29+
final (key, chain) =
30+
loadPrivateKeyAndCertificateChainFromPKCS12(certBytes, '1234');
31+
expect(
32+
key
33+
.as(bindings.Key.type)
34+
.getFormat()!
35+
.toDartString(releaseOriginal: true),
36+
'PKCS#8');
37+
expect(chain.length, 1);
38+
expect(chain[0].getType()!.toDartString(), 'X.509');
39+
});
40+
41+
test('bad password', () async {
42+
final certBytes =
43+
await loadCertificateBytes('test_certs/test-combined.p12');
44+
expect(
45+
() => loadPrivateKeyAndCertificateChainFromPKCS12(
46+
certBytes, 'incorrectpassword'),
47+
throwsA(isA<io.IOException>().having(
48+
(e) => e.toString(), 'toString', contains('password'))));
49+
});
50+
51+
test('bad PKCS12 data', () async {
52+
final certBytes = Uint8List.fromList([1, 2, 3, 4]);
53+
expect(
54+
() =>
55+
loadPrivateKeyAndCertificateChainFromPKCS12(certBytes, '1234'),
56+
throwsA(isA<io.IOException>().having(
57+
(e) => e.toString(),
58+
'toString',
59+
contains('does not represent a PKCS12 key store'))));
60+
});
61+
});
62+
63+
test('unknown server cert', () async {
64+
final serverContext = io.SecurityContext()
65+
..useCertificateChainBytes(
66+
await loadCertificateBytes('test_certs/server_chain.p12'),
67+
password: 'dartdart')
68+
..usePrivateKeyBytes(
69+
await loadCertificateBytes('test_certs/server_key.p12'),
70+
password: 'dartdart');
71+
final server =
72+
await io.SecureServerSocket.bind('localhost', 0, serverContext);
73+
final serverException = Completer<void>();
74+
server.listen((socket) async {
75+
serverException.complete();
76+
await socket.close();
77+
}, onError: (Object e) {
78+
serverException.completeError(e);
79+
});
80+
addTearDown(server.close);
81+
82+
final config =
83+
const OkHttpClientConfiguration(validateServerCertificates: true);
84+
final httpClient = OkHttpClient(configuration: config);
85+
addTearDown(httpClient.close);
86+
87+
expect(
88+
() async =>
89+
await httpClient.get(Uri.https('localhost:${server.port}', '/')),
90+
throwsA(isA<ClientException>()
91+
.having((e) => e.message, 'message', contains('Handshake'))));
92+
expect(
93+
() async => await serverException.future,
94+
throwsA(isA<io.HandshakeException>()
95+
.having((e) => e.message, 'message', contains('Handshake'))));
96+
});
97+
98+
test('ignore unknown server cert', () async {
99+
final serverContext = io.SecurityContext()
100+
..useCertificateChainBytes(
101+
await loadCertificateBytes('test_certs/server_chain.p12'),
102+
password: 'dartdart')
103+
..usePrivateKeyBytes(
104+
await loadCertificateBytes('test_certs/server_key.p12'),
105+
password: 'dartdart');
106+
final server =
107+
await io.SecureServerSocket.bind('localhost', 0, serverContext);
108+
server.listen((socket) async {
109+
socket
110+
.writeAll(['HTTP/1.1 200 OK', 'Content-Length: 0', '\r\n'], '\r\n');
111+
await socket.close();
112+
});
113+
addTearDown(server.close);
114+
115+
final config =
116+
const OkHttpClientConfiguration(validateServerCertificates: false);
117+
final httpClient = OkHttpClient(configuration: config);
118+
addTearDown(httpClient.close);
119+
120+
expect(
121+
(await httpClient.get(Uri.https('localhost:${server.port}', '/')))
122+
.statusCode,
123+
200);
124+
});
125+
126+
test('client cert', () async {
127+
final certBytes =
128+
await loadCertificateBytes('test_certs/test-combined.p12');
129+
final serverContext = io.SecurityContext()
130+
..useCertificateChainBytes(
131+
await loadCertificateBytes('test_certs/server_chain.p12'),
132+
password: 'dartdart')
133+
..usePrivateKeyBytes(
134+
await loadCertificateBytes('test_certs/server_key.p12'),
135+
password: 'dartdart')
136+
..setTrustedCertificatesBytes(certBytes, password: '1234');
137+
138+
final clientCertificate = Completer<io.X509Certificate?>();
139+
final server = await io.SecureServerSocket.bind(
140+
'localhost', 0, serverContext,
141+
requireClientCertificate: true);
142+
server.listen((socket) async {
143+
clientCertificate.complete(socket.peerCertificate);
144+
socket
145+
.writeAll(['HTTP/1.1 200 OK', 'Content-Length: 0', '\r\n'], '\r\n');
146+
await socket.close();
147+
});
148+
addTearDown(server.close);
149+
150+
final (key, chain) =
151+
loadPrivateKeyAndCertificateChainFromPKCS12(certBytes, '1234');
152+
final config = OkHttpClientConfiguration(
153+
clientPrivateKey: key,
154+
clientCertificateChain: chain,
155+
validateServerCertificates: false);
156+
final httpClient = OkHttpClient(configuration: config);
157+
addTearDown(httpClient.close);
158+
159+
expect(
160+
(await httpClient.get(Uri.https('localhost:${server.port}', '/')))
161+
.statusCode,
162+
200);
163+
expect((await clientCertificate.future)!.issuer,
164+
contains('Internet Widgits Pty Ltd'));
165+
});
166+
167+
test('private key without cert chain', () async {
168+
final certBytes =
169+
await loadCertificateBytes('test_certs/test-combined.p12');
170+
171+
final (key, chain) =
172+
loadPrivateKeyAndCertificateChainFromPKCS12(certBytes, '1234');
173+
final config = OkHttpClientConfiguration(clientPrivateKey: key);
174+
expect(() => OkHttpClient(configuration: config), throwsArgumentError);
175+
});
176+
177+
test('private key without cert chain', () async {
178+
final certBytes =
179+
await loadCertificateBytes('test_certs/test-combined.p12');
180+
181+
final (key, chain) =
182+
loadPrivateKeyAndCertificateChainFromPKCS12(certBytes, '1234');
183+
final config = OkHttpClientConfiguration(clientCertificateChain: chain);
184+
expect(() => OkHttpClient(configuration: config), throwsArgumentError);
185+
});
186+
});
187+
}

pkgs/ok_http/example/integration_test/client_test.dart

+2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Future<void> testConformance() async {
2828
supportsFoldedHeaders: false,
2929
canSendCookieHeaders: true,
3030
canReceiveSetCookieHeaders: true,
31+
correctlyHandlesNullHeaderValues: false,
3132
);
3233
} finally {
3334
HttpClientRequestProfile.profilingEnabled = profile;
@@ -46,6 +47,7 @@ Future<void> testConformance() async {
4647
supportsFoldedHeaders: false,
4748
canSendCookieHeaders: true,
4849
canReceiveSetCookieHeaders: true,
50+
correctlyHandlesNullHeaderValues: false,
4951
);
5052
} finally {
5153
HttpClientRequestProfile.profilingEnabled = profile;

0 commit comments

Comments
 (0)