/* * ConnectBot: simple, powerful, open-source SSH client for Android * Copyright 2007 Kenny Root, Jeffrey Sharkey * * 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 org.connectbot; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.security.KeyPair; import java.security.PrivateKey; import java.security.PublicKey; import java.util.Collections; import java.util.EventListener; import java.util.LinkedList; import java.util.List; import org.connectbot.bean.PubkeyBean; import org.connectbot.service.TerminalManager; import org.connectbot.util.PubkeyDatabase; import org.connectbot.util.PubkeyUtils; import org.openintents.intents.FileManagerIntents; import com.trilead.ssh2.crypto.Base64; import com.trilead.ssh2.crypto.PEMDecoder; import com.trilead.ssh2.crypto.PEMStructure; import android.annotation.TargetApi; import android.content.ActivityNotFoundException; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.DialogInterface; import android.content.DialogInterface.OnClickListener; import android.content.Intent; import android.content.ServiceConnection; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.os.IBinder; import android.support.annotation.VisibleForTesting; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.text.ClipboardManager; import android.util.Log; import android.view.ContextMenu; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.MenuItem.OnMenuItemClickListener; import android.view.View; import android.view.ViewGroup; import android.widget.EditText; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; /** * List public keys in database by nickname and describe their properties. Allow users to import, * generate, rename, and delete key pairs. * * @author Kenny Root */ public class PubkeyListActivity extends AppCompatListActivity implements EventListener { public final static String TAG = "CB.PubkeyListActivity"; private static final int MAX_KEYFILE_SIZE = 32768; private static final int REQUEST_CODE_PICK_FILE = 1; // Constants for AndExplorer's file picking intent private static final String ANDEXPLORER_TITLE = "explorer_title"; private static final String MIME_TYPE_ANDEXPLORER_FILE = "vnd.android.cursor.dir/lysesoft.andexplorer.file"; protected ClipboardManager clipboard; private TerminalManager bound = null; private ServiceConnection connection = new ServiceConnection() { public void onServiceConnected(ComponentName className, IBinder service) { bound = ((TerminalManager.TerminalBinder) service).getService(); // update our listview binder to find the service updateList(); } public void onServiceDisconnected(ComponentName className) { bound = null; updateList(); } }; @Override public void onStart() { super.onStart(); bindService(new Intent(this, TerminalManager.class), connection, Context.BIND_AUTO_CREATE); updateList(); } @Override public void onStop() { super.onStop(); unbindService(connection); } @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); setContentView(R.layout.act_pubkeylist); mListView = (RecyclerView) findViewById(R.id.list); mListView.setHasFixedSize(true); mListView.setLayoutManager(new LinearLayoutManager(this)); mListView.addItemDecoration(new ListItemDecoration(this)); mEmptyView = findViewById(R.id.empty); registerForContextMenu(mListView); clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); } @Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.pubkey_list_activity_menu, menu); return super.onCreateOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.add_new_key_icon: startActivity(new Intent(this, GeneratePubkeyActivity.class)); return true; case R.id.import_existing_key_icon: importExistingKey(); return true; default: return super.onOptionsItemSelected(item); } } private boolean importExistingKey() { Uri sdcard = Uri.fromFile(Environment.getExternalStorageDirectory()); String pickerTitle = getString(R.string.pubkey_list_pick); if (Build.VERSION.SDK_INT >= 19 && importExistingKeyKitKat()) { return true; } else { return importExistingKeyOpenIntents(sdcard, pickerTitle) || importExistingKeyAndExplorer(sdcard, pickerTitle) || pickFileSimple(); } } /** * Fires an intent to spin up the "file chooser" UI and select a private key. */ @TargetApi(19) public boolean importExistingKeyKitKat() { // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's file // browser. Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); // Filter to only show results that can be "opened", such as a // file (as opposed to a list of contacts or timezones) intent.addCategory(Intent.CATEGORY_OPENABLE); // PKCS#8 MIME types aren't widely supported, so we'll try */* fro now. intent.setType("*/*"); try { startActivityForResult(intent, REQUEST_CODE_PICK_FILE); return true; } catch (ActivityNotFoundException e) { return false; } } /** * Imports an existing key using the OpenIntents-style request. */ private boolean importExistingKeyOpenIntents(Uri sdcard, String pickerTitle) { // Try to use OpenIntent's file browser to pick a file Intent intent = new Intent(FileManagerIntents.ACTION_PICK_FILE); intent.setData(sdcard); intent.putExtra(FileManagerIntents.EXTRA_TITLE, pickerTitle); intent.putExtra(FileManagerIntents.EXTRA_BUTTON_TEXT, getString(android.R.string.ok)); try { startActivityForResult(intent, REQUEST_CODE_PICK_FILE); return true; } catch (ActivityNotFoundException e) { return false; } } private boolean importExistingKeyAndExplorer(Uri sdcard, String pickerTitle) { Intent intent; intent = new Intent(Intent.ACTION_PICK); intent.setDataAndType(sdcard, MIME_TYPE_ANDEXPLORER_FILE); intent.putExtra(ANDEXPLORER_TITLE, pickerTitle); try { startActivityForResult(intent, REQUEST_CODE_PICK_FILE); return true; } catch (ActivityNotFoundException e) { return false; } } /** * Builds a simple list of files to pick from. */ private boolean pickFileSimple() { // build list of all files in sdcard root final File sdcard = Environment.getExternalStorageDirectory(); Log.d(TAG, sdcard.toString()); // Don't show a dialog if the SD card is completely absent. final String state = Environment.getExternalStorageState(); if (!Environment.MEDIA_MOUNTED_READ_ONLY.equals(state) && !Environment.MEDIA_MOUNTED.equals(state)) { new android.support.v7.app.AlertDialog.Builder( PubkeyListActivity.this, R.style.AlertDialogTheme) .setMessage(R.string.alert_sdcard_absent) .setNegativeButton(android.R.string.cancel, null).create().show(); return true; } List<String> names = new LinkedList<String>(); { File[] files = sdcard.listFiles(); if (files != null) { for (File file : sdcard.listFiles()) { if (file.isDirectory()) continue; names.add(file.getName()); } } } Collections.sort(names); final String[] namesList = names.toArray(new String[] {}); Log.d(TAG, names.toString()); // prompt user to select any file from the sdcard root new android.support.v7.app.AlertDialog.Builder( PubkeyListActivity.this, R.style.AlertDialogTheme) .setTitle(R.string.pubkey_list_pick) .setItems(namesList, new OnClickListener() { public void onClick(DialogInterface arg0, int arg1) { String name = namesList[arg1]; readKeyFromFile(Uri.fromFile(new File(sdcard, name))); } }) .setNegativeButton(android.R.string.cancel, null).create().show(); return true; } protected void handleAddKey(final PubkeyBean pubkey) { if (pubkey.isEncrypted()) { final View view = View.inflate(this, R.layout.dia_password, null); final EditText passwordField = (EditText) view.findViewById(android.R.id.text1); new android.support.v7.app.AlertDialog.Builder( PubkeyListActivity.this, R.style.AlertDialogTheme) .setView(view) .setPositiveButton(R.string.pubkey_unlock, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int which) { handleAddKey(pubkey, passwordField.getText().toString()); } }) .setNegativeButton(android.R.string.cancel, null).create().show(); } else { handleAddKey(pubkey, null); } } protected void handleAddKey(PubkeyBean keybean, String password) { KeyPair pair = null; try { pair = PubkeyUtils.convertToKeyPair(keybean, password); } catch (PubkeyUtils.BadPasswordException e) { String message = getResources().getString(R.string.pubkey_failed_add, keybean.getNickname()); Toast.makeText(PubkeyListActivity.this, message, Toast.LENGTH_LONG).show(); } if (pair == null) { return; } Log.d(TAG, String.format("Unlocked key '%s'", keybean.getNickname())); // save this key in memory bound.addKey(keybean, pair, true); updateList(); } protected void updateList() { PubkeyDatabase pubkeyDb = PubkeyDatabase.get(PubkeyListActivity.this); mAdapter = new PubkeyAdapter(this, pubkeyDb.allPubkeys()); mListView.setAdapter(mAdapter); adjustViewVisibility(); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent resultData) { super.onActivityResult(requestCode, resultCode, resultData); switch (requestCode) { case REQUEST_CODE_PICK_FILE: if (resultCode == RESULT_OK && resultData != null) { Uri uri = resultData.getData(); try { if (uri != null) { readKeyFromFile(uri); } else { String filename = resultData.getDataString(); if (filename != null) { readKeyFromFile(Uri.parse(filename)); } } } catch (IllegalArgumentException e) { Log.e(TAG, "Couldn't read from picked file", e); } } break; } } public static byte[] getBytesFromInputStream(InputStream is, int maxSize) throws IOException { ByteArrayOutputStream os = new ByteArrayOutputStream(); byte[] buffer = new byte[0xFFFF]; for (int len; (len = is.read(buffer)) != -1 && os.size() < maxSize; ) { os.write(buffer, 0, len); } if (os.size() >= maxSize) { throw new IOException("File was too big"); } os.flush(); return os.toByteArray(); } private KeyPair readPKCS8Key(byte[] keyData) { BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(keyData))); // parse the actual key once to check if its encrypted // then save original file contents into our database try { ByteArrayOutputStream keyBytes = new ByteArrayOutputStream(); String line; boolean inKey = false; while ((line = reader.readLine()) != null) { if (line.equals(PubkeyUtils.PKCS8_START)) { inKey = true; } else if (line.equals(PubkeyUtils.PKCS8_END)) { break; } else if (inKey) { keyBytes.write(line.getBytes("US-ASCII")); } } if (keyBytes.size() > 0) { byte[] decoded = Base64.decode(keyBytes.toString().toCharArray()); return PubkeyUtils.recoverKeyPair(decoded); } } catch (Exception e) { return null; } return null; } /** * @param uri URI to private key to read. */ private void readKeyFromFile(Uri uri) { PubkeyBean pubkey = new PubkeyBean(); // find the exact file selected pubkey.setNickname(uri.getLastPathSegment()); byte[] keyData; try { ContentResolver resolver = getContentResolver(); keyData = getBytesFromInputStream(resolver.openInputStream(uri), MAX_KEYFILE_SIZE); } catch (IOException e) { Toast.makeText(PubkeyListActivity.this, R.string.pubkey_import_parse_problem, Toast.LENGTH_LONG).show(); return; } KeyPair kp; if ((kp = readPKCS8Key(keyData)) != null) { String algorithm = convertAlgorithmName(kp.getPrivate().getAlgorithm()); pubkey.setType(algorithm); pubkey.setPrivateKey(kp.getPrivate().getEncoded()); pubkey.setPublicKey(kp.getPublic().getEncoded()); } else { try { PEMStructure struct = PEMDecoder.parsePEM(new String(keyData).toCharArray()); boolean encrypted = PEMDecoder.isPEMEncrypted(struct); pubkey.setEncrypted(encrypted); if (!encrypted) { kp = PEMDecoder.decode(struct, null); String algorithm = convertAlgorithmName(kp.getPrivate().getAlgorithm()); pubkey.setType(algorithm); pubkey.setPrivateKey(kp.getPrivate().getEncoded()); pubkey.setPublicKey(kp.getPublic().getEncoded()); } else { pubkey.setType(PubkeyDatabase.KEY_TYPE_IMPORTED); pubkey.setPrivateKey(keyData); } } catch (IOException e) { Log.e(TAG, "Problem parsing imported private key", e); Toast.makeText(PubkeyListActivity.this, R.string.pubkey_import_parse_problem, Toast.LENGTH_LONG).show(); } } // write new value into database PubkeyDatabase pubkeyDb = PubkeyDatabase.get(this); pubkeyDb.savePubkey(pubkey); updateList(); } private String convertAlgorithmName(String algorithm) { if ("EdDSA".equals(algorithm)) { return PubkeyDatabase.KEY_TYPE_ED25519; } else { return algorithm; } } public class PubkeyViewHolder extends ItemViewHolder { public final ImageView icon; public final TextView nickname; public final TextView caption; public PubkeyBean pubkey; public PubkeyViewHolder(View v) { super(v); icon = (ImageView) v.findViewById(android.R.id.icon); nickname = (TextView) v.findViewById(android.R.id.text1); caption = (TextView) v.findViewById(android.R.id.text2); } @Override public void onClick(View v) { boolean loaded = bound != null && bound.isKeyLoaded(pubkey.getNickname()); // handle toggling key in-memory on/off if (loaded) { bound.removeKey(pubkey.getNickname()); updateList(); } else { handleAddKey(pubkey); } } @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { // Create menu to handle deleting and editing pubkey menu.setHeaderTitle(pubkey.getNickname()); // TODO: option load/unload key from in-memory list // prompt for password as needed for passworded keys // cant change password or clipboard imported keys final boolean imported = PubkeyDatabase.KEY_TYPE_IMPORTED.equals(pubkey.getType()); final boolean loaded = bound != null && bound.isKeyLoaded(pubkey.getNickname()); MenuItem load = menu.add(loaded ? R.string.pubkey_memory_unload : R.string.pubkey_memory_load); load.setOnMenuItemClickListener(new OnMenuItemClickListener() { public boolean onMenuItemClick(MenuItem item) { if (loaded) { bound.removeKey(pubkey.getNickname()); updateList(); } else { handleAddKey(pubkey); //bound.addKey(nickname, trileadKey); } return true; } }); MenuItem onstartToggle = menu.add(R.string.pubkey_load_on_start); onstartToggle.setEnabled(!pubkey.isEncrypted()); onstartToggle.setCheckable(true); onstartToggle.setChecked(pubkey.isStartup()); onstartToggle.setOnMenuItemClickListener(new OnMenuItemClickListener() { public boolean onMenuItemClick(MenuItem item) { // toggle onstart status pubkey.setStartup(!pubkey.isStartup()); PubkeyDatabase pubkeyDb = PubkeyDatabase.get(PubkeyListActivity.this); pubkeyDb.savePubkey(pubkey); updateList(); return true; } }); MenuItem copyPublicToClipboard = menu.add(R.string.pubkey_copy_public); copyPublicToClipboard.setEnabled(!imported); copyPublicToClipboard.setOnMenuItemClickListener(new OnMenuItemClickListener() { public boolean onMenuItemClick(MenuItem item) { try { PublicKey pk = PubkeyUtils.decodePublic(pubkey.getPublicKey(), pubkey.getType()); String openSSHPubkey = PubkeyUtils.convertToOpenSSHFormat(pk, pubkey.getNickname()); clipboard.setText(openSSHPubkey); } catch (Exception e) { e.printStackTrace(); } return true; } }); MenuItem copyPrivateToClipboard = menu.add(R.string.pubkey_copy_private); copyPrivateToClipboard.setEnabled(!pubkey.isEncrypted() || imported); copyPrivateToClipboard.setOnMenuItemClickListener(new OnMenuItemClickListener() { public boolean onMenuItemClick(MenuItem item) { try { String data = null; if (imported) data = new String(pubkey.getPrivateKey()); else { PrivateKey pk = PubkeyUtils.decodePrivate(pubkey.getPrivateKey(), pubkey.getType()); data = PubkeyUtils.exportPEM(pk, null); } clipboard.setText(data); } catch (Exception e) { e.printStackTrace(); } return true; } }); MenuItem changePassword = menu.add(R.string.pubkey_change_password); changePassword.setEnabled(!imported); changePassword.setOnMenuItemClickListener(new OnMenuItemClickListener() { public boolean onMenuItemClick(MenuItem item) { final View changePasswordView = View.inflate(PubkeyListActivity.this, R.layout.dia_changepassword, null); changePasswordView.findViewById(R.id.old_password_prompt) .setVisibility(pubkey.isEncrypted() ? View.VISIBLE : View.GONE); new android.support.v7.app.AlertDialog.Builder( PubkeyListActivity.this, R.style.AlertDialogTheme) .setView(changePasswordView) .setPositiveButton(R.string.button_change, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int which) { String oldPassword = ((EditText) changePasswordView.findViewById(R.id.old_password)).getText().toString(); String password1 = ((EditText) changePasswordView.findViewById(R.id.password1)).getText().toString(); String password2 = ((EditText) changePasswordView.findViewById(R.id.password2)).getText().toString(); if (!password1.equals(password2)) { new android.support.v7.app.AlertDialog.Builder( PubkeyListActivity.this, R.style.AlertDialogTheme) .setMessage(R.string.alert_passwords_do_not_match_msg) .setPositiveButton(android.R.string.ok, null) .create().show(); return; } try { if (!pubkey.changePassword(oldPassword, password1)) new android.support.v7.app.AlertDialog.Builder( PubkeyListActivity.this, R.style.AlertDialogTheme) .setMessage(R.string.alert_wrong_password_msg) .setPositiveButton(android.R.string.ok, null) .create().show(); else { PubkeyDatabase pubkeyDb = PubkeyDatabase.get(PubkeyListActivity.this); pubkeyDb.savePubkey(pubkey); updateList(); } } catch (Exception e) { Log.e(TAG, "Could not change private key password", e); new android.support.v7.app.AlertDialog.Builder( PubkeyListActivity.this, R.style.AlertDialogTheme) .setMessage(R.string.alert_key_corrupted_msg) .setPositiveButton(android.R.string.ok, null) .create().show(); } } }) .setNegativeButton(android.R.string.cancel, null).create().show(); return true; } }); MenuItem confirmUse = menu.add(R.string.pubkey_confirm_use); confirmUse.setCheckable(true); confirmUse.setChecked(pubkey.isConfirmUse()); confirmUse.setOnMenuItemClickListener(new OnMenuItemClickListener() { public boolean onMenuItemClick(MenuItem item) { // toggle confirm use pubkey.setConfirmUse(!pubkey.isConfirmUse()); PubkeyDatabase pubkeyDb = PubkeyDatabase.get(PubkeyListActivity.this); pubkeyDb.savePubkey(pubkey); updateList(); return true; } }); MenuItem delete = menu.add(R.string.pubkey_delete); delete.setOnMenuItemClickListener(new OnMenuItemClickListener() { public boolean onMenuItemClick(MenuItem item) { // prompt user to make sure they really want this new android.support.v7.app.AlertDialog.Builder( PubkeyListActivity.this, R.style.AlertDialogTheme) .setMessage(getString(R.string.delete_message, pubkey.getNickname())) .setPositiveButton(R.string.delete_pos, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int which) { // dont forget to remove from in-memory if (loaded) { bound.removeKey(pubkey.getNickname()); } // delete from backend database and update gui PubkeyDatabase pubkeyDb = PubkeyDatabase.get(PubkeyListActivity.this); pubkeyDb.deletePubkey(pubkey); updateList(); } }) .setNegativeButton(R.string.delete_neg, null).create().show(); return true; } }); } } @VisibleForTesting private class PubkeyAdapter extends ItemAdapter { private final List<PubkeyBean> pubkeys; public PubkeyAdapter(Context context, List<PubkeyBean> pubkeys) { super(context); this.pubkeys = pubkeys; } @Override public PubkeyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_pubkey, parent, false); return new PubkeyViewHolder(v); } public void onBindViewHolder(ItemViewHolder holder, int position) { PubkeyViewHolder pubkeyHolder = (PubkeyViewHolder) holder; PubkeyBean pubkey = pubkeys.get(position); pubkeyHolder.pubkey = pubkey; if (pubkey == null) { // Well, something bad happened. We can't continue. Log.e("PubkeyAdapter", "Pubkey bean is null!"); pubkeyHolder.nickname.setText("Error during lookup"); } else { pubkeyHolder.nickname.setText(pubkey.getNickname()); } boolean imported = PubkeyDatabase.KEY_TYPE_IMPORTED.equals(pubkey.getType()); if (imported) { try { PEMStructure struct = PEMDecoder.parsePEM(new String(pubkey.getPrivateKey()).toCharArray()); String type; if (struct.pemType == PEMDecoder.PEM_RSA_PRIVATE_KEY) { type = "RSA"; } else if (struct.pemType == PEMDecoder.PEM_DSA_PRIVATE_KEY) { type = "DSA"; } else if (struct.pemType == PEMDecoder.PEM_EC_PRIVATE_KEY) { type = "EC"; } else if (struct.pemType == PEMDecoder.PEM_OPENSSH_PRIVATE_KEY) { type = "OpenSSH"; } else { throw new RuntimeException("Unexpected key type: " + struct.pemType); } pubkeyHolder.caption.setText(String.format("%s unknown-bit", type)); } catch (IOException e) { Log.e(TAG, "Error decoding IMPORTED public key at " + pubkey.getId(), e); } } else { try { pubkeyHolder.caption.setText(pubkey.getDescription(getApplicationContext())); } catch (Exception e) { Log.e(TAG, "Error decoding public key at " + pubkey.getId(), e); pubkeyHolder.caption.setText(R.string.pubkey_unknown_format); } } if (bound == null) { pubkeyHolder.icon.setVisibility(View.GONE); } else { pubkeyHolder.icon.setVisibility(View.VISIBLE); if (bound.isKeyLoaded(pubkey.getNickname())) pubkeyHolder.icon.setImageState(new int[] { android.R.attr.state_checked }, true); else pubkeyHolder.icon.setImageState(new int[] { }, true); } } @Override public int getItemCount() { return pubkeys.size(); } @Override public long getItemId(int position) { return pubkeys.get(position).getId(); } } }