-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.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@@ -20,7 +19,6 @@ import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.ChannelSftp.LsEntry; import com.jcraft.jsch.ChannelSftp.LsEntry;
import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException; import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.KeyPair;
import com.jcraft.jsch.Session; import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpATTRS; import com.jcraft.jsch.SftpATTRS;
import com.jcraft.jsch.SftpException; import com.jcraft.jsch.SftpException;
@@ -30,11 +28,32 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
@SuppressWarnings("unused") // Exposed by JavaFileStorageBindings
public class SftpStorage extends JavaFileStorageBase { 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 DEFAULT_SFTP_PORT = 22;
public static final int UNSET_SFTP_CONNECT_TIMEOUT = -1; public static final int UNSET_SFTP_CONNECT_TIMEOUT = -1;
private static final String SFTP_CONNECT_TIMEOUT_OPTION_NAME = "connectTimeout"; 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; JSch jsch;
@@ -44,39 +63,36 @@ public class SftpStorage extends JavaFileStorageBase {
public String username; public String username;
public String password; public String password;
public String localPath; public String localPath;
public String keyName;
public String keyPassphrase;
public int port; public int port;
public int connectTimeoutSec = UNSET_SFTP_CONNECT_TIMEOUT; public int connectTimeoutSec = UNSET_SFTP_CONNECT_TIMEOUT;
public String toString() { public String toString() {
return "ConnectionInfo{host=" + host + ",port=" + port + ",user=" + username + 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) { private static Map<String, String> buildOptionMap(ConnectionInfo ci, boolean includeSensitive) {
return buildOptionMap(ci.connectTimeoutSec); 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(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; Context _appContext;
private final SftpPublicPrivateKeyUtils _keyUtils;
public SftpStorage(Context appContext) { public SftpStorage(Context appContext) {
_appContext = appContext; _appContext = appContext;
_keyUtils = new SftpPublicPrivateKeyUtils(getBaseDir());
} }
private static final String SFTP_PROTOCOL_ID = "sftp"; private static final String SFTP_PROTOCOL_ID = "sftp";
@@ -184,7 +200,8 @@ public class SftpStorage extends JavaFileStorageBase {
tryDisconnect(c); tryDisconnect(c);
return buildFullPath(cInfo.host, cInfo.port, newPath, 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) { } catch (Exception e) {
throw convertException(e); throw convertException(e);
} }
@@ -402,28 +419,23 @@ public class SftpStorage extends JavaFileStorageBase {
return java.net.URLEncoder.encode(unencoded, UTF_8); return java.net.URLEncoder.encode(unencoded, UTF_8);
} }
ChannelSftp init(ConnectionInfo cInfo) throws JSchException, UnsupportedEncodingException { ChannelSftp init(ConnectionInfo cInfo) throws JSchException, UnsupportedEncodingException {
jsch = new JSch(); jsch = new JSch();
String base_dir = getBaseDir(); String base_dir = getBaseDir();
jsch.setKnownHosts(base_dir + "/known_hosts"); jsch.setKnownHosts(base_dir + "/known_hosts");
String key_filename = getKeyFileName(); String key_filepath = _keyUtils.resolveKeyFilePath(jsch, cInfo.keyName);
try{
createKeyPair(key_filename);
} catch (Exception ex) {
System.out.println(ex);
}
try { try {
jsch.addIdentity(key_filename); jsch.addIdentity(key_filepath);
} catch (java.lang.Exception e) } catch (java.lang.Exception e) {
{
} }
Session session = jsch.getSession(cInfo.username, cInfo.host, cInfo.port); 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.setUserInfo(ui);
session.setConfig("PreferredAuthentications", "publickey,password"); session.setConfig("PreferredAuthentications", "publickey,password");
@@ -434,7 +446,6 @@ public class SftpStorage extends JavaFileStorageBase {
channel.connect(); channel.connect();
ChannelSftp c = (ChannelSftp) channel; ChannelSftp c = (ChannelSftp) channel;
logDebug("success: init Sftp");
return c; return c;
} }
@@ -451,38 +462,60 @@ public class SftpStorage extends JavaFileStorageBase {
return _appContext.getFilesDir().getAbsolutePath(); return _appContext.getFilesDir().getAbsolutePath();
} }
private String getKeyFileName() { public boolean deleteCustomKey(String keyName) throws FileNotFoundException {
return getBaseDir() + "/id_kp2a_rsa"; return _keyUtils.deleteCustomKey(keyName);
} }
public String[] getCustomKeyNames() {
return _keyUtils.getCustomKeyNames();
}
@SuppressWarnings("unused") // Exposed by JavaFileStorageBindings
public String createKeyPair() throws IOException, JSchException { 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) public ConnectionInfo splitStringToConnectionInfo(String filename)
throws UnsupportedEncodingException { throws UnsupportedEncodingException {
ConnectionInfo ci = new ConnectionInfo(); ConnectionInfo ci = new ConnectionInfo();
ci.host = extractUserPwdHostPort(filename); ci.host = extractUserPwdHostPort(filename);
String userPwd = ci.host.substring(0, ci.host.indexOf('@')); String userPwd = ci.host.substring(0, ci.host.indexOf('@'));
ci.username = decode(userPwd.substring(0, userPwd.indexOf(":"))); int sepIdx = userPwd.indexOf(":");
ci.password = decode(userPwd.substring(userPwd.indexOf(":")+1)); 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.host = ci.host.substring(ci.host.indexOf('@') + 1);
ci.port = DEFAULT_SFTP_PORT; ci.port = DEFAULT_SFTP_PORT;
int portSeparatorIndex = ci.host.indexOf(":"); 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); 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; return ci;
} }
@@ -544,9 +583,7 @@ public class SftpStorage extends JavaFileStorageBase {
.append("@") .append("@")
.append(ci.host) .append(ci.host)
.append(ci.localPath); .append(ci.localPath);
if (ci.hasOptions()) { appendOptions(dName, buildOptionMap(ci, false));
appendOptions(dName, buildOptionMap(ci));
}
return dName.toString(); return dName.toString();
} }
catch (Exception e) catch (Exception e)
@@ -580,11 +617,15 @@ public class SftpStorage extends JavaFileStorageBase {
public String buildFullPath(String host, int port, String localPath, public String buildFullPath(String host, int port, String localPath,
String username, String password, String username, String password,
int connectTimeoutSec) int connectTimeoutSec,
String keyName, String keyPassphrase)
throws UnsupportedEncodingException { throws UnsupportedEncodingException {
StringBuilder uri = new StringBuilder(getProtocolPrefix())
.append(encode(username)).append(":").append(encode(password)) StringBuilder uri = new StringBuilder(getProtocolPrefix()).append(encode(username));
.append("@").append(host); if (password != null) {
uri.append(":").append(encode(password));
}
uri.append("@").append(host);
if (port != DEFAULT_SFTP_PORT) { if (port != DEFAULT_SFTP_PORT) {
uri.append(":").append(port); uri.append(":").append(port);
@@ -593,8 +634,11 @@ public class SftpStorage extends JavaFileStorageBase {
uri.append(localPath); uri.append(localPath);
} }
Map<String, String> opts = buildOptionMap(connectTimeoutSec); appendOptions(uri, new OptionMapBuilder()
appendOptions(uri, opts); .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(); return uri.toString();
} }
@@ -635,4 +679,31 @@ public class SftpStorage extends JavaFileStorageBase {
return o1.getKey().compareTo(o2.getKey()); 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; Context _appContext;
String _password; String _password;
String _passphrase;
public SftpUserInfo(String password, Context appContext) public SftpUserInfo(String password, String passphrase, Context appContext)
{ {
_password = password; _password = password;
_passphrase = passphrase;
_appContext = appContext; _appContext = appContext;
} }
@Override @Override
public String getPassphrase() { public String getPassphrase() {
return null; return _passphrase;
} }
@Override @Override
@@ -134,12 +136,12 @@ public class SftpUserInfo implements UserInfo {
@Override @Override
public boolean promptPassword(String message) { public boolean promptPassword(String message) {
return true; return _password != null;
} }
@Override @Override
public boolean promptPassphrase(String message) { public boolean promptPassphrase(String message) {
return false; //passphrase not supported return _passphrase != null;
} }
@Override @Override

View File

@@ -697,12 +697,11 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
if (protocolId.equals("sftp")) if (protocolId.equals("sftp"))
{ {
final View view = getLayoutInflater().inflate(R.layout.sftp_credentials, null); final View view = getLayoutInflater().inflate(R.layout.sftp_credentials, null);
final SftpStorage sftpStorage = (SftpStorage)storageToTest;
view.findViewById(R.id.send_public_key).setOnClickListener(v -> { view.findViewById(R.id.send_public_key).setOnClickListener(v -> {
Intent sendIntent = new Intent(); Intent sendIntent = new Intent();
SftpStorage sftpStorage = (SftpStorage)storageToTest;
try { try {
String pub_filename = sftpStorage.createKeyPair(); String pub_filename = sftpStorage.createKeyPair();
@@ -715,35 +714,100 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
} }
catch (Exception ex) catch (Exception ex)
{ {
Toast.makeText(this,"Failed to create key pair: " + ex.getMessage(), Toast.LENGTH_LONG); Toast.makeText(this,"Failed to create key pair: " + ex.getMessage(), Toast.LENGTH_LONG).show();
return; }
});
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) new AlertDialog.Builder(this)
.setView(view) .setView(view)
.setTitle("Enter SFTP credentials") .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; SftpStorage sftpStorage1 = (SftpStorage)storageToTest;
try { try {
EditText etHost = ((EditText)view.findViewById(R.id.sftp_host)); EditText etHost = view.findViewById(R.id.sftp_host);
String host = etHost.getText().toString(); String host = etHost.getText().toString();
EditText etUser = ((EditText)view.findViewById(R.id.sftp_user)); EditText etUser = view.findViewById(R.id.sftp_user);
String user = etUser.getText().toString(); String user = etUser.getText().toString();
EditText etPwd = ((EditText)view.findViewById(R.id.sftp_password)); EditText etPwd = view.findViewById(R.id.sftp_password);
String pwd = etPwd.getText().toString(); String pwd = etPwd.getText().toString();
EditText etPort = ((EditText)view.findViewById(R.id.sftp_port)); EditText etPort = view.findViewById(R.id.sftp_port);
int port = Integer.parseInt(etPort.getText().toString()); int port = Integer.parseInt(etPort.getText().toString());
EditText etInitDir = ((EditText)view.findViewById(R.id.sftp_initial_dir)); EditText etInitDir = view.findViewById(R.id.sftp_initial_dir);
String initialDir = etInitDir.getText().toString(); String initialDir = etInitDir.getText().toString();
EditText etConnectTimeout = ((EditText)view.findViewById(R.id.sftp_connect_timeout)); EditText etConnectTimeout = view.findViewById(R.id.sftp_connect_timeout);
int connectTimeout = SftpStorage.UNSET_SFTP_CONNECT_TIMEOUT; int connectTimeout = SftpStorage.UNSET_SFTP_CONNECT_TIMEOUT;
String ctStr = etConnectTimeout.getText().toString(); String ctStr = etConnectTimeout.getText().toString();
if (!ctStr.isEmpty()) { if (!ctStr.isEmpty()) {
@@ -755,12 +819,17 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
} catch (NumberFormatException parseEx) { } catch (NumberFormatException parseEx) {
} }
} }
onReceivePathForFileSelect(requestCode, sftpStorage.buildFullPath( host, port, initialDir, user, pwd, connectTimeout)); 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) { } catch (UnsupportedEncodingException e) {
// TODO Auto-generated catch block
e.printStackTrace(); e.printStackTrace();
} }
}
}) })
.create() .create()
.show(); .show();

View File

@@ -77,5 +77,59 @@
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="send public key" /> 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> </LinearLayout>

View File

@@ -42,6 +42,12 @@ namespace keepass2android
private readonly string _schemeSeparator = "://"; private readonly string _schemeSeparator = "://";
private bool _tryGetPermanentAccess; 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 string DefaultExtension { get; set; }
public FileSelectHelper(Activity activity, bool isForSave, bool tryGetPermanentAccess, int requestCode) public FileSelectHelper(Activity activity, bool isForSave, bool tryGetPermanentAccess, int requestCode)
@@ -57,11 +63,40 @@ namespace keepass2android
#if !EXCLUDE_JAVAFILESTORAGE && !NoNet #if !EXCLUDE_JAVAFILESTORAGE && !NoNet
AlertDialog.Builder builder = new AlertDialog.Builder(activity); AlertDialog.Builder builder = new AlertDialog.Builder(activity);
View dlgContents = activity.LayoutInflater.Inflate(Resource.Layout.sftpcredentials, null); 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); 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);
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);
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) => 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(); string pub_filename = fileStorage.CreateKeyPair();
Intent sendIntent = new Intent(); Intent sendIntent = new Intent();
@@ -73,62 +108,150 @@ namespace keepass2android
activity.StartActivity(Intent.CreateChooser(sendIntent, "Send public key to...")); 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;
spinner.ItemSelected += (sender, args) => string toastMsg = null;
if (!string.IsNullOrEmpty(keyName) && !string.IsNullOrEmpty(keyContent))
{ {
if (spinner.SelectedItemPosition == 0) try
{ {
dlgContents.FindViewById<EditText>(Resource.Id.sftp_password).Visibility = ViewStates.Visible; fileStorage.SavePrivateKeyContent(keyName, keyContent);
dlgContents.FindViewById<Button>(Resource.Id.send_public_key_button).Visibility = ViewStates.Gone; 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 else
{ {
dlgContents.FindViewById<EditText>(Resource.Id.sftp_password).Visibility = ViewStates.Gone; toastMsg = ctx.GetString(Resource.String.private_key_info);
dlgContents.FindViewById<Button>(Resource.Id.send_public_key_button).Visibility = ViewStates.Visible; }
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)) if (!defaultPath.EndsWith(_schemeSeparator))
{ {
var fileStorage = new SftpStorage(activity.ApplicationContext);
SftpStorage.ConnectionInfo ci = fileStorage.SplitStringToConnectionInfo(defaultPath); SftpStorage.ConnectionInfo ci = fileStorage.SplitStringToConnectionInfo(defaultPath);
dlgContents.FindViewById<EditText>(Resource.Id.sftp_host).Text = ci.Host; 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_port).Text = ci.Port.ToString();
dlgContents.FindViewById<EditText>(Resource.Id.sftp_user).Text = ci.Username; 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_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; dlgContents.FindViewById<EditText>(Resource.Id.sftp_initial_dir).Text = ci.LocalPath;
if (ci.ConnectTimeoutSec != SftpStorage.UnsetSftpConnectTimeout) if (ci.ConnectTimeoutSec != SftpStorage.UnsetSftpConnectTimeout)
{ {
dlgContents.FindViewById<EditText>(Resource.Id.sftp_connect_timeout).Text = ci.ConnectTimeoutSec.ToString(); dlgContents.FindViewById<EditText>(Resource.Id.sftp_connect_timeout).Text = ci.ConnectTimeoutSec.ToString();
} }
if (string.IsNullOrEmpty(ci.Password))
if (!string.IsNullOrEmpty(ci.Password))
{ {
spinner.SetSelection(1); 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.SetView(dlgContents);
builder.SetPositiveButton(Android.Resource.String.Ok, builder.SetPositiveButton(Android.Resource.String.Ok, (sender, args) => {
(sender, args) => int idx = 0;
{
string host = dlgContents.FindViewById<EditText>(Resource.Id.sftp_host).Text; string host = dlgContents.FindViewById<EditText>(Resource.Id.sftp_host).Text;
string portText = dlgContents.FindViewById<EditText>(Resource.Id.sftp_port).Text; string portText = dlgContents.FindViewById<EditText>(Resource.Id.sftp_port).Text;
int port = SftpStorage.DefaultSftpPort; int port = SftpStorage.DefaultSftpPort;
if (!string.IsNullOrEmpty(portText)) if (!string.IsNullOrEmpty(portText))
int.TryParse(portText, out port); int.TryParse(portText, out port);
string user = dlgContents.FindViewById<EditText>(Resource.Id.sftp_user).Text; string user = dlgContents.FindViewById<EditText>(Resource.Id.sftp_user).Text;
string password = dlgContents.FindViewById<EditText>(Resource.Id.sftp_password).Text;
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; string initialPath = dlgContents.FindViewById<EditText>(Resource.Id.sftp_initial_dir).Text;
if (string.IsNullOrEmpty(initialPath)) if (string.IsNullOrEmpty(initialPath))
initialPath = "/"; initialPath = "/";
string connectTimeoutText = dlgContents.FindViewById<EditText>(Resource.Id.sftp_connect_timeout).Text; string connectTimeoutText = dlgContents.FindViewById<EditText>(Resource.Id.sftp_connect_timeout).Text;
int connectTimeout = SftpStorage.UnsetSftpConnectTimeout; int connectTimeout = SftpStorage.UnsetSftpConnectTimeout;
if (!string.IsNullOrEmpty(connectTimeoutText)) { if (!string.IsNullOrEmpty(connectTimeoutText))
{
int.TryParse(connectTimeoutText, out connectTimeout); int.TryParse(connectTimeoutText, out connectTimeout);
} }
string sftpPath = new SftpStorage(activity.ApplicationContext) string sftpPath = fileStorage.BuildFullPath(
.BuildFullPath(host, port, initialPath, user, password, connectTimeout); host, port, initialPath, user, password, connectTimeout, keyName, keyPassphrase);
onStartBrowse(sftpPath); onStartBrowse(sftpPath);
}); });
EventHandler<DialogClickEventArgs> evtH = new EventHandler<DialogClickEventArgs>((sender, e) => onCancel()); EventHandler<DialogClickEventArgs> evtH = new EventHandler<DialogClickEventArgs>((sender, e) => onCancel());
@@ -141,6 +264,38 @@ namespace keepass2android
#endif #endif
} }
#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) private void ShowHttpDialog(Activity activity, Util.FileSelectedHandler onStartBrowse, Action onCancel, string defaultPath)
{ {
#if !EXCLUDE_JAVAFILESTORAGE && !NoNet #if !EXCLUDE_JAVAFILESTORAGE && !NoNet

View File

@@ -68,11 +68,74 @@
android:singleLine="true" android:singleLine="true"
android:hint="@string/hint_pass" android:hint="@string/hint_pass"
android:importantForAccessibility="no"/> android:importantForAccessibility="no"/>
<Button android:id="@+id/send_public_key_button"
<Button
android:id="@+id/send_public_key_button"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/send_public_key" /> 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:singleLine="true"
android:inputType="textPassword"
android:hint="@string/hint_sftp_key_passphrase" />
</LinearLayout>
<TextView android:id="@+id/initial_dir" <TextView android:id="@+id/initial_dir"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -167,6 +167,7 @@
<string name="hint_keyfile">key file</string> <string name="hint_keyfile">key file</string>
<string name="hint_length">length</string> <string name="hint_length">length</string>
<string name="hint_pass">password</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_login_pass">Password</string>
<string name="hint_title">name</string> <string name="hint_title">name</string>
<string name="hint_url">URL</string> <string name="hint_url">URL</string>
@@ -590,6 +591,19 @@
<string name="enter_sftp_login_title">Enter SFTP login data:</string> <string name="enter_sftp_login_title">Enter SFTP login data:</string>
<string name="sftp_auth_mode">Authentication mode</string> <string name="sftp_auth_mode">Authentication mode</string>
<string name="send_public_key">Send public key...</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> <string name="enter_ftp_login_title">Enter FTP login data:</string>
@@ -1322,7 +1336,8 @@ Initial public release
</string-array> </string-array>
<string-array name="sftp_auth_modes"> <string-array name="sftp_auth_modes">
<item>Password</item> <item>Password</item>
<item>Private/Public key</item> <item>K2A Private/Public key</item>
<item>Custom Private key</item>
</string-array> </string-array>
<string-array name="AcceptAllServerCertificates_options"> <string-array name="AcceptAllServerCertificates_options">
<item>Ignore certificate validation failures</item> <item>Ignore certificate validation failures</item>