-Generalize SFTP query param option map building
-Add "key" and "phrase" as SFTP query params
  key: custom private key name
  phrase: passphrase used to unlock key
-Add CRUD support for custom private keys
  Key files are stored in "user_keys" subdirectory
  File names are constructed by (sanitized) key name
  Basic support for private key content validation
-Existing and new key-related functionality moved into
  SftpPublicPrivateKeyUtils class

UI:
-Add custom private key support to SFTP Credentials dialog
  Add a new auth mode item (authModeSpinner)
  Add Spinner showing saved private key names, with an option
   to create a new one (top).
  Add Delete Private Key button; deletes the selected key
   in Spinner

Testing:
-Add custom private key CRUD support to JavaFileStorageTest app
 via file chooser SFTP Credentials panel
This commit is contained in:
Rick Brown
2023-01-16 19:45:13 -05:00
parent 83e77b2a31
commit 5e265d1816
8 changed files with 809 additions and 164 deletions

View File

@@ -0,0 +1,216 @@
package keepass2android.javafilestorage;
import android.util.Pair;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileWriter;
import java.io.IOException;
import java.io.StringReader;
import java.util.regex.Pattern;
import androidx.annotation.Nullable;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.KeyPair;
class SftpPublicPrivateKeyUtils {
private enum Validity {
NOT_ATTEMPTED, VALID, NOT_VALID;
}
private static final String SFTP_CUSTOM_KEY_DIRNAME = "user_keys";
private static final String KP2A_PRIVATE_KEY_FILENAME = "id_kp2a_rsa";
private final File appBaseDir;
/**
* Do NOT access this variable directly! Use {@link #baseDir()} instead.
*/
private final File customKeyBaseDir;
private volatile Validity validDir = Validity.NOT_ATTEMPTED;
SftpPublicPrivateKeyUtils(String appBaseDir) {
// Assume app base directory exists already
this.appBaseDir = new File(appBaseDir);
// Intentionally skipping existence/creation checking in constructor
// See baseDir()
this.customKeyBaseDir = new File(appBaseDir, SFTP_CUSTOM_KEY_DIRNAME);
}
private Pair<File, Boolean> baseDir() {
if (validDir == Validity.NOT_ATTEMPTED) {
synchronized (this) {
if (!customKeyBaseDir.exists()) {
customKeyBaseDir.mkdirs();
}
if (customKeyBaseDir.exists() && customKeyBaseDir.isDirectory()) {
validDir = Validity.VALID;
} else {
validDir = Validity.NOT_VALID;
}
}
}
return new Pair<>(customKeyBaseDir, validDir == Validity.VALID);
}
boolean deleteCustomKey(String keyName) throws FileNotFoundException {
File f = getCustomKeyFile(keyName);
return f.isFile() && f.delete();
}
String[] getCustomKeyNames() {
Pair<File, Boolean> base = baseDir();
if (!base.second) {
// Log it?
return new String[]{};
}
return base.first.list();
}
void savePrivateKeyContent(String keyName, String keyContent) throws IOException, Exception {
keyContent = PrivateKeyValidator.ensureValidContent(keyContent);
File f = getCustomKeyFile(keyName);
try (BufferedWriter w = new BufferedWriter(new FileWriter(f))) {
w.write(keyContent);
}
}
String getCustomKeyFilePath(String customKeyName) throws FileNotFoundException {
return getCustomKeyFile(customKeyName).getAbsolutePath();
}
String resolveKeyFilePath(JSch jschInst, @Nullable String customKeyName) {
// Custom private key configured
if (customKeyName != null) {
try {
return getCustomKeyFilePath(customKeyName);
} catch (FileNotFoundException e) {
System.out.println(e);
}
}
// Use KP2A's public/private key
String keyFilePath = getAppKeyFileName();
try{
createKeyPair(jschInst, keyFilePath);
} catch (Exception ex) {
System.out.println(ex);
}
return keyFilePath;
}
String createKeyPair(JSch jschInst) throws IOException, JSchException {
return createKeyPair(jschInst, getAppKeyFileName());
}
/**
* Exposed for testing purposes only
* @param keyName
* @return
*/
String getSanitizedCustomKeyName(String keyName) {
return PrivateKeyValidator.sanitizeKeyAsFilename(keyName);
}
/**
* Exposed for testing purposes only.
* @param keyContent
* @return
* @throws Exception
*/
String getValidatedCustomKeyContent(String keyContent) throws Exception {
return PrivateKeyValidator.ensureValidContent(keyContent);
}
private String createKeyPair(JSch jschInst, String key_filename) throws JSchException, IOException {
String public_key_filename = key_filename + ".pub";
File file = new File(key_filename);
if (file.exists())
return public_key_filename;
int type = KeyPair.RSA;
KeyPair kpair = KeyPair.genKeyPair(jschInst, type, 4096);
kpair.writePrivateKey(key_filename);
kpair.writePublicKey(public_key_filename, "generated by Keepass2Android");
//ret = "Fingerprint: " + kpair.getFingerPrint();
kpair.dispose();
return public_key_filename;
}
private String getAppKeyFileName() {
return new File(appBaseDir, KP2A_PRIVATE_KEY_FILENAME).getAbsolutePath();
}
private File getCustomKeyFile(String customKeyName) throws FileNotFoundException {
Pair<File, Boolean> base = baseDir();
if (!base.second) {
throw new FileNotFoundException("Custom key directory");
}
String keyFileName = PrivateKeyValidator.sanitizeKeyAsFilename(customKeyName);
if (!keyFileName.isEmpty()) {
File keyFile = new File(base.first, keyFileName);
// Protect against bad actors trying to navigate away from the base directory.
// This is probably overkill, given sanitizeKeyAsFilename(...) but better safe than sorry.
if (base.first.equals(keyFile.getParentFile())) {
return keyFile;
}
}
// The key was sanitized to nothing, or the parent check above failed.
throw new FileNotFoundException("Malformed key name");
}
private static class PrivateKeyValidator {
private static final Pattern CONTENT_FIRST_LINE = Pattern.compile("^-+BEGIN\\s[^\\s]+\\sPRIVATE\\sKEY-+$");
private static final Pattern CONTENT_LAST_LINE = Pattern.compile("^-+END\\s[^\\s]+\\sPRIVATE\\sKEY-+$");
/**
* Key-to-filename sanitizer solution sourced from:
* <a href="https://www.b4x.com/android/forum/threads/sanitize-filename.82558/" />
*/
private static final Pattern KEY_SANITIZER = Pattern.compile("([^\\p{L}\\s\\d\\-_~,;:\\[\\]\\(\\).'])",
Pattern.CASE_INSENSITIVE);
static String sanitizeKeyAsFilename(String key) {
return KEY_SANITIZER.matcher(key.trim()).replaceAll("");
}
static String ensureValidContent(String content) throws Exception {
content = content.trim();
boolean isValid = true;
try (BufferedReader r = new BufferedReader(new StringReader(content))) {
boolean validFirst = false;
String line;
String last = null;
while ((line = r.readLine()) != null) {
if (!validFirst) {
if (CONTENT_FIRST_LINE.matcher(line).matches()) {
validFirst = true;
} else {
isValid = false;
break;
}
}
last = line;
}
if (!isValid || last == null || !CONTENT_LAST_LINE.matcher(last).matches()) {
throw new RuntimeException("Malformed private key content");
}
} catch (Exception e) {
android.util.Log.d(SftpStorage.class.getName(), "Invalid key content", e);
throw e;
}
return content;
}
}
}

View File

@@ -2,7 +2,6 @@ package keepass2android.javafilestorage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
@@ -20,7 +19,6 @@ import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.ChannelSftp.LsEntry;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.KeyPair;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpATTRS;
import com.jcraft.jsch.SftpException;
@@ -30,11 +28,32 @@ import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
@SuppressWarnings("unused") // Exposed by JavaFileStorageBindings
public class SftpStorage extends JavaFileStorageBase {
@FunctionalInterface
interface ValueResolver<T> {
/**
* Takes a raw value and resolves it to either a String containing the String representation
* of that value, or null. The latter signifying that the raw value could not be "resolved".
*
* @param value
* @return String, or null if not resolvable
*/
String resolve(T value);
}
public static final int DEFAULT_SFTP_PORT = 22;
public static final int UNSET_SFTP_CONNECT_TIMEOUT = -1;
private static final String SFTP_CONNECT_TIMEOUT_OPTION_NAME = "connectTimeout";
private static final String SFTP_KEYNAME_OPTION_NAME = "key";
private static final String SFTP_KEYPASSPHRASE_OPTION_NAME = "phrase";
private static final ValueResolver<Integer> cTimeoutResolver = c ->
c == null || c == UNSET_SFTP_CONNECT_TIMEOUT ? null : String.valueOf(c);
private static final ValueResolver<String> nonBlankStringResolver = s ->
s == null || s.isBlank() ? null : s;
JSch jsch;
@@ -44,39 +63,36 @@ public class SftpStorage extends JavaFileStorageBase {
public String username;
public String password;
public String localPath;
public String keyName;
public String keyPassphrase;
public int port;
public int connectTimeoutSec = UNSET_SFTP_CONNECT_TIMEOUT;
public String toString() {
return "ConnectionInfo{host=" + host + ",port=" + port + ",user=" + username +
",pwd=<hidden>,path=" + localPath + ",connectTimeout=" + connectTimeoutSec +
",pwd=<hidden>,localPath=" + localPath + ",key=" + keyName +
",phrase=<hidden>,connectTimeout=" + connectTimeoutSec +
"}";
}
}
boolean hasOptions() {
return connectTimeoutSec != UNSET_SFTP_CONNECT_TIMEOUT;
private static Map<String, String> buildOptionMap(ConnectionInfo ci, boolean includeSensitive) {
OptionMapBuilder b = new OptionMapBuilder()
.addOption(SFTP_CONNECT_TIMEOUT_OPTION_NAME, ci.connectTimeoutSec, cTimeoutResolver)
.addOption(SFTP_KEYNAME_OPTION_NAME, ci.keyName, nonBlankStringResolver);
if (includeSensitive) {
b.addOption(SFTP_KEYPASSPHRASE_OPTION_NAME, ci.keyPassphrase, nonBlankStringResolver);
}
return b.build();
}
private static Map<String, String> buildOptionMap(ConnectionInfo ci) {
return buildOptionMap(ci.connectTimeoutSec);
}
private static Map<String, String> buildOptionMap(int connectTimeoutSec) {
Map<String, String> opts = new HashMap<>();
if (connectTimeoutSec != UNSET_SFTP_CONNECT_TIMEOUT) {
opts.put(SFTP_CONNECT_TIMEOUT_OPTION_NAME, String.valueOf(connectTimeoutSec));
}
return opts;
}
Context _appContext;
private final SftpPublicPrivateKeyUtils _keyUtils;
public SftpStorage(Context appContext) {
_appContext = appContext;
_keyUtils = new SftpPublicPrivateKeyUtils(getBaseDir());
}
private static final String SFTP_PROTOCOL_ID = "sftp";
@@ -184,7 +200,8 @@ public class SftpStorage extends JavaFileStorageBase {
tryDisconnect(c);
return buildFullPath(cInfo.host, cInfo.port, newPath,
cInfo.username, cInfo.password, cInfo.connectTimeoutSec);
cInfo.username, cInfo.password, cInfo.connectTimeoutSec,
cInfo.keyName, cInfo.keyPassphrase);
} catch (Exception e) {
throw convertException(e);
}
@@ -402,28 +419,23 @@ public class SftpStorage extends JavaFileStorageBase {
return java.net.URLEncoder.encode(unencoded, UTF_8);
}
ChannelSftp init(ConnectionInfo cInfo) throws JSchException, UnsupportedEncodingException {
jsch = new JSch();
String base_dir = getBaseDir();
jsch.setKnownHosts(base_dir + "/known_hosts");
String key_filename = getKeyFileName();
try{
createKeyPair(key_filename);
} catch (Exception ex) {
System.out.println(ex);
}
String key_filepath = _keyUtils.resolveKeyFilePath(jsch, cInfo.keyName);
try {
jsch.addIdentity(key_filename);
} catch (java.lang.Exception e)
{
jsch.addIdentity(key_filepath);
} catch (java.lang.Exception e) {
}
Session session = jsch.getSession(cInfo.username, cInfo.host, cInfo.port);
UserInfo ui = new SftpUserInfo(cInfo.password, _appContext);
UserInfo ui = new SftpUserInfo(cInfo.password, cInfo.keyPassphrase, _appContext);
session.setUserInfo(ui);
session.setConfig("PreferredAuthentications", "publickey,password");
@@ -434,7 +446,6 @@ public class SftpStorage extends JavaFileStorageBase {
channel.connect();
ChannelSftp c = (ChannelSftp) channel;
logDebug("success: init Sftp");
return c;
}
@@ -451,38 +462,60 @@ public class SftpStorage extends JavaFileStorageBase {
return _appContext.getFilesDir().getAbsolutePath();
}
private String getKeyFileName() {
return getBaseDir() + "/id_kp2a_rsa";
public boolean deleteCustomKey(String keyName) throws FileNotFoundException {
return _keyUtils.deleteCustomKey(keyName);
}
public String[] getCustomKeyNames() {
return _keyUtils.getCustomKeyNames();
}
@SuppressWarnings("unused") // Exposed by JavaFileStorageBindings
public String createKeyPair() throws IOException, JSchException {
return createKeyPair(getKeyFileName());
return _keyUtils.createKeyPair(jsch);
}
private String createKeyPair(String key_filename) throws JSchException, IOException {
String public_key_filename = key_filename + ".pub";
File file = new File(key_filename);
if (file.exists())
return public_key_filename;
int type = KeyPair.RSA;
KeyPair kpair = KeyPair.genKeyPair(jsch, type, 4096);
kpair.writePrivateKey(key_filename);
kpair.writePublicKey(public_key_filename, "generated by Keepass2Android");
//ret = "Fingerprint: " + kpair.getFingerPrint();
kpair.dispose();
return public_key_filename;
@SuppressWarnings("unused") // Exposed by JavaFileStorageBindings
public void savePrivateKeyContent(String keyName, String keyContent) throws IOException, Exception {
_keyUtils.savePrivateKeyContent(keyName, keyContent);
}
/**
* Exposed for testing purposes only.
* @param keyName
* @return
*/
public String sanitizeCustomKeyName(String keyName) {
return _keyUtils.getSanitizedCustomKeyName(keyName);
}
/**
* Exposed for testing purposes only.
* @param keyContent
* @return
* @throws Exception
*/
public String getValidatedCustomKeyContent(String keyContent) throws Exception {
return _keyUtils.getValidatedCustomKeyContent(keyContent);
}
public ConnectionInfo splitStringToConnectionInfo(String filename)
throws UnsupportedEncodingException {
ConnectionInfo ci = new ConnectionInfo();
ci.host = extractUserPwdHostPort(filename);
String userPwd = ci.host.substring(0, ci.host.indexOf('@'));
ci.username = decode(userPwd.substring(0, userPwd.indexOf(":")));
ci.password = decode(userPwd.substring(userPwd.indexOf(":")+1));
int sepIdx = userPwd.indexOf(":");
if (sepIdx > 0) {
ci.username = decode(userPwd.substring(0, sepIdx));
ci.password = decode(userPwd.substring(sepIdx + 1));
} else {
ci.username = userPwd;
ci.password = null;
}
ci.host = ci.host.substring(ci.host.indexOf('@') + 1);
ci.port = DEFAULT_SFTP_PORT;
int portSeparatorIndex = ci.host.indexOf(":");
@@ -503,6 +536,12 @@ public class SftpStorage extends JavaFileStorageBase {
logDebug(SFTP_CONNECT_TIMEOUT_OPTION_NAME + " option not a number: " + optVal);
}
}
if (options.containsKey(SFTP_KEYNAME_OPTION_NAME)) {
ci.keyName = options.get(SFTP_KEYNAME_OPTION_NAME);
}
if (options.containsKey(SFTP_KEYPASSPHRASE_OPTION_NAME)) {
ci.keyPassphrase = options.get(SFTP_KEYPASSPHRASE_OPTION_NAME);
}
return ci;
}
@@ -544,9 +583,7 @@ public class SftpStorage extends JavaFileStorageBase {
.append("@")
.append(ci.host)
.append(ci.localPath);
if (ci.hasOptions()) {
appendOptions(dName, buildOptionMap(ci));
}
appendOptions(dName, buildOptionMap(ci, false));
return dName.toString();
}
catch (Exception e)
@@ -580,11 +617,15 @@ public class SftpStorage extends JavaFileStorageBase {
public String buildFullPath(String host, int port, String localPath,
String username, String password,
int connectTimeoutSec)
int connectTimeoutSec,
String keyName, String keyPassphrase)
throws UnsupportedEncodingException {
StringBuilder uri = new StringBuilder(getProtocolPrefix())
.append(encode(username)).append(":").append(encode(password))
.append("@").append(host);
StringBuilder uri = new StringBuilder(getProtocolPrefix()).append(encode(username));
if (password != null) {
uri.append(":").append(encode(password));
}
uri.append("@").append(host);
if (port != DEFAULT_SFTP_PORT) {
uri.append(":").append(port);
@@ -593,8 +634,11 @@ public class SftpStorage extends JavaFileStorageBase {
uri.append(localPath);
}
Map<String, String> opts = buildOptionMap(connectTimeoutSec);
appendOptions(uri, opts);
appendOptions(uri, new OptionMapBuilder()
.addOption(SFTP_CONNECT_TIMEOUT_OPTION_NAME, connectTimeoutSec, cTimeoutResolver)
.addOption(SFTP_KEYNAME_OPTION_NAME, keyName, nonBlankStringResolver)
.addOption(SFTP_KEYPASSPHRASE_OPTION_NAME, keyPassphrase, nonBlankStringResolver)
.build());
return uri.toString();
}
@@ -635,4 +679,31 @@ public class SftpStorage extends JavaFileStorageBase {
return o1.getKey().compareTo(o2.getKey());
}
}
private static class OptionMapBuilder {
private final Map<String, String> options = new HashMap<>();
/**
* Attempts to add a raw value <code>oVal</code> to the underlying option map with key <code>oName</code>
* iff the <code>resolver</code> produces a non-null output when invoked using the raw value.
*
* @param oName the name/key associated with the value, if added
* @param oVal the raw value attempting to be added
* @param resolver the resolver that determines if the value will be added
*
* @return OptionMapBuilder (updated)
* @param <T> the raw value type
*/
<T> OptionMapBuilder addOption(final String oName, T oVal, ValueResolver<T> resolver) {
String resolved = resolver.resolve(oVal);
if (resolved != null) {
options.put(oName, resolved);
}
return this;
}
Map<String, String> build() {
return new HashMap<>(options);
}
}
}

View File

@@ -113,17 +113,19 @@ public class SftpUserInfo implements UserInfo {
Context _appContext;
String _password;
String _passphrase;
public SftpUserInfo(String password, Context appContext)
public SftpUserInfo(String password, String passphrase, Context appContext)
{
_password = password;
_passphrase = passphrase;
_appContext = appContext;
}
@Override
public String getPassphrase() {
return null;
return _passphrase;
}
@Override
@@ -134,12 +136,12 @@ public class SftpUserInfo implements UserInfo {
@Override
public boolean promptPassword(String message) {
return true;
return _password != null;
}
@Override
public boolean promptPassphrase(String message) {
return false; //passphrase not supported
return _passphrase != null;
}
@Override

View File

@@ -697,12 +697,11 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
if (protocolId.equals("sftp"))
{
final View view = getLayoutInflater().inflate(R.layout.sftp_credentials, null);
final SftpStorage sftpStorage = (SftpStorage)storageToTest;
view.findViewById(R.id.send_public_key).setOnClickListener(v -> {
Intent sendIntent = new Intent();
SftpStorage sftpStorage = (SftpStorage)storageToTest;
try {
String pub_filename = sftpStorage.createKeyPair();
@@ -715,51 +714,121 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
}
catch (Exception ex)
{
Toast.makeText(this,"Failed to create key pair: " + ex.getMessage(), Toast.LENGTH_LONG);
return;
Toast.makeText(this,"Failed to create key pair: " + ex.getMessage(), Toast.LENGTH_LONG).show();
}
});
view.findViewById(R.id.list_private_keys).setOnClickListener(v -> {
String[] keys = sftpStorage.getCustomKeyNames();
Toast.makeText(this, "keys: " + String.join(",", keys), Toast.LENGTH_LONG).show();
});
view.findViewById(R.id.add_private_key).setOnClickListener(v -> {
EditText etKeyName = view.findViewById(R.id.private_key_name);
String keyName = etKeyName.getText().toString();
EditText etKeyContent = view.findViewById(R.id.private_key_content);
String keyContent = etKeyContent.getText().toString();
try {
sftpStorage.savePrivateKeyContent(keyName, keyContent);
Toast.makeText(this, "Add successful", Toast.LENGTH_LONG).show();
}
catch (Exception e) {
Toast.makeText(this, "Add failed: " + e.getMessage(), Toast.LENGTH_LONG).show();
}
});
view.findViewById(R.id.delete_private_key).setOnClickListener(v -> {
EditText etKeyName = view.findViewById(R.id.private_key_name);
String keyName = etKeyName.getText().toString();
String exMessage = null;
boolean success = false;
try {
success = sftpStorage.deleteCustomKey(keyName);
}
catch (Exception e) {
exMessage = e.getMessage();
}
StringBuilder msg = new StringBuilder("Delete ");
msg.append(success ? "succeeded" : "FAILED");
if (exMessage != null) {
msg.append(" (").append(exMessage).append(")");
}
Toast.makeText(this, msg.toString(), Toast.LENGTH_LONG).show();
});
view.findViewById(R.id.validate_private_key).setOnClickListener(v -> {
EditText etKeyName = view.findViewById(R.id.private_key_name);
String inKeyName = etKeyName.getText().toString();
if (!inKeyName.isEmpty()) {
String keyResponse;
try {
keyResponse = sftpStorage.sanitizeCustomKeyName(inKeyName);
} catch (Exception e) {
keyResponse = "EX:" + e.getMessage();
}
String msg = "key: [" + inKeyName + "] -> [" + keyResponse + "]";
Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
}
EditText etKeyContent = view.findViewById(R.id.private_key_content);
String inKeyContent = etKeyContent.getText().toString();
String msg;
if (!inKeyContent.isEmpty()) {
try {
// We could print the key, but I don't it's that helpful
sftpStorage.getValidatedCustomKeyContent(inKeyContent);
msg = "Key content is valid";
} catch (Exception e) {
msg = "Invalid key content: " + e.getMessage();
}
Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
}
});
new AlertDialog.Builder(this)
.setView(view)
.setTitle("Enter SFTP credentials")
.setPositiveButton("OK",new DialogInterface.OnClickListener() {
.setPositiveButton("OK", (dialog, which) -> {
@Override
public void onClick(DialogInterface dialog, int which) {
Toast.makeText(MainActivity.this, "Hey", Toast.LENGTH_LONG).show();
Toast.makeText(MainActivity.this, "Hey", Toast.LENGTH_LONG).show();
SftpStorage sftpStorage = (SftpStorage)storageToTest;
try {
EditText etHost = ((EditText)view.findViewById(R.id.sftp_host));
String host = etHost.getText().toString();
EditText etUser = ((EditText)view.findViewById(R.id.sftp_user));
String user = etUser.getText().toString();
EditText etPwd = ((EditText)view.findViewById(R.id.sftp_password));
String pwd = etPwd.getText().toString();
EditText etPort = ((EditText)view.findViewById(R.id.sftp_port));
int port = Integer.parseInt(etPort.getText().toString());
EditText etInitDir = ((EditText)view.findViewById(R.id.sftp_initial_dir));
String initialDir = etInitDir.getText().toString();
EditText etConnectTimeout = ((EditText)view.findViewById(R.id.sftp_connect_timeout));
int connectTimeout = SftpStorage.UNSET_SFTP_CONNECT_TIMEOUT;
String ctStr = etConnectTimeout.getText().toString();
if (!ctStr.isEmpty()) {
try {
int ct = Integer.parseInt(ctStr);
if (connectTimeout != ct) {
connectTimeout = ct;
}
} catch (NumberFormatException parseEx) {
SftpStorage sftpStorage1 = (SftpStorage)storageToTest;
try {
EditText etHost = view.findViewById(R.id.sftp_host);
String host = etHost.getText().toString();
EditText etUser = view.findViewById(R.id.sftp_user);
String user = etUser.getText().toString();
EditText etPwd = view.findViewById(R.id.sftp_password);
String pwd = etPwd.getText().toString();
EditText etPort = view.findViewById(R.id.sftp_port);
int port = Integer.parseInt(etPort.getText().toString());
EditText etInitDir = view.findViewById(R.id.sftp_initial_dir);
String initialDir = etInitDir.getText().toString();
EditText etConnectTimeout = view.findViewById(R.id.sftp_connect_timeout);
int connectTimeout = SftpStorage.UNSET_SFTP_CONNECT_TIMEOUT;
String ctStr = etConnectTimeout.getText().toString();
if (!ctStr.isEmpty()) {
try {
int ct = Integer.parseInt(ctStr);
if (connectTimeout != ct) {
connectTimeout = ct;
}
} catch (NumberFormatException parseEx) {
}
onReceivePathForFileSelect(requestCode, sftpStorage.buildFullPath( host, port, initialDir, user, pwd, connectTimeout));
} catch (UnsupportedEncodingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
EditText etKeyName = view.findViewById(R.id.private_key_name);
String keyName = etKeyName.getText().toString();
EditText etKeyPassphrase = view.findViewById(R.id.private_key_passphrase);
String keyPassphrase = etKeyPassphrase.getText().toString();
onReceivePathForFileSelect(requestCode, sftpStorage1.buildFullPath(
host, port, initialDir, user, pwd, connectTimeout,
keyName, keyPassphrase));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
})
.create()

View File

@@ -77,5 +77,59 @@
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="send public key" />
<TextView
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:layout_marginTop="20dp"
android:textStyle="bold"
android:text="Private Keys Functions" />
<LinearLayout
android:orientation="horizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<Button android:id="@+id/list_private_keys"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="List" />
<Button android:id="@+id/add_private_key"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Add" />
<Button android:id="@+id/delete_private_key"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Delete" />
<Button android:id="@+id/validate_private_key"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Validate" />
</LinearLayout>
<EditText android:id="@+id/private_key_name"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textNoSuggestions"
android:text=""
android:hint="key name" />
<EditText android:id="@+id/private_key_content"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:lines="5"
android:text=""
android:hint="key content" />
<EditText android:id="@+id/private_key_passphrase"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textNoSuggestions"
android:text=""
android:hint="key passphrase (optional)" />
</LinearLayout>

View File

@@ -42,6 +42,12 @@ namespace keepass2android
private readonly string _schemeSeparator = "://";
private bool _tryGetPermanentAccess;
private const int SftpModeSpinnerPasswd = 0;
private const int SftpModeSpinnerPubKey = 1;
private const int SftpModeSpinnerCustomKey = 2;
private const int SftpKeySpinnerCreateNewIdx = 0;
public string DefaultExtension { get; set; }
public FileSelectHelper(Activity activity, bool isForSave, bool tryGetPermanentAccess, int requestCode)
@@ -57,81 +63,198 @@ namespace keepass2android
#if !EXCLUDE_JAVAFILESTORAGE && !NoNet
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
View dlgContents = activity.LayoutInflater.Inflate(Resource.Layout.sftpcredentials, null);
var ctx = activity.ApplicationContext;
var fileStorage = new Keepass2android.Javafilestorage.SftpStorage(ctx);
var spinner = dlgContents.FindViewById<Spinner>(Resource.Id.sftp_auth_mode_spinner);
dlgContents.FindViewById<Button>(Resource.Id.send_public_key_button).Click += (sender, args) =>
{
var fileStorage = new Keepass2android.Javafilestorage.SftpStorage(activity.ApplicationContext);
string pub_filename = fileStorage.CreateKeyPair();
LinearLayout addNewBtn = dlgContents.FindViewById<LinearLayout>(Resource.Id.sftp_add_key_group);
Button deleteBtn = dlgContents.FindViewById<Button>(Resource.Id.sftp_delete_key_button);
EditText keyNameTxt = dlgContents.FindViewById<EditText>(Resource.Id.sftp_key_name);
EditText keyContentTxt = dlgContents.FindViewById<EditText>(Resource.Id.sftp_key_content);
Intent sendIntent = new Intent();
sendIntent.SetAction(Intent.ActionSend);
sendIntent.PutExtra(Intent.ExtraText, System.IO.File.ReadAllText(pub_filename));
var keySpinner = dlgContents.FindViewById<Spinner>(Resource.Id.sftp_key_names);
var keyNamesAdapter = new ArrayAdapter(ctx, Android.Resource.Layout.SimpleSpinnerDropDownItem, new List<string>());
UpdatePrivateKeyNames(keyNamesAdapter, fileStorage, ctx);
keyNamesAdapter.SetDropDownViewResource(Android.Resource.Layout.SimpleSpinnerDropDownItem);
keySpinner.Adapter = keyNamesAdapter;
keySpinner.SetSelection(SftpKeySpinnerCreateNewIdx);
sendIntent.PutExtra(Intent.ExtraSubject, "Keepass2Android sftp public key");
sendIntent.SetType("text/plain");
activity.StartActivity(Intent.CreateChooser(sendIntent, "Send public key to..."));
};
keySpinner.ItemSelected += (sender, args) =>
{
if (keySpinner.SelectedItemPosition == SftpKeySpinnerCreateNewIdx)
{
keyNameTxt.Text = "";
keyContentTxt.Text = "";
addNewBtn.Visibility = ViewStates.Visible;
deleteBtn.Visibility = ViewStates.Gone;
}
else
{
addNewBtn.Visibility = ViewStates.Gone;
deleteBtn.Visibility = ViewStates.Visible;
}
};
var authModeSpinner = dlgContents.FindViewById<Spinner>(Resource.Id.sftp_auth_mode_spinner);
dlgContents.FindViewById<Button>(Resource.Id.send_public_key_button).Click += (sender, args) =>
{
string pub_filename = fileStorage.CreateKeyPair();
spinner.ItemSelected += (sender, args) =>
{
if (spinner.SelectedItemPosition == 0)
{
dlgContents.FindViewById<EditText>(Resource.Id.sftp_password).Visibility = ViewStates.Visible;
dlgContents.FindViewById<Button>(Resource.Id.send_public_key_button).Visibility = ViewStates.Gone;
}
else
{
dlgContents.FindViewById<EditText>(Resource.Id.sftp_password).Visibility = ViewStates.Gone;
dlgContents.FindViewById<Button>(Resource.Id.send_public_key_button).Visibility = ViewStates.Visible;
Intent sendIntent = new Intent();
sendIntent.SetAction(Intent.ActionSend);
sendIntent.PutExtra(Intent.ExtraText, System.IO.File.ReadAllText(pub_filename));
sendIntent.PutExtra(Intent.ExtraSubject, "Keepass2Android sftp public key");
sendIntent.SetType("text/plain");
activity.StartActivity(Intent.CreateChooser(sendIntent, "Send public key to..."));
};
dlgContents.FindViewById<Button>(Resource.Id.sftp_save_key_button).Click += (sender, args) =>
{
string keyName = keyNameTxt.Text;
string keyContent = keyContentTxt.Text;
string toastMsg = null;
if (!string.IsNullOrEmpty(keyName) && !string.IsNullOrEmpty(keyContent))
{
try
{
fileStorage.SavePrivateKeyContent(keyName, keyContent);
keyNameTxt.Text = "";
keyContentTxt.Text = "";
toastMsg = ctx.GetString(Resource.String.private_key_saved);
}
catch (Exception e)
{
toastMsg = ctx.GetString(Resource.String.private_key_save_failed,
new Java.Lang.Object[] { e.Message });
}
}
else
{
toastMsg = ctx.GetString(Resource.String.private_key_info);
}
if (toastMsg!= null) {
Toast.MakeText(_activity, toastMsg, ToastLength.Long).Show();
}
UpdatePrivateKeyNames(keyNamesAdapter, fileStorage, ctx);
keySpinner.SetSelection(ResolveKeySpinnerSelection(keyNamesAdapter, keyName));
};
dlgContents.FindViewById<Button>(Resource.Id.sftp_delete_key_button).Click += (sender, args) =>
{
int selectedKey = dlgContents.FindViewById<Spinner>(Resource.Id.sftp_key_names).SelectedItemPosition;
string keyName = ResolveSelectedKeyName(keyNamesAdapter, selectedKey);
if (!string.IsNullOrEmpty(keyName))
{
bool deleted = fileStorage.DeleteCustomKey(keyName);
int msgId = deleted ? Resource.String.private_key_delete : Resource.String.private_key_delete_failed;
string msg = ctx.GetString(msgId, new Java.Lang.Object[] { keyName });
Toast.MakeText(_activity, msg, ToastLength.Long).Show();
UpdatePrivateKeyNames(keyNamesAdapter, fileStorage, ctx);
keySpinner.SetSelection(SftpKeySpinnerCreateNewIdx);
}
};
authModeSpinner.ItemSelected += (sender, args) =>
{
var passwordBox = dlgContents.FindViewById<EditText>(Resource.Id.sftp_password);
var publicKeyButton = dlgContents.FindViewById<Button>(Resource.Id.send_public_key_button);
var keyfileGroup = dlgContents.FindViewById<LinearLayout>(Resource.Id.sftp_keyfile_group);
switch (authModeSpinner.SelectedItemPosition)
{
case SftpModeSpinnerPasswd:
passwordBox.Visibility = ViewStates.Visible;
publicKeyButton.Visibility = ViewStates.Gone;
keyfileGroup.Visibility = ViewStates.Gone;
break;
case SftpModeSpinnerPubKey:
passwordBox.Visibility = ViewStates.Gone;
publicKeyButton.Visibility = ViewStates.Visible;
keyfileGroup.Visibility = ViewStates.Gone;
break;
case SftpModeSpinnerCustomKey:
passwordBox.Visibility = ViewStates.Gone;
publicKeyButton.Visibility = ViewStates.Gone;
keyfileGroup.Visibility = ViewStates.Visible;
break;
}
};
if (!defaultPath.EndsWith(_schemeSeparator))
{
var fileStorage = new SftpStorage(activity.ApplicationContext);
SftpStorage.ConnectionInfo ci = fileStorage.SplitStringToConnectionInfo(defaultPath);
dlgContents.FindViewById<EditText>(Resource.Id.sftp_host).Text = ci.Host;
dlgContents.FindViewById<EditText>(Resource.Id.sftp_port).Text = ci.Port.ToString();
dlgContents.FindViewById<EditText>(Resource.Id.sftp_user).Text = ci.Username;
dlgContents.FindViewById<EditText>(Resource.Id.sftp_password).Text = ci.Password;
dlgContents.FindViewById<EditText>(Resource.Id.sftp_key_name).Text = ci.KeyName;
dlgContents.FindViewById<EditText>(Resource.Id.sftp_key_passphrase).Text = ci.KeyPassphrase;
dlgContents.FindViewById<EditText>(Resource.Id.sftp_initial_dir).Text = ci.LocalPath;
if (ci.ConnectTimeoutSec != SftpStorage.UnsetSftpConnectTimeout)
{
dlgContents.FindViewById<EditText>(Resource.Id.sftp_connect_timeout).Text = ci.ConnectTimeoutSec.ToString();
}
if (string.IsNullOrEmpty(ci.Password))
{
spinner.SetSelection(1);
}
if (!string.IsNullOrEmpty(ci.Password))
{
authModeSpinner.SetSelection(SftpModeSpinnerPasswd);
} else if (!string.IsNullOrEmpty(ci.KeyName))
{
authModeSpinner.SetSelection(SftpModeSpinnerCustomKey);
keySpinner.SetSelection(ResolveKeySpinnerSelection(keyNamesAdapter, ci.KeyName));
} else
{
authModeSpinner.SetSelection(SftpModeSpinnerPubKey);
}
}
builder.SetView(dlgContents);
builder.SetPositiveButton(Android.Resource.String.Ok,
(sender, args) =>
{
string host = dlgContents.FindViewById<EditText>(Resource.Id.sftp_host).Text;
string portText = dlgContents.FindViewById<EditText>(Resource.Id.sftp_port).Text;
int port = SftpStorage.DefaultSftpPort;
if (!string.IsNullOrEmpty(portText))
int.TryParse(portText, out port);
string user = dlgContents.FindViewById<EditText>(Resource.Id.sftp_user).Text;
string password = dlgContents.FindViewById<EditText>(Resource.Id.sftp_password).Text;
string initialPath = dlgContents.FindViewById<EditText>(Resource.Id.sftp_initial_dir).Text;
if (string.IsNullOrEmpty(initialPath))
initialPath = "/";
string connectTimeoutText = dlgContents.FindViewById<EditText>(Resource.Id.sftp_connect_timeout).Text;
int connectTimeout = SftpStorage.UnsetSftpConnectTimeout;
if (!string.IsNullOrEmpty(connectTimeoutText)) {
int.TryParse(connectTimeoutText, out connectTimeout);
}
builder.SetPositiveButton(Android.Resource.String.Ok, (sender, args) => {
int idx = 0;
string host = dlgContents.FindViewById<EditText>(Resource.Id.sftp_host).Text;
string portText = dlgContents.FindViewById<EditText>(Resource.Id.sftp_port).Text;
int port = SftpStorage.DefaultSftpPort;
if (!string.IsNullOrEmpty(portText))
int.TryParse(portText, out port);
string user = dlgContents.FindViewById<EditText>(Resource.Id.sftp_user).Text;
string sftpPath = new SftpStorage(activity.ApplicationContext)
.BuildFullPath(host, port, initialPath, user, password, connectTimeout);
onStartBrowse(sftpPath);
});
EventHandler<DialogClickEventArgs> evtH = new EventHandler<DialogClickEventArgs>((sender, e) => onCancel());
string password = null;
string keyName = null, keyPassphrase = null;
switch (dlgContents.FindViewById<Spinner>(Resource.Id.sftp_auth_mode_spinner).SelectedItemPosition)
{
case SftpModeSpinnerPasswd:
password = dlgContents.FindViewById<EditText>(Resource.Id.sftp_password).Text;
break;
case SftpModeSpinnerPubKey:
break;
case SftpModeSpinnerCustomKey:
keyName = ResolveSelectedKeyName(keyNamesAdapter, keySpinner.SelectedItemPosition);
keyPassphrase = dlgContents.FindViewById<EditText>(Resource.Id.sftp_key_passphrase).Text;
break;
}
string initialPath = dlgContents.FindViewById<EditText>(Resource.Id.sftp_initial_dir).Text;
if (string.IsNullOrEmpty(initialPath))
initialPath = "/";
string connectTimeoutText = dlgContents.FindViewById<EditText>(Resource.Id.sftp_connect_timeout).Text;
int connectTimeout = SftpStorage.UnsetSftpConnectTimeout;
if (!string.IsNullOrEmpty(connectTimeoutText))
{
int.TryParse(connectTimeoutText, out connectTimeout);
}
string sftpPath = fileStorage.BuildFullPath(
host, port, initialPath, user, password, connectTimeout, keyName, keyPassphrase);
onStartBrowse(sftpPath);
});
EventHandler<DialogClickEventArgs> evtH = new EventHandler<DialogClickEventArgs>((sender, e) => onCancel());
builder.SetNegativeButton(Android.Resource.String.Cancel, evtH);
builder.SetTitle(activity.GetString(Resource.String.enter_sftp_login_title));
@@ -139,9 +262,41 @@ namespace keepass2android
dialog.Show();
#endif
}
}
private void ShowHttpDialog(Activity activity, Util.FileSelectedHandler onStartBrowse, Action onCancel, string defaultPath)
#if !EXCLUDE_JAVAFILESTORAGE && !NoNet
private void UpdatePrivateKeyNames(ArrayAdapter dataView, SftpStorage storage, Context ctx)
{
dataView.Clear();
dataView.Add(ctx.GetString(Resource.String.private_key_create_new));
foreach (string keyName in storage.GetCustomKeyNames())
dataView.Add(keyName);
}
private int ResolveKeySpinnerSelection(ArrayAdapter dataView, string keyName)
{
int idx = -1;
for (int i = 0; i < dataView.Count; i++)
{
string itemName = dataView.GetItem(i).ToString();
if (string.Equals(keyName, itemName)) {
idx = i;
break;
}
}
return idx < 0 ? SftpKeySpinnerCreateNewIdx : idx;
}
private string ResolveSelectedKeyName(ArrayAdapter dataView, int selectedItem)
{
if (selectedItem != SftpKeySpinnerCreateNewIdx && selectedItem > 0 && selectedItem < dataView.Count)
return dataView.GetItem(selectedItem).ToString();
else
return null;
}
#endif
private void ShowHttpDialog(Activity activity, Util.FileSelectedHandler onStartBrowse, Action onCancel, string defaultPath)
{
#if !EXCLUDE_JAVAFILESTORAGE && !NoNet
AlertDialog.Builder builder = new AlertDialog.Builder(activity);

View File

@@ -61,17 +61,80 @@
</LinearLayout>
<EditText
android:id="@+id/sftp_password"
android:id="@+id/sftp_password"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true"
android:hint="@string/hint_pass"
android:importantForAccessibility="no"/>
<Button
android:id="@+id/send_public_key_button"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="@string/send_public_key" />
<LinearLayout
android:id="@+id/sftp_keyfile_group"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<LinearLayout
android:orientation="horizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/private_key_select" />
<Spinner
android:id="@+id/sftp_key_names"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="3dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/sftp_add_key_group"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<EditText android:id="@+id/sftp_key_name"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_sftp_key_name" />
<EditText
android:id="@+id/sftp_key_content"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="1"
android:maxLines="6"
android:hint="@string/hint_sftp_key_content" />
<Button
android:id="@+id/sftp_save_key_button"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/save_key" />
</LinearLayout>
<Button
android:id="@+id/sftp_delete_key_button"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/delete_key" />
<EditText
android:id="@+id/sftp_key_passphrase"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:singleLine="true"
android:hint="@string/hint_pass"
android:importantForAccessibility="no"/>
<Button android:id="@+id/send_public_key_button"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="@string/send_public_key" />
android:inputType="textPassword"
android:hint="@string/hint_sftp_key_passphrase" />
</LinearLayout>
<TextView android:id="@+id/initial_dir"
@@ -102,4 +165,4 @@
android:singleLine="true"
android:inputType="number" />
</LinearLayout>
</LinearLayout>

View File

@@ -167,6 +167,7 @@
<string name="hint_keyfile">key file</string>
<string name="hint_length">length</string>
<string name="hint_pass">password</string>
<string name="hint_keyfile_path">SSH private key path</string>
<string name="hint_login_pass">Password</string>
<string name="hint_title">name</string>
<string name="hint_url">URL</string>
@@ -590,6 +591,19 @@
<string name="enter_sftp_login_title">Enter SFTP login data:</string>
<string name="sftp_auth_mode">Authentication mode</string>
<string name="send_public_key">Send public key...</string>
<string name="select_private_keyfile">Select private key...</string>
<string name="hint_sftp_key_name">New key name</string>
<string name="hint_sftp_key_content">New key content</string>
<string name="private_key_saved">Private key saved</string>
<string name="private_key_save_failed">FAILED to save private key: %1$s</string>
<string name="private_key_info">Enter key name and content to save</string>
<string name="private_key_delete">Deleted private key: %1$s</string>
<string name="private_key_delete_failed">FAILED to deleted private key: %1$s</string>
<string name="save_key">Save Private Key</string>
<string name="delete_key">Delete Private Key</string>
<string name="private_key_select">Selected private key</string>
<string name="private_key_create_new">[Add New...]</string>
<string name="hint_sftp_key_passphrase">Key passphrase (optional)</string>
<string name="enter_ftp_login_title">Enter FTP login data:</string>
@@ -1322,7 +1336,8 @@ Initial public release
</string-array>
<string-array name="sftp_auth_modes">
<item>Password</item>
<item>Private/Public key</item>
<item>K2A Private/Public key</item>
<item>Custom Private key</item>
</string-array>
<string-array name="AcceptAllServerCertificates_options">
<item>Ignore certificate validation failures</item>