diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml index da465ba74..a0c79875f 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 000000000..ceb1a1eeb 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 000000000..af7f8288d 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 000000000..b7c7ffd0e 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 000000000..fc06aa5d4 --- /dev/null +++ b/src/frontends/android/app/src/main/res/layout/profile_import_view.xml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +