Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Android's certificate verification JNI layer #2251

Merged
merged 12 commits into from
May 24, 2022
2 changes: 2 additions & 0 deletions library/common/jni/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ cc_library(
],
deps = [
"//library/common/jni/import:jni_import_lib",
"//library/common/strings:string_conversions_lib",
"//library/common/types:c_types_lib",
"@envoy//source/common/common:assert_lib",
],
Expand Down Expand Up @@ -160,6 +161,7 @@ cc_library(
],
deps = [
":java_jni_support",
"//library/common/strings:string_conversions_lib",
"//library/common/jni/import:jni_import_lib",
"//library/common:envoy_main_interface_lib",
"//library/common/types:c_types_lib",
Expand Down
6 changes: 6 additions & 0 deletions library/common/jni/jni_interface.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,12 @@ jobjectArray jvm_cert_get_certificate_chain_encoded(JNIEnv* env, jobject result)
env->CallObjectMethod(jcls_AndroidCertVerifyResult, jmid_getCertificateChainEncoded, result));
}

// Once we have a better picture of how Android's certificate verification will
// be plugged into EM, we should decide where this function should really live.
// Context: as of now JNI functions declared in this file are not exported through any
// header files, instead they are stored as callbacks into plain function
// tables. For this reason, this function, which would ideally be defined in
// jni_utility.cc, is currently defined here.
void ExtractCertVerifyResult(JNIEnv* env, jobject result,
StefanoDuo marked this conversation as resolved.
Show resolved Hide resolved
envoy_cert_verify_status_android_t* status,
bool* is_issued_by_known_root,
Expand Down
22 changes: 12 additions & 10 deletions library/common/jni/jni_utility.cc
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
#include <stdlib.h>
#include <string.h>

#include <codecvt>

#include "source/common/common/assert.h"

#include "library/common/jni/jni_support.h"
#include "library/common/jni/jni_version.h"
#include "library/common/strings/string_conversions.h"

// NOLINT(namespace-envoy)

Expand Down Expand Up @@ -259,8 +258,9 @@ envoy_map to_native_map(JNIEnv* env, jobjectArray entries) {
}

jstring ConvertUTF8ToJavaString(JNIEnv* env, const std::string& str) {
std::u16string utf16 =
std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t>{}.from_bytes(str);
// JNI's NewStringUTF expects "modified" UTF8 so instead convert the string to UTF16 and pass it
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OOC, what is "modified UTF8"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIU it's what is used by the JVM under the hood https://docs.oracle.com/javase/1.5.0/docs/guide/jni/spec/types.html#wp16542.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Wowwwww! That is wacky! I didn't realize "Modified UTF-8" was term of art. Wikipedia also has a good summary.

Modified UTF-8 (MUTF-8) originated in the Java programming language. In Modified UTF-8, the null character (U+0000) uses the two-byte overlong encoding 11000000 10000000 (hexadecimal C0 80), instead of 00000000 (hexadecimal 00).[79] Modified UTF-8 strings never contain any actual null bytes but can contain all Unicode code points including U+0000,[80] which allows such strings (with a null byte appended) to be processed by traditional null-terminated string functions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid surprises due to the weirdness of Modified UTF-8, the convention we've adopted a this layer is to pass true UTF-8 byte arrays across the JNI and then encode/decode them to java.lang.Strings on the Java side. For simplicity's sake (and perhaps to avoid the risk of extra logic manipulating buffers) it would be nice to maintain that convention, if possible, rather than introduce Modified UTF-8 handling in C++.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I wasn't aware of that, my bad. Can you point me to a specific example so I can more closely follow your approach?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a simple example that shows the JNI side (this supports an implementation of a simple extension to retrieve runtime-defined platform Strings):

static envoy_data jvm_get_string(const void* context) {

And here is the Java side:

https://github.com/envoyproxy/envoy-mobile/blob/aa0beeea7079dbc6c9021f9bd6fd4801dbb1381a/library/java/io/envoyproxy/envoymobile/engine/JvmStringAccessorContext.java

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the code to pass true UTF-8 byte arrays across the JNI interface as you suggested.

I don't think I need to wrap stuff in an accessor for my use case (the strings are only needed for the duration of the JNI call). Let me know if this looks okay to you!

// to NewString.
std::u16string utf16 = UTF8ToUTF16(str.data(), str.size());
const jchar* jutf16 = reinterpret_cast<const jchar*>(utf16.data());
return env->NewString(jutf16, utf16.length());
}
Expand Down Expand Up @@ -292,8 +292,8 @@ void JavaArrayOfByteArrayToStringVector(JNIEnv* env, jobjectArray array,
for (size_t i = 0; i < len; ++i) {
jbyteArray bytes_array = static_cast<jbyteArray>(env->GetObjectArrayElement(array, i));
jsize bytes_len = env->GetArrayLength(bytes_array);
// We don't care whether the returned array is a copy or not as we're simply
// copying it into C++ owned memory.
// It doesn't matter if the array returned by GetByteArrayElements is a copy
// or not, as the data will be simply be copied into C++ owned memory below.
jbyte* bytes = env->GetByteArrayElements(bytes_array, /*isCopy=*/nullptr);
(*out)[i].assign(reinterpret_cast<const char*>(bytes), bytes_len);
// There is nothing to write back, it is always safe to JNI_ABORT.
Expand All @@ -307,8 +307,8 @@ void JavaArrayOfByteToBytesVector(JNIEnv* env, jbyteArray array, std::vector<uin
const size_t len = env->GetArrayLength(array);
out->resize(len);

// We don't care whether the returned array is a copy or not as we're simply
// copying it into C++ owned memory.
// It doesn't matter if the array returned by GetByteArrayElements is a copy
// or not, as the data will be simply be copied into C++ owned memory below.
jbyte* jbytes = env->GetByteArrayElements(array, /*isCopy=*/nullptr);
uint8_t* bytes = reinterpret_cast<uint8_t*>(jbytes);
std::copy(bytes, bytes + len, out->begin());
Expand All @@ -317,11 +317,13 @@ void JavaArrayOfByteToBytesVector(JNIEnv* env, jbyteArray array, std::vector<uin
}

void ConvertJavaStringToUTF8(JNIEnv* env, jstring str, std::string* result) {
// JNI's GetStringUTFChars() returns strings in Java "modified" UTF8, so
// instead get the String in UTF16 and manually convert that to UTF8.
// We don't care whether the returned string is a copy or not as we're simply
// copying it into C++ owned memory.
const jchar* utf16 = env->GetStringChars(str, /*isCopy=*/nullptr);
std::string utf8 = std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t>{}.to_bytes(
reinterpret_cast<const char16_t*>(utf16));
size_t len = env->GetStringLength(str);
std::string utf8 = UTF16ToUTF8(reinterpret_cast<const char16_t*>(utf16), len);
*result = utf8;
env->ReleaseStringChars(str, utf16);
}
StefanoDuo marked this conversation as resolved.
Show resolved Hide resolved
10 changes: 10 additions & 0 deletions library/common/strings/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
load("@rules_cc//cc:defs.bzl", "cc_library")

licenses(["notice"]) # Apache 2

cc_library(
name = "string_conversions_lib",
srcs = ["string_conversions.cc"],
hdrs = ["string_conversions.h"],
visibility = ["//visibility:public"],
)
14 changes: 14 additions & 0 deletions library/common/strings/string_conversions.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#include "library/common/strings/string_conversions.h"

#include <codecvt>
#include <locale>

std::string UTF16ToUTF8(const char16_t* utf16, size_t len) {
return std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t>{}.to_bytes(utf16,
utf16 + len);
}

std::u16string UTF8ToUTF16(const char* utf8, size_t len) {
return std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t>{}.from_bytes(utf8,
utf8 + len);
}
7 changes: 7 additions & 0 deletions library/common/strings/string_conversions.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#pragma once

#include <string>

StefanoDuo marked this conversation as resolved.
Show resolved Hide resolved
std::string UTF16ToUTF8(const char16_t* utf16, size_t len);
StefanoDuo marked this conversation as resolved.
Show resolved Hide resolved

std::u16string UTF8ToUTF16(const char* utf8, size_t len);
24 changes: 16 additions & 8 deletions library/java/org/chromium/net/FakeX509Util.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,30 @@
* Fake utility functions to verify X.509 certificates.
*
* FakeX509Util is not particularly clever: from its perspective a certificate is just a string and
* its contents have no particular meaning. For a verification to succeed all certificates in a
* chain must have been previously registered as root certificates. This doesn't make much sense
* w.r.t. how X.509 certificates are really validated, but we're not interested in mimicking that,
* we just want something to confirm that JNI calls have taken place.
* its contents have no particular meaning. For a verification to succeed:
* - all certificates in a chain must have been previously registered as root certificates
* - host and authentication type must match the expected hardcoded values
* This doesn't make much sense w.r.t. how X.509 certificates are really validated, but we're not
* interested in mimicking that, we just want something to confirm that JNI calls have taken place.
*/
public final class FakeX509Util {
private static final Set<String> validFakeCerts = new HashSet<String>();

public static final String expectedAuthType = "RSA";
public static final String expectedHost = "www.example.com";

public static void addTestRootCertificate(byte[] rootCertBytes) {
String fakeCertificate = new String(rootCertBytes);
validFakeCerts.add(fakeCertificate);
}

public static void clearTestRootCertificates() { validFakeCerts.clear(); }

/* Fake certificate chain verification. Returns CertVerifyStatusAndroid.OK only if all
* certificates in the chain have been previously registered as root certificates,
* CertVerifyStatusAndroid.NO_TRUSTED_ROOT otherwise.
/**
* Performs fake certificate chain verification. Returns CertVerifyStatusAndroid.NO_TRUSTED_ROOT
* if at least one of the certificates in the chain has not been previously registered as a root.
* Returns CertVerifyStatusAndroid.OK if authType and host match respectively expectedAuthType and
* expectedHost; CertVerifyStatusAndroid.FAILED otherwise.
*/
public static AndroidCertVerifyResult verifyServerCertificates(byte[][] certChain,
String authType, String host) {
Expand All @@ -43,6 +49,8 @@ public static AndroidCertVerifyResult verifyServerCertificates(byte[][] certChai
}
}

return new AndroidCertVerifyResult(CertVerifyStatusAndroid.OK);
return authType.equals(expectedAuthType) && host.equals(expectedHost)
? new AndroidCertVerifyResult(CertVerifyStatusAndroid.OK)
: new AndroidCertVerifyResult(CertVerifyStatusAndroid.FAILED);
}
}
14 changes: 14 additions & 0 deletions test/common/strings/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
load("@envoy//bazel:envoy_build_system.bzl", "envoy_cc_test", "envoy_package")

licenses(["notice"]) # Apache 2

envoy_package()

envoy_cc_test(
name = "string_conversions_test",
srcs = ["string_conversions_test.cc"],
repository = "@envoy",
deps = [
"//library/common/strings:string_conversions_lib",
],
)
11 changes: 11 additions & 0 deletions test/common/strings/string_conversions_test.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#include <string>

#include "gtest/gtest.h"
#include "library/common/strings/string_conversions.h"

TEST(StringConversions, SameEncoding) {
std::string utf8 = u8"z\u00df\u6c34\U0001f34c";
std::u16string utf16 = u"z\u00df\u6c34\U0001f34c";

EXPECT_EQ(utf16, UTF8ToUTF16(utf8.data(), utf8.size()));
StefanoDuo marked this conversation as resolved.
Show resolved Hide resolved
}
41 changes: 29 additions & 12 deletions test/java/org/chromium/net/CertificateVerificationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public final class CertificateVerificationTest {
JniLibrary.load();
}

private static final String authType = FakeX509Util.expectedAuthType;
private static final String host = FakeX509Util.expectedHost;

@Before
public void setUp() throws Exception {
AndroidNetworkLibrary.setFakeCertificateVerificationForTesting(true);
Expand All @@ -39,8 +42,6 @@ public void tearDown() throws Exception {
public void testChainWithNonRootCertificate() throws Exception {
final String[] fakeCertChain = new String[] {"fake cert"};
final byte[][] certChain = new byte[][] {fakeCertChain[0].getBytes()};
final String authType = "";
final String host = "";

AndroidCertVerifyResult result =
(AndroidCertVerifyResult)JniLibrary.callCertificateVerificationFromNative(certChain,
Expand All @@ -52,8 +53,6 @@ public void testChainWithNonRootCertificate() throws Exception {
public void testChainWithRootCertificate() throws Exception {
final String[] fakeCertChain = new String[] {"fake cert"};
final byte[][] certChain = new byte[][] {fakeCertChain[0].getBytes()};
final String authType = "";
final String host = "";

JniLibrary.callAddTestRootCertificateFromNative(certChain[0]);
AndroidCertVerifyResult result =
Expand All @@ -62,12 +61,36 @@ public void testChainWithRootCertificate() throws Exception {
assertEquals(result.getStatus(), CertVerifyStatusAndroid.OK);
}

@Test
public void testChainWithRootCertificateWrongHostname() throws Exception {
final String[] fakeCertChain = new String[] {"fake cert"};
final byte[][] certChain = new byte[][] {fakeCertChain[0].getBytes()};
final String host = "wrong host";

JniLibrary.callAddTestRootCertificateFromNative(certChain[0]);
AndroidCertVerifyResult result =
(AndroidCertVerifyResult)JniLibrary.callCertificateVerificationFromNative(certChain,
authType, host);
assertEquals(result.getStatus(), CertVerifyStatusAndroid.FAILED);
}

@Test
public void testChainWithRootCertificateWrongAuthType() throws Exception {
final String[] fakeCertChain = new String[] {"fake cert"};
final byte[][] certChain = new byte[][] {fakeCertChain[0].getBytes()};
final String authType = "wrong auth type";

JniLibrary.callAddTestRootCertificateFromNative(certChain[0]);
AndroidCertVerifyResult result =
(AndroidCertVerifyResult)JniLibrary.callCertificateVerificationFromNative(certChain,
authType, host);
assertEquals(result.getStatus(), CertVerifyStatusAndroid.FAILED);
}

@Test
public void testClearTestRootCertificate() throws Exception {
final String[] fakeCertChain = new String[] {"fake cert"};
final byte[][] certChain = new byte[][] {fakeCertChain[0].getBytes()};
final String authType = "";
final String host = "";

JniLibrary.callAddTestRootCertificateFromNative(certChain[0]);
JniLibrary.callClearTestRootCertificateFromNative();
Expand All @@ -82,8 +105,6 @@ public void testChainWithMultipleNonRootCertificates() throws Exception {
final String[] fakeCertChain = new String[] {"fake cert", "another fake cert"};
final byte[][] certChain =
new byte[][] {fakeCertChain[0].getBytes(), fakeCertChain[1].getBytes()};
final String authType = "";
final String host = "";

AndroidCertVerifyResult result =
(AndroidCertVerifyResult)JniLibrary.callCertificateVerificationFromNative(certChain,
Expand All @@ -96,8 +117,6 @@ public void testChainWithMixedCertificates() throws Exception {
final String[] fakeCertChain = new String[] {"fake cert", "another fake cert"};
final byte[][] certChain =
new byte[][] {fakeCertChain[0].getBytes(), fakeCertChain[1].getBytes()};
final String authType = "";
final String host = "";

JniLibrary.callAddTestRootCertificateFromNative(certChain[0]);
AndroidCertVerifyResult result =
Expand All @@ -111,8 +130,6 @@ public void testChainWithMultipleRootCertificates() throws Exception {
final String[] fakeCertChain = new String[] {"fake cert", "another fake cert"};
final byte[][] certChain =
new byte[][] {fakeCertChain[0].getBytes(), fakeCertChain[1].getBytes()};
final String authType = "";
final String host = "";

JniLibrary.callAddTestRootCertificateFromNative(certChain[0]);
JniLibrary.callAddTestRootCertificateFromNative(certChain[1]);
Expand Down