diff options
| -rw-r--r-- | core/java/com/android/internal/net/VpnProfile.java | 293 | ||||
| -rw-r--r-- | tests/net/java/com/android/internal/net/VpnProfileTest.java | 185 |
2 files changed, 421 insertions, 57 deletions
diff --git a/core/java/com/android/internal/net/VpnProfile.java b/core/java/com/android/internal/net/VpnProfile.java index 4bb012aac769..bbae0273ef4e 100644 --- a/core/java/com/android/internal/net/VpnProfile.java +++ b/core/java/com/android/internal/net/VpnProfile.java @@ -16,6 +16,7 @@ package com.android.internal.net; +import android.annotation.NonNull; import android.compat.annotation.UnsupportedAppUsage; import android.net.ProxyInfo; import android.os.Build; @@ -23,21 +24,34 @@ import android.os.Parcel; import android.os.Parcelable; import android.text.TextUtils; +import com.android.internal.annotations.VisibleForTesting; + import java.net.InetAddress; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; /** - * Parcel-like entity class for VPN profiles. To keep things simple, all - * fields are package private. Methods are provided for serialization, so - * storage can be implemented easily. Two rules are set for this class. - * First, all fields must be kept non-null. Second, always make a copy - * using clone() before modifying. + * Profile storage class for a platform VPN. + * + * <p>This class supports both the Legacy VPN, as well as application-configurable platform VPNs + * (such as IKEv2/IPsec). + * + * <p>This class is serialized and deserialized via the {@link #encode()} and {@link #decode()} + * functions for persistent storage in the Android Keystore. The encoding is entirely custom, but + * must be kept for backward compatibility for devices upgrading between Android versions. * * @hide */ -public class VpnProfile implements Cloneable, Parcelable { +public final class VpnProfile implements Cloneable, Parcelable { private static final String TAG = "VpnProfile"; + @VisibleForTesting static final String VALUE_DELIMITER = "\0"; + @VisibleForTesting static final String LIST_DELIMITER = ","; + // Match these constants with R.array.vpn_types. public static final int TYPE_PPTP = 0; public static final int TYPE_L2TP_IPSEC_PSK = 1; @@ -45,39 +59,85 @@ public class VpnProfile implements Cloneable, Parcelable { public static final int TYPE_IPSEC_XAUTH_PSK = 3; public static final int TYPE_IPSEC_XAUTH_RSA = 4; public static final int TYPE_IPSEC_HYBRID_RSA = 5; - public static final int TYPE_MAX = 5; + public static final int TYPE_IKEV2_IPSEC_USER_PASS = 6; + public static final int TYPE_IKEV2_IPSEC_PSK = 7; + public static final int TYPE_IKEV2_IPSEC_RSA = 8; + public static final int TYPE_MAX = 8; // Match these constants with R.array.vpn_proxy_settings. public static final int PROXY_NONE = 0; public static final int PROXY_MANUAL = 1; + private static final String ENCODED_NULL_PROXY_INFO = "\0\0\0\0"; + // Entity fields. @UnsupportedAppUsage - public final String key; // -1 + public final String key; // -1 + @UnsupportedAppUsage - public String name = ""; // 0 + public String name = ""; // 0 + @UnsupportedAppUsage - public int type = TYPE_PPTP; // 1 + public int type = TYPE_PPTP; // 1 + @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) - public String server = ""; // 2 + public String server = ""; // 2 + @UnsupportedAppUsage - public String username = ""; // 3 - public String password = ""; // 4 - public String dnsServers = ""; // 5 - public String searchDomains = ""; // 6 - public String routes = ""; // 7 - public boolean mppe = true; // 8 - public String l2tpSecret = ""; // 9 - public String ipsecIdentifier = "";// 10 - public String ipsecSecret = ""; // 11 - public String ipsecUserCert = ""; // 12 - public String ipsecCaCert = ""; // 13 - public String ipsecServerCert = "";// 14 - public ProxyInfo proxy = null; // 15~18 + public String username = ""; // 3 + public String password = ""; // 4 + public String dnsServers = ""; // 5 + public String searchDomains = ""; // 6 + public String routes = ""; // 7 + public boolean mppe = true; // 8 + public String l2tpSecret = ""; // 9 + public String ipsecIdentifier = ""; // 10 + + /** + * The RSA private key or pre-shared key used for authentication. + * + * <p>If areAuthParamsInline is {@code true}, this String will be either: + * + * <ul> + * <li>If this is an IKEv2 RSA profile: a PKCS#8 encoded {@link java.security.PrivateKey} + * <li>If this is an IKEv2 PSK profile: a string value representing the PSK. + * </ul> + */ + public String ipsecSecret = ""; // 11 + + /** + * The RSA certificate to be used for digital signature authentication. + * + * <p>If areAuthParamsInline is {@code true}, this String will be a pem-encoded {@link + * java.security.X509Certificate} + */ + public String ipsecUserCert = ""; // 12 + + /** + * The RSA certificate that should be used to verify the server's end/target certificate. + * + * <p>If areAuthParamsInline is {@code true}, this String will be a pem-encoded {@link + * java.security.X509Certificate} + */ + public String ipsecCaCert = ""; // 13 + public String ipsecServerCert = ""; // 14 + public ProxyInfo proxy = null; // 15~18 + + /** + * The list of allowable algorithms. + * + * <p>This list is validated in the setter to ensure that encoding characters (list, value + * delimiters) are not present in the algorithm names. See {@link #validateAllowedAlgorithms()} + */ + private List<String> mAllowedAlgorithms = new ArrayList<>(); // 19 + public boolean isBypassable = false; // 20 + public boolean isMetered = false; // 21 + public int maxMtu = 1400; // 22 + public boolean areAuthParamsInline = false; // 23 // Helper fields. @UnsupportedAppUsage - public boolean saveLogin = false; + public transient boolean saveLogin = false; public VpnProfile(String key) { this.key = key; @@ -103,6 +163,34 @@ public class VpnProfile implements Cloneable, Parcelable { ipsecServerCert = in.readString(); saveLogin = in.readInt() != 0; proxy = in.readParcelable(null); + mAllowedAlgorithms = new ArrayList<>(); + in.readList(mAllowedAlgorithms, null); + isBypassable = in.readBoolean(); + isMetered = in.readBoolean(); + maxMtu = in.readInt(); + areAuthParamsInline = in.readBoolean(); + } + + /** + * Retrieves the list of allowed algorithms. + * + * <p>The contained elements are as listed in {@link IpSecAlgorithm} + */ + public List<String> getAllowedAlgorithms() { + return Collections.unmodifiableList(mAllowedAlgorithms); + } + + /** + * Validates and sets the list of algorithms that can be used for the IPsec transforms. + * + * @param allowedAlgorithms the list of allowable algorithms, as listed in {@link + * IpSecAlgorithm}. + * @throws IllegalArgumentException if any delimiters are used in algorithm names. See {@link + * #VALUE_DELIMITER} and {@link LIST_DELIMITER}. + */ + public void setAllowedAlgorithms(List<String> allowedAlgorithms) { + validateAllowedAlgorithms(allowedAlgorithms); + mAllowedAlgorithms = allowedAlgorithms; } @Override @@ -125,8 +213,18 @@ public class VpnProfile implements Cloneable, Parcelable { out.writeString(ipsecServerCert); out.writeInt(saveLogin ? 1 : 0); out.writeParcelable(proxy, flags); + out.writeList(mAllowedAlgorithms); + out.writeBoolean(isBypassable); + out.writeBoolean(isMetered); + out.writeInt(maxMtu); + out.writeBoolean(areAuthParamsInline); } + /** + * Decodes a VpnProfile instance from the encoded byte array. + * + * <p>See {@link #encode()} + */ @UnsupportedAppUsage public static VpnProfile decode(String key, byte[] value) { try { @@ -134,9 +232,11 @@ public class VpnProfile implements Cloneable, Parcelable { return null; } - String[] values = new String(value, StandardCharsets.UTF_8).split("\0", -1); - // There can be 14 - 19 Bytes in values.length. - if (values.length < 14 || values.length > 19) { + String[] values = new String(value, StandardCharsets.UTF_8).split(VALUE_DELIMITER, -1); + // Acceptable numbers of values are: + // 14-19: Standard profile, with option for serverCert, proxy + // 24: Standard profile with serverCert, proxy and platform-VPN parameters. + if ((values.length < 14 || values.length > 19) && values.length != 24) { return null; } @@ -164,13 +264,23 @@ public class VpnProfile implements Cloneable, Parcelable { String port = (values.length > 16) ? values[16] : ""; String exclList = (values.length > 17) ? values[17] : ""; String pacFileUrl = (values.length > 18) ? values[18] : ""; - if (pacFileUrl.isEmpty()) { + if (!host.isEmpty() || !port.isEmpty() || !exclList.isEmpty()) { profile.proxy = new ProxyInfo(host, port.isEmpty() ? 0 : Integer.parseInt(port), exclList); - } else { + } else if (!pacFileUrl.isEmpty()) { profile.proxy = new ProxyInfo(pacFileUrl); } - } // else profle.proxy = null + } // else profile.proxy = null + + // Either all must be present, or none must be. + if (values.length >= 24) { + profile.mAllowedAlgorithms = Arrays.asList(values[19].split(LIST_DELIMITER)); + profile.isBypassable = Boolean.parseBoolean(values[20]); + profile.isMetered = Boolean.parseBoolean(values[21]); + profile.maxMtu = Integer.parseInt(values[22]); + profile.areAuthParamsInline = Boolean.parseBoolean(values[23]); + } + profile.saveLogin = !profile.username.isEmpty() || !profile.password.isEmpty(); return profile; } catch (Exception e) { @@ -179,36 +289,52 @@ public class VpnProfile implements Cloneable, Parcelable { return null; } + /** + * Encodes a VpnProfile instance to a byte array for storage. + * + * <p>See {@link #decode(String, byte[])} + */ public byte[] encode() { StringBuilder builder = new StringBuilder(name); - builder.append('\0').append(type); - builder.append('\0').append(server); - builder.append('\0').append(saveLogin ? username : ""); - builder.append('\0').append(saveLogin ? password : ""); - builder.append('\0').append(dnsServers); - builder.append('\0').append(searchDomains); - builder.append('\0').append(routes); - builder.append('\0').append(mppe); - builder.append('\0').append(l2tpSecret); - builder.append('\0').append(ipsecIdentifier); - builder.append('\0').append(ipsecSecret); - builder.append('\0').append(ipsecUserCert); - builder.append('\0').append(ipsecCaCert); - builder.append('\0').append(ipsecServerCert); + builder.append(VALUE_DELIMITER).append(type); + builder.append(VALUE_DELIMITER).append(server); + builder.append(VALUE_DELIMITER).append(saveLogin ? username : ""); + builder.append(VALUE_DELIMITER).append(saveLogin ? password : ""); + builder.append(VALUE_DELIMITER).append(dnsServers); + builder.append(VALUE_DELIMITER).append(searchDomains); + builder.append(VALUE_DELIMITER).append(routes); + builder.append(VALUE_DELIMITER).append(mppe); + builder.append(VALUE_DELIMITER).append(l2tpSecret); + builder.append(VALUE_DELIMITER).append(ipsecIdentifier); + builder.append(VALUE_DELIMITER).append(ipsecSecret); + builder.append(VALUE_DELIMITER).append(ipsecUserCert); + builder.append(VALUE_DELIMITER).append(ipsecCaCert); + builder.append(VALUE_DELIMITER).append(ipsecServerCert); if (proxy != null) { - builder.append('\0').append(proxy.getHost() != null ? proxy.getHost() : ""); - builder.append('\0').append(proxy.getPort()); - builder.append('\0').append(proxy.getExclusionListAsString() != null ? - proxy.getExclusionListAsString() : ""); - builder.append('\0').append(proxy.getPacFileUrl().toString()); + builder.append(VALUE_DELIMITER).append(proxy.getHost() != null ? proxy.getHost() : ""); + builder.append(VALUE_DELIMITER).append(proxy.getPort()); + builder.append(VALUE_DELIMITER) + .append( + proxy.getExclusionListAsString() != null + ? proxy.getExclusionListAsString() + : ""); + builder.append(VALUE_DELIMITER).append(proxy.getPacFileUrl().toString()); + } else { + builder.append(ENCODED_NULL_PROXY_INFO); } + + builder.append(VALUE_DELIMITER).append(String.join(LIST_DELIMITER, mAllowedAlgorithms)); + builder.append(VALUE_DELIMITER).append(isBypassable); + builder.append(VALUE_DELIMITER).append(isMetered); + builder.append(VALUE_DELIMITER).append(maxMtu); + builder.append(VALUE_DELIMITER).append(areAuthParamsInline); + return builder.toString().getBytes(StandardCharsets.UTF_8); } /** - * Tests if profile is valid for lockdown, which requires IPv4 address for - * both server and DNS. Server hostnames would require using DNS before - * connection. + * Tests if profile is valid for lockdown, which requires IPv4 address for both server and DNS. + * Server hostnames would require using DNS before connection. */ public boolean isValidLockdownProfile() { return isTypeValidForLockdown() @@ -238,10 +364,7 @@ public class VpnProfile implements Cloneable, Parcelable { return !TextUtils.isEmpty(dnsServers); } - /** - * Returns {@code true} if all DNS servers have numeric addresses, - * e.g. 8.8.8.8 - */ + /** Returns {@code true} if all DNS servers have numeric addresses, e.g. 8.8.8.8 */ public boolean areDnsAddressesNumeric() { try { for (String dnsServer : dnsServers.split(" +")) { @@ -253,6 +376,62 @@ public class VpnProfile implements Cloneable, Parcelable { return true; } + /** + * Validates that the provided list of algorithms does not contain illegal characters. + * + * @param allowedAlgorithms The list to be validated + */ + public static void validateAllowedAlgorithms(List<String> allowedAlgorithms) { + for (final String alg : allowedAlgorithms) { + if (alg.contains(VALUE_DELIMITER) || alg.contains(LIST_DELIMITER)) { + throw new IllegalArgumentException( + "Algorithm contained illegal ('\0' or ',') character"); + } + } + } + + /** Generates a hashcode over the VpnProfile. */ + @Override + public int hashCode() { + return Objects.hash( + key, type, server, username, password, dnsServers, searchDomains, routes, mppe, + l2tpSecret, ipsecIdentifier, ipsecSecret, ipsecUserCert, ipsecCaCert, ipsecServerCert, + proxy, mAllowedAlgorithms, isBypassable, isMetered, maxMtu, areAuthParamsInline); + } + + /** Checks VPN profiles for interior equality. */ + @Override + public boolean equals(Object obj) { + if (!(obj instanceof VpnProfile)) { + return false; + } + + final VpnProfile other = (VpnProfile) obj; + return Objects.equals(key, other.key) + && Objects.equals(name, other.name) + && type == other.type + && Objects.equals(server, other.server) + && Objects.equals(username, other.username) + && Objects.equals(password, other.password) + && Objects.equals(dnsServers, other.dnsServers) + && Objects.equals(searchDomains, other.searchDomains) + && Objects.equals(routes, other.routes) + && mppe == other.mppe + && Objects.equals(l2tpSecret, other.l2tpSecret) + && Objects.equals(ipsecIdentifier, other.ipsecIdentifier) + && Objects.equals(ipsecSecret, other.ipsecSecret) + && Objects.equals(ipsecUserCert, other.ipsecUserCert) + && Objects.equals(ipsecCaCert, other.ipsecCaCert) + && Objects.equals(ipsecServerCert, other.ipsecServerCert) + && Objects.equals(proxy, other.proxy) + && Objects.equals(mAllowedAlgorithms, other.mAllowedAlgorithms) + && isBypassable == other.isBypassable + && isMetered == other.isMetered + && maxMtu == other.maxMtu + && areAuthParamsInline == other.areAuthParamsInline; + } + + @NonNull public static final Creator<VpnProfile> CREATOR = new Creator<VpnProfile>() { @Override public VpnProfile createFromParcel(Parcel in) { diff --git a/tests/net/java/com/android/internal/net/VpnProfileTest.java b/tests/net/java/com/android/internal/net/VpnProfileTest.java new file mode 100644 index 000000000000..8a4b53343c26 --- /dev/null +++ b/tests/net/java/com/android/internal/net/VpnProfileTest.java @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.internal.net; + +import static com.android.testutils.ParcelUtilsKt.assertParcelSane; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.net.IpSecAlgorithm; + +import androidx.test.filters.SmallTest; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.util.Arrays; + +/** Unit tests for {@link VpnProfile}. */ +@SmallTest +@RunWith(JUnit4.class) +public class VpnProfileTest { + private static final String DUMMY_PROFILE_KEY = "Test"; + + @Test + public void testDefaults() throws Exception { + final VpnProfile p = new VpnProfile(DUMMY_PROFILE_KEY); + + assertEquals(DUMMY_PROFILE_KEY, p.key); + assertEquals("", p.name); + assertEquals(VpnProfile.TYPE_PPTP, p.type); + assertEquals("", p.server); + assertEquals("", p.username); + assertEquals("", p.password); + assertEquals("", p.dnsServers); + assertEquals("", p.searchDomains); + assertEquals("", p.routes); + assertTrue(p.mppe); + assertEquals("", p.l2tpSecret); + assertEquals("", p.ipsecIdentifier); + assertEquals("", p.ipsecSecret); + assertEquals("", p.ipsecUserCert); + assertEquals("", p.ipsecCaCert); + assertEquals("", p.ipsecServerCert); + assertEquals(null, p.proxy); + assertTrue(p.getAllowedAlgorithms() != null && p.getAllowedAlgorithms().isEmpty()); + assertFalse(p.isBypassable); + assertFalse(p.isMetered); + assertEquals(1400, p.maxMtu); + assertFalse(p.areAuthParamsInline); + } + + private VpnProfile getSampleIkev2Profile(String key) { + final VpnProfile p = new VpnProfile(key); + + p.name = "foo"; + p.type = VpnProfile.TYPE_IKEV2_IPSEC_USER_PASS; + p.server = "bar"; + p.username = "baz"; + p.password = "qux"; + p.dnsServers = "8.8.8.8"; + p.searchDomains = ""; + p.routes = "0.0.0.0/0"; + p.mppe = false; + p.l2tpSecret = ""; + p.ipsecIdentifier = "quux"; + p.ipsecSecret = "quuz"; + p.ipsecUserCert = "corge"; + p.ipsecCaCert = "grault"; + p.ipsecServerCert = "garply"; + p.proxy = null; + p.setAllowedAlgorithms( + Arrays.asList( + IpSecAlgorithm.AUTH_CRYPT_AES_GCM, + IpSecAlgorithm.AUTH_HMAC_SHA512, + IpSecAlgorithm.CRYPT_AES_CBC)); + p.isBypassable = true; + p.isMetered = true; + p.maxMtu = 1350; + p.areAuthParamsInline = true; + + // Not saved, but also not compared. + p.saveLogin = true; + + return p; + } + + @Test + public void testEquals() { + assertEquals( + getSampleIkev2Profile(DUMMY_PROFILE_KEY), getSampleIkev2Profile(DUMMY_PROFILE_KEY)); + + final VpnProfile modified = getSampleIkev2Profile(DUMMY_PROFILE_KEY); + modified.maxMtu--; + assertNotEquals(getSampleIkev2Profile(DUMMY_PROFILE_KEY), modified); + } + + @Test + public void testParcelUnparcel() { + assertParcelSane(getSampleIkev2Profile(DUMMY_PROFILE_KEY), 22); + } + + @Test + public void testSetInvalidAlgorithmValueDelimiter() { + final VpnProfile profile = getSampleIkev2Profile(DUMMY_PROFILE_KEY); + + try { + profile.setAllowedAlgorithms( + Arrays.asList("test" + VpnProfile.VALUE_DELIMITER + "test")); + fail("Expected failure due to value separator in algorithm name"); + } catch (IllegalArgumentException expected) { + } + } + + @Test + public void testSetInvalidAlgorithmListDelimiter() { + final VpnProfile profile = getSampleIkev2Profile(DUMMY_PROFILE_KEY); + + try { + profile.setAllowedAlgorithms( + Arrays.asList("test" + VpnProfile.LIST_DELIMITER + "test")); + fail("Expected failure due to value separator in algorithm name"); + } catch (IllegalArgumentException expected) { + } + } + + @Test + public void testEncodeDecode() { + final VpnProfile profile = getSampleIkev2Profile(DUMMY_PROFILE_KEY); + final VpnProfile decoded = VpnProfile.decode(DUMMY_PROFILE_KEY, profile.encode()); + assertEquals(profile, decoded); + } + + @Test + public void testEncodeDecodeTooManyValues() { + final VpnProfile profile = getSampleIkev2Profile(DUMMY_PROFILE_KEY); + final byte[] tooManyValues = + (new String(profile.encode()) + VpnProfile.VALUE_DELIMITER + "invalid").getBytes(); + + assertNull(VpnProfile.decode(DUMMY_PROFILE_KEY, tooManyValues)); + } + + @Test + public void testEncodeDecodeInvalidNumberOfValues() { + final VpnProfile profile = getSampleIkev2Profile(DUMMY_PROFILE_KEY); + final String encoded = new String(profile.encode()); + final byte[] tooFewValues = + encoded.substring(0, encoded.lastIndexOf(VpnProfile.VALUE_DELIMITER)).getBytes(); + + assertNull(VpnProfile.decode(DUMMY_PROFILE_KEY, tooFewValues)); + } + + @Test + public void testEncodeDecodeLoginsNotSaved() { + final VpnProfile profile = getSampleIkev2Profile(DUMMY_PROFILE_KEY); + profile.saveLogin = false; + + final VpnProfile decoded = VpnProfile.decode(DUMMY_PROFILE_KEY, profile.encode()); + assertNotEquals(profile, decoded); + + // Add the username/password back, everything else must be equal. + decoded.username = profile.username; + decoded.password = profile.password; + assertEquals(profile, decoded); + } +} |