From: Tobias Brunner Date: Thu, 29 Dec 2016 16:35:57 +0000 (+0100) Subject: android: Add activity to import VPN profiles from JSON-encoded files X-Git-Tag: 5.5.2dr5~52^2~3 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=3107634e305b37a5bfa7d13e09409879358b9b9c;p=thirdparty%2Fstrongswan.git android: Add activity to import VPN profiles from JSON-encoded files The file format is documented on the wiki. URLs to .sswan files may be intercepted and downloaded files with a media type of application/vnd.strongswan.profile may also be opened (the file extension doesn't matter in that case). Whether downloaded files for which the media type is not correct but the extension is .sswan can be opened depends on the app that issues the Intent. For instance, from the default Downloads app it won't work due to the content:// URLs that do not contain the file name but when opening the downloaded file from within Chrome's Downloads view it works as these Intents use file:// URLs, which contain the complete file name (the latter requires a new permission). --- diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml index da465ba740..a0c79875f9 100644 --- a/src/frontends/android/app/src/main/AndroidManifest.xml +++ b/src/frontends/android/app/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.ui; + +import android.app.Activity; +import android.app.LoaderManager; +import android.app.ProgressDialog; +import android.content.AsyncTaskLoader; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.Loader; +import android.net.Uri; +import android.os.Bundle; +import android.security.KeyChain; +import android.security.KeyChainAliasCallback; +import android.security.KeyChainException; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v7.app.AppCompatActivity; +import android.util.Base64; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import org.json.JSONException; +import org.json.JSONObject; +import org.strongswan.android.R; +import org.strongswan.android.data.VpnProfile; +import org.strongswan.android.data.VpnProfileDataSource; +import org.strongswan.android.data.VpnType; +import org.strongswan.android.data.VpnType.VpnTypeFeature; +import org.strongswan.android.logic.TrustedCertificateManager; +import org.strongswan.android.security.TrustedCertificateEntry; +import org.strongswan.android.ui.widget.TextInputLayoutHelper; +import org.strongswan.android.utils.Constants; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.UUID; + +public class VpnProfileImportActivity extends AppCompatActivity +{ + private static final String PKCS12_INSTALLED = "PKCS12_INSTALLED"; + private static final int INSTALL_PKCS12 = 0; + private static final int PROFILE_LOADER = 0; + private static final int USER_CERT_LOADER = 1; + + private VpnProfileDataSource mDataSource; + private ParsedVpnProfile mProfile; + private VpnProfile mExisting; + private TrustedCertificateEntry mCertEntry; + private TrustedCertificateEntry mUserCertEntry; + private String mUserCertLoading; + private boolean mHideImport; + private ProgressDialog mProgress; + private TextView mExistsWarning; + private ViewGroup mBasicDataGroup; + private TextView mName; + private TextView mGateway; + private TextView mSelectVpnType; + private ViewGroup mUsernamePassword; + private EditText mUsername; + private TextInputLayoutHelper mUsernameWrap; + private EditText mPassword; + private ViewGroup mUserCertificate; + private RelativeLayout mSelectUserCert; + private Button mImportUserCert; + private ViewGroup mRemoteCertificate; + private RelativeLayout mRemoteCert; + + private LoaderManager.LoaderCallbacks mProfileLoaderCallbacks = new LoaderManager.LoaderCallbacks() + { + @Override + public Loader onCreateLoader(int id, Bundle args) + { + return new ProfileLoader(VpnProfileImportActivity.this, getIntent().getData()); + } + + @Override + public void onLoadFinished(Loader loader, String data) + { + handleProfile(data); + } + + @Override + public void onLoaderReset(Loader loader) + { + + } + }; + + private LoaderManager.LoaderCallbacks mUserCertificateLoaderCallbacks = new LoaderManager.LoaderCallbacks() + { + @Override + public Loader onCreateLoader(int id, Bundle args) + { + return new UserCertificateLoader(VpnProfileImportActivity.this, mUserCertLoading); + } + + @Override + public void onLoadFinished(Loader loader, TrustedCertificateEntry data) + { + handleUserCertificate(data); + } + + @Override + public void onLoaderReset(Loader loader) + { + + } + }; + + @Override + public void onCreate(Bundle savedInstanceState) + { + super.onCreate(savedInstanceState); + + getSupportActionBar().setHomeAsUpIndicator(R.drawable.ic_close_white_24dp); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + mDataSource = new VpnProfileDataSource(this); + mDataSource.open(); + + setContentView(R.layout.profile_import_view); + + mExistsWarning = (TextView)findViewById(R.id.exists_warning); + mBasicDataGroup = (ViewGroup)findViewById(R.id.basic_data_group); + mName = (TextView)findViewById(R.id.name); + mGateway = (TextView)findViewById(R.id.gateway); + mSelectVpnType = (TextView)findViewById(R.id.vpn_type); + + mUsernamePassword = (ViewGroup)findViewById(R.id.username_password_group); + mUsername = (EditText)findViewById(R.id.username); + mUsernameWrap = (TextInputLayoutHelper) findViewById(R.id.username_wrap); + mPassword = (EditText)findViewById(R.id.password); + + mUserCertificate = (ViewGroup)findViewById(R.id.user_certificate_group); + mSelectUserCert = (RelativeLayout)findViewById(R.id.select_user_certificate); + mImportUserCert = (Button)findViewById(R.id.import_user_certificate); + + mRemoteCertificate = (ViewGroup)findViewById(R.id.remote_certificate_group); + mRemoteCert = (RelativeLayout)findViewById(R.id.remote_certificate); + + mExistsWarning.setVisibility(View.GONE); + mBasicDataGroup.setVisibility(View.GONE); + mUsernamePassword.setVisibility(View.GONE); + mUserCertificate.setVisibility(View.GONE); + mRemoteCertificate.setVisibility(View.GONE); + + mSelectUserCert.setOnClickListener(new SelectUserCertOnClickListener()); + mImportUserCert.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) + { + Intent intent = KeyChain.createInstallIntent(); + intent.putExtra(KeyChain.EXTRA_NAME, getString(R.string.profile_cert_alias, mProfile.getName())); + intent.putExtra(KeyChain.EXTRA_PKCS12, mProfile.PKCS12); + startActivityForResult(intent, INSTALL_PKCS12); + } + }); + + Intent intent = getIntent(); + String action = intent.getAction(); + if (Intent.ACTION_VIEW.equals(action)) + { + mProgress = ProgressDialog.show(this, null, getString(R.string.loading), + true, true, new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) + { + finish(); + } + }); + + getLoaderManager().initLoader(PROFILE_LOADER, null, mProfileLoaderCallbacks); + } + + if (savedInstanceState != null) + { + mUserCertLoading = savedInstanceState.getString(VpnProfileDataSource.KEY_USER_CERTIFICATE); + if (mUserCertLoading != null) + { + getLoaderManager().initLoader(USER_CERT_LOADER, null, mUserCertificateLoaderCallbacks); + } + mImportUserCert.setEnabled(!savedInstanceState.getBoolean(PKCS12_INSTALLED)); + } + } + + @Override + protected void onDestroy() + { + super.onDestroy(); + mDataSource.close(); + } + + @Override + protected void onSaveInstanceState(Bundle outState) + { + super.onSaveInstanceState(outState); + if (mUserCertEntry != null) + { + outState.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE, mUserCertEntry.getAlias()); + } + outState.putBoolean(PKCS12_INSTALLED, !mImportUserCert.isEnabled()); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) + { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.profile_import, menu); + if (mHideImport) + { + MenuItem item = menu.findItem(R.id.menu_accept); + item.setVisible(false); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) + { + switch (item.getItemId()) + { + case android.R.id.home: + finish(); + return true; + case R.id.menu_accept: + saveProfile(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) + { + super.onActivityResult(requestCode, resultCode, data); + switch (requestCode) + { + case INSTALL_PKCS12: + if (resultCode == Activity.RESULT_OK) + { /* no need to import twice */ + mImportUserCert.setEnabled(false); + mSelectUserCert.performClick(); + } + } + } + + public void handleProfile(String data) + { + mProgress.dismiss(); + + mProfile = null; + if (data != null) + { + try + { + JSONObject obj = new JSONObject(data); + mProfile = parseProfile(obj); + } + catch (JSONException e) + { + mExistsWarning.setVisibility(View.VISIBLE); + mExistsWarning.setText(e.getLocalizedMessage()); + mHideImport = true; + invalidateOptionsMenu(); + return; + } + } + if (mProfile == null) + { + Toast.makeText(this, R.string.profile_import_failed, Toast.LENGTH_LONG).show(); + finish(); + return; + } + mExisting = mDataSource.getVpnProfile(mProfile.getUUID()); + mExistsWarning.setVisibility(mExisting != null ? View.VISIBLE : View.GONE); + + mBasicDataGroup.setVisibility(View.VISIBLE); + mName.setText(mProfile.getName()); + mGateway.setText(mProfile.getGateway()); + mSelectVpnType.setText(getResources().getStringArray(R.array.vpn_types)[mProfile.getVpnType().ordinal()]); + + mUsernamePassword.setVisibility(mProfile.getVpnType().has(VpnTypeFeature.USER_PASS) ? View.VISIBLE : View.GONE); + if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS)) + { + mUsername.setText(mProfile.getUsername()); + if (mProfile.getUsername() != null && !mProfile.getUsername().isEmpty()) + { + mUsername.setEnabled(false); + } + } + + mUserCertificate.setVisibility(mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE) ? View.VISIBLE : View.GONE); + mRemoteCertificate.setVisibility(mProfile.Certificate != null ? View.VISIBLE : View.GONE); + mImportUserCert.setVisibility(mProfile.PKCS12 != null ? View.VISIBLE : View.GONE); + + updateUserCertView(); + + if (mProfile.Certificate != null) + { + try + { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + X509Certificate certificate = (X509Certificate)factory.generateCertificate(new ByteArrayInputStream(mProfile.Certificate)); + KeyStore store = KeyStore.getInstance("LocalCertificateStore"); + store.load(null, null); + String alias = store.getCertificateAlias(certificate); + mCertEntry = new TrustedCertificateEntry(alias, certificate); + ((TextView)mRemoteCert.findViewById(android.R.id.text1)).setText(mCertEntry.getSubjectPrimary()); + ((TextView)mRemoteCert.findViewById(android.R.id.text2)).setText(mCertEntry.getSubjectSecondary()); + } + catch (CertificateException | NoSuchAlgorithmException | KeyStoreException | IOException e) + { + e.printStackTrace(); + mRemoteCertificate.setVisibility(View.GONE); + } + } + } + + private void handleUserCertificate(TrustedCertificateEntry data) + { + mUserCertEntry = data; + mUserCertLoading = null; + updateUserCertView(); + } + + private void updateUserCertView() + { + if (mUserCertLoading != null) + { + ((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setText(mUserCertLoading); + ((TextView)mSelectUserCert.findViewById(android.R.id.text2)).setText(R.string.loading); + } + else if (mUserCertEntry != null) + { /* clear any errors and set the new data */ + ((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setError(null); + ((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setText(mUserCertEntry.getAlias()); + ((TextView)mSelectUserCert.findViewById(android.R.id.text2)).setText(mUserCertEntry.getCertificate().getSubjectDN().toString()); + } + else + { + ((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setText(R.string.profile_user_select_certificate_label); + ((TextView)mSelectUserCert.findViewById(android.R.id.text2)).setText(R.string.profile_user_select_certificate); + } + } + + private ParsedVpnProfile parseProfile(JSONObject obj) throws JSONException + { + UUID uuid; + try + { + uuid = UUID.fromString(obj.getString("uuid")); + } + catch (IllegalArgumentException e) + { + e.printStackTrace(); + return null; + } + ParsedVpnProfile profile = new ParsedVpnProfile(); + + profile.setUUID(uuid); + profile.setName(obj.getString("name")); + VpnType type = VpnType.fromIdentifier(obj.getString("type")); + profile.setVpnType(type); + + JSONObject remote = obj.getJSONObject("remote"); + profile.setGateway(remote.getString("addr")); + profile.setPort(getInteger(remote, "port", 1, 65535)); + profile.setRemoteId(remote.optString("id", null)); + profile.Certificate = decodeBase64(remote.optString("cert", null)); + + JSONObject local = obj.optJSONObject("local"); + if (local != null) + { + if (type.has(VpnTypeFeature.USER_PASS)) + { + profile.setUsername(local.optString("eap_id", null)); + } + + if (type.has(VpnTypeFeature.CERTIFICATE)) + { + profile.setLocalId(local.optString("id", null)); + profile.PKCS12 = decodeBase64(local.optString("p12", null)); + } + } + + profile.setMTU(getInteger(obj, "mtu", Constants.MTU_MIN, Constants.MTU_MAX)); + JSONObject split = obj.optJSONObject("split-tunneling"); + if (split != null) + { + int st = 0; + st |= split.optBoolean("block-ipv4") ? VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4 : 0; + st |= split.optBoolean("block-ipv6") ? VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6 : 0; + profile.setSplitTunneling(st == 0 ? null : st); + } + return profile; + } + + private Integer getInteger(JSONObject obj, String key, int min, int max) + { + Integer res = obj.optInt(key); + return res < min || res > max ? null : res; + } + + /** + * Save or update the profile depending on whether we actually have a + * profile object or not (this was created in updateProfileData) + */ + private void saveProfile() + { + if (verifyInput()) + { + updateProfileData(); + if (mExisting != null) + { + mProfile.setId(mExisting.getId()); + mDataSource.updateVpnProfile(mProfile); + } + else + { + mDataSource.insertProfile(mProfile); + } + if (mCertEntry != null) + { + try + { /* store the CA/server certificate */ + KeyStore store = KeyStore.getInstance("LocalCertificateStore"); + store.load(null, null); + store.setCertificateEntry(null, mCertEntry.getCertificate()); + TrustedCertificateManager.getInstance().reset(); + } + catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException e) + { + e.printStackTrace(); + } + } + Intent intent = new Intent(Constants.VPN_PROFILES_CHANGED); + intent.putExtra(Constants.VPN_PROFILES_SINGLE, mProfile.getId()); + LocalBroadcastManager.getInstance(this).sendBroadcast(intent); + + intent = new Intent(this, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + + setResult(RESULT_OK, new Intent().putExtra(VpnProfileDataSource.KEY_ID, mProfile.getId())); + finish(); + } + } + + /** + * Verify the user input and display error messages. + * @return true if the input is valid + */ + private boolean verifyInput() + { + boolean valid = true; + if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS)) + { + if (mUsername.getText().toString().trim().isEmpty()) + { + mUsernameWrap.setError(getString(R.string.alert_text_no_input_username)); + valid = false; + } + } + if (mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE) && mUserCertEntry == null) + { /* let's show an error icon */ + ((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setError(""); + valid = false; + } + return valid; + } + + /** + * Update the profile object with the data entered by the user + */ + private void updateProfileData() + { + if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS)) + { + mProfile.setUsername(mUsername.getText().toString().trim()); + String password = mPassword.getText().toString().trim(); + password = password.isEmpty() ? null : password; + mProfile.setPassword(password); + } + if (mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE)) + { + mProfile.setUserCertificateAlias(mUserCertEntry.getAlias()); + } + if (mCertEntry != null) + { + mProfile.setCertificateAlias(mCertEntry.getAlias()); + } + } + + /** + * Load the JSON-encoded VPN profile from the given URI + */ + private static class ProfileLoader extends AsyncTaskLoader + { + private final Uri mUri; + private String mData; + + public ProfileLoader(Context context, Uri uri) + { + super(context); + mUri = uri; + } + + @Override + public String loadInBackground() + { + InputStream in = null; + + if (ContentResolver.SCHEME_CONTENT.equals(mUri.getScheme()) || + ContentResolver.SCHEME_FILE.equals(mUri.getScheme())) + { + try + { + in = getContext().getContentResolver().openInputStream(mUri); + } + catch (FileNotFoundException e) + { + e.printStackTrace(); + } + } + else + { + try + { + URL url = new URL(mUri.toString()); + in = url.openStream(); + } + catch (IOException e) + { + e.printStackTrace(); + } + } + if (in != null) + { + return streamToString(in); + } + return null; + } + + @Override + protected void onStartLoading() + { + if (mData != null) + { /* if we have data ready, deliver it directly */ + deliverResult(mData); + } + if (takeContentChanged() || mData == null) + { + forceLoad(); + } + } + + @Override + public void deliverResult(String data) + { + if (isReset()) + { + return; + } + mData = data; + if (isStarted()) + { /* if it is started we deliver the data directly, + * otherwise this is handled in onStartLoading */ + super.deliverResult(data); + } + } + + @Override + protected void onReset() + { + mData = null; + super.onReset(); + } + + private String streamToString(InputStream in) + { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[1024]; + int len; + + try + { + while ((len = in.read(buf)) != -1) + { + out.write(buf, 0, len); + } + return out.toString("UTF-8"); + } + catch (IOException e) + { + e.printStackTrace(); + } + return null; + } + } + + /** + * Ask the user to select an available certificate. + */ + private class SelectUserCertOnClickListener implements View.OnClickListener, KeyChainAliasCallback + { + @Override + public void onClick(View v) + { + String alias = null; + if (mUserCertEntry != null) + { + alias = mUserCertEntry.getAlias(); + mUserCertEntry = null; + } + else if (mProfile != null) + { + alias = getString(R.string.profile_cert_alias, mProfile.getName()); + } + KeyChain.choosePrivateKeyAlias(VpnProfileImportActivity.this, this, new String[] { "RSA" }, null, null, -1, alias); + } + + @Override + public void alias(final String alias) + { + /* alias() is not called from our main thread */ + runOnUiThread(new Runnable() { + @Override + public void run() + { + mUserCertLoading = alias; + updateUserCertView(); + if (alias != null) + { /* otherwise the dialog was canceled, the request denied */ + getLoaderManager().restartLoader(USER_CERT_LOADER, null, mUserCertificateLoaderCallbacks); + } + } + }); + } + } + + /** + * Load the selected user certificate asynchronously. This cannot be done + * from the main thread as getCertificateChain() calls back to our main + * thread to bind to the KeyChain service resulting in a deadlock. + */ + private static class UserCertificateLoader extends AsyncTaskLoader + { + private final String mAlias; + private TrustedCertificateEntry mData; + + public UserCertificateLoader(Context context, String alias) + { + super(context); + mAlias = alias; + } + + @Override + public TrustedCertificateEntry loadInBackground() + { + X509Certificate[] chain = null; + try + { + chain = KeyChain.getCertificateChain(getContext(), mAlias); + } + catch (KeyChainException | InterruptedException e) + { + e.printStackTrace(); + } + if (chain != null && chain.length > 0) + { + return new TrustedCertificateEntry(mAlias, chain[0]); + } + return null; + } + + @Override + protected void onStartLoading() + { + if (mData != null) + { /* if we have data ready, deliver it directly */ + deliverResult(mData); + } + if (takeContentChanged() || mData == null) + { + forceLoad(); + } + } + + @Override + public void deliverResult(TrustedCertificateEntry data) + { + if (isReset()) + { + return; + } + mData = data; + if (isStarted()) + { /* if it is started we deliver the data directly, + * otherwise this is handled in onStartLoading */ + super.deliverResult(data); + } + } + + @Override + protected void onReset() + { + mData = null; + super.onReset(); + } + } + + private byte[] decodeBase64(String encoded) + { + if (encoded == null || encoded.isEmpty()) + { + return null; + } + byte[] data = null; + try + { + data = Base64.decode(encoded, Base64.DEFAULT); + } + catch (IllegalArgumentException e) + { + e.printStackTrace(); + } + return data; + } + + private class ParsedVpnProfile extends VpnProfile + { + public byte[] Certificate; + public byte[] PKCS12; + } +} diff --git a/src/frontends/android/app/src/main/res/drawable-hdpi/ic_close_white_24dp.png b/src/frontends/android/app/src/main/res/drawable-hdpi/ic_close_white_24dp.png new file mode 100644 index 0000000000..ceb1a1eebf Binary files /dev/null and b/src/frontends/android/app/src/main/res/drawable-hdpi/ic_close_white_24dp.png differ diff --git a/src/frontends/android/app/src/main/res/drawable-mdpi/ic_close_white_24dp.png b/src/frontends/android/app/src/main/res/drawable-mdpi/ic_close_white_24dp.png new file mode 100644 index 0000000000..af7f8288da Binary files /dev/null and b/src/frontends/android/app/src/main/res/drawable-mdpi/ic_close_white_24dp.png differ diff --git a/src/frontends/android/app/src/main/res/drawable-xhdpi/ic_close_white_24dp.png b/src/frontends/android/app/src/main/res/drawable-xhdpi/ic_close_white_24dp.png new file mode 100644 index 0000000000..b7c7ffd0e7 Binary files /dev/null and b/src/frontends/android/app/src/main/res/drawable-xhdpi/ic_close_white_24dp.png differ diff --git a/src/frontends/android/app/src/main/res/layout/profile_import_view.xml b/src/frontends/android/app/src/main/res/layout/profile_import_view.xml new file mode 100644 index 0000000000..fc06aa5d4a --- /dev/null +++ b/src/frontends/android/app/src/main/res/layout/profile_import_view.xml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +