From aa57a980603fca1f78e5dc6a40ecc753dddea14a Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:50:13 +0530 Subject: [PATCH 1/3] fix(sftp): stabilize connections and storage cleanup --- src/fileSystem/sftp.js | 66 ++++-- src/lib/openFolder.js | 7 +- src/lib/recents.js | 2 +- src/pages/fileBrowser/fileBrowser.js | 40 +++- .../sftp/src/com/foxdebug/sftp/Sftp.java | 197 ++++++++++-------- src/utils/Url.js | 18 ++ tests/unit/url.test.js | 26 +++ 7 files changed, 245 insertions(+), 111 deletions(-) diff --git a/src/fileSystem/sftp.js b/src/fileSystem/sftp.js index 7c4b72980..4b6aea399 100644 --- a/src/fileSystem/sftp.js +++ b/src/fileSystem/sftp.js @@ -6,6 +6,9 @@ import Path from "utils/Path"; import Url from "utils/Url"; import internalFs from "./internalFs"; +let pendingConnection = null; +let pendingConnectionID = null; + class SftpClient { #MAX_TRY = 3; #hostname; @@ -19,7 +22,6 @@ class SftpClient { #connectionID; #path; #stat; - #retry = 0; /** * @@ -48,7 +50,7 @@ class SftpClient { }, }); - this.#connectionID = `${this.#username}@${this.#hostname}`; + this.#connectionID = `${this.#username}@${this.#hostname}:${this.#port}`; } setPath(path) { @@ -398,20 +400,50 @@ class SftpClient { } async connect() { - await new Promise((resolve, reject) => { - const retry = (err) => { - if (settings.value.retryRemoteFsAfterFail) { - if (++this.#retry > this.#MAX_TRY) { - this.#retry = 0; - reject(err); - } else { - this.connect().then(resolve).catch(reject); - } - } else { - reject(err); - } - }; + if (pendingConnection) { + if (pendingConnectionID === this.#connectionID) { + return pendingConnection; + } + try { + await pendingConnection; + } catch { + // The next profile should still get its own connection attempt. + } + return this.connect(); + } + + pendingConnectionID = this.#connectionID; + pendingConnection = this.#connectWithRetry(); + try { + return await pendingConnection; + } finally { + if (pendingConnectionID === this.#connectionID) { + pendingConnection = null; + pendingConnectionID = null; + } + } + } + + async #connectWithRetry() { + const attempts = settings.value.retryRemoteFsAfterFail + ? this.#MAX_TRY + 1 + : 1; + let lastError; + + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await this.#connectOnce(); + } catch (error) { + lastError = error; + } + } + + throw lastError; + } + + #connectOnce() { + return new Promise((resolve, reject) => { if (this.#authenticationType === "key") { sftp.connectUsingKeyFile( this.#hostname, @@ -420,7 +452,7 @@ class SftpClient { this.#keyFile, this.#passPhrase, resolve, - retry, + reject, ); return; } @@ -431,7 +463,7 @@ class SftpClient { this.#username, this.#password, resolve, - retry, + reject, ); }); } diff --git a/src/lib/openFolder.js b/src/lib/openFolder.js index 741b6f583..2fd75a1d1 100644 --- a/src/lib/openFolder.js +++ b/src/lib/openFolder.js @@ -1192,9 +1192,10 @@ openFolder.removeItem = (url) => { openFolder.removeFolders = (url) => { ({ url } = Url.parse(url)); - const regex = new RegExp("^" + escapeStringRegexp(url)); - addedFolder.forEach((folder) => { - if (regex.test(folder.url)) { + // remove() mutates addedFolder, so iterate over a snapshot to avoid skipping + // adjacent folders that belong to the same remote storage. + [...addedFolder].forEach((folder) => { + if (Url.isSameOrDescendant(folder.url, url)) { folder.remove(); } }); diff --git a/src/lib/recents.js b/src/lib/recents.js index 816a374f5..1f583a8a0 100644 --- a/src/lib/recents.js +++ b/src/lib/recents.js @@ -55,7 +55,7 @@ const recents = { removeFolder(url) { ({ url } = Url.parse(url)); this.folders = this.folders.filter((folder) => { - return !new RegExp("^" + escapeStringRegexp(folder.url)).test(url); + return !Url.isSameOrDescendant(folder.url, url); }); }, diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index e977eb59d..6efd71e62 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -1087,7 +1087,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const confirmation = await confirm(strings.warning, message); if (!confirmation) break; - deleteFunction(); + await deleteFunction(); break; } @@ -1219,10 +1219,35 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } } - function removeStorage() { - if (url) { - recents.removeFolder(url); - recents.removeFile(url); + async function removeStorage() { + const removedStorage = storageList.find( + (storage) => storage.uuid === uuid, + ); + const storageUrl = removedStorage?.url || url; + + if (storageUrl) { + recents.removeFolder(storageUrl); + recents.removeFile(storageUrl); + openFolder.removeFolders(storageUrl); + helpers.updateUriOfAllActiveFiles(storageUrl, null); + } + if ( + storageUrl && + removedStorage && + (removedStorage.storageType === "sftp" || + removedStorage.type === "sftp") + ) { + const { username, hostname, port = 22 } = Url.decodeUrl(storageUrl); + const connectionID = `${username}@${hostname}:${port}`; + await new Promise((resolve) => { + sftp.isConnected((activeConnectionID) => { + if (activeConnectionID !== connectionID) { + resolve(); + return; + } + sftp.close(resolve, resolve); + }, resolve); + }); } storageList = storageList.filter((storage) => { if (storage.uuid !== uuid) { @@ -1234,13 +1259,12 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { const keyFile = decodeURIComponent( parsedUrl.query["keyFile"] || "", ); - if (keyFile) { - fsOperation(keyFile).delete(); - } + if (keyFile) fsOperation(keyFile).delete().catch(console.warn); } return false; }); localStorage.storageList = JSON.stringify(storageList); + acode.exec("save-state"); reload(); } diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java index 25b373742..eff9f96c7 100644 --- a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java +++ b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java @@ -8,11 +8,13 @@ import androidx.documentfile.provider.DocumentFile; import com.sshtools.client.SshClient; import com.sshtools.client.SshClient.SshClientBuilder; +import com.sshtools.client.SshClientContext; import com.sshtools.client.sftp.SftpClient; import com.sshtools.client.sftp.SftpClient.SftpClientBuilder; import com.sshtools.client.sftp.SftpFile; import com.sshtools.client.sftp.TransferCancelledException; import com.sshtools.common.permissions.PermissionDeniedException; +import com.sshtools.common.policy.FileSystemPolicy; import com.sshtools.common.publickey.InvalidPassphraseException; import com.sshtools.common.publickey.SshKeyUtils; import com.sshtools.common.sftp.SftpFileAttributes; @@ -20,7 +22,7 @@ import com.sshtools.common.ssh.SshException; import com.sshtools.common.ssh.components.SshKeyPair; import com.sshtools.common.ssh.components.jce.JCEProvider; -import com.sshtools.common.util.FileUtils; +import com.sshtools.common.util.UnsignedInteger32; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -41,12 +43,17 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; -import java.util.Arrays; import org.bouncycastle.jce.provider.BouncyCastleProvider; public class Sftp extends CordovaPlugin { private static final String TAG = "SFTP"; + // Maverick's 16 MB default is allocated in full for every SFTP subsystem. + // A smaller mobile window prevents connection bursts from exhausting the heap. + private static final long SFTP_MAX_WINDOW_SIZE = 1024L * 1024L; + private static final long SFTP_MIN_WINDOW_SIZE = 128L * 1024L; + private static boolean cryptoProviderConfigured; + private final Object connectionLock = new Object(); private SshClient ssh; private SftpClient sftp; private Context context; @@ -58,6 +65,79 @@ public void initialize(CordovaInterface cordova, CordovaWebView webView) { context = cordova.getContext(); activity = cordova.getActivity(); System.setProperty("maverick.log.nothread", "true"); + configureCryptoProvider(); + } + + private static synchronized void configureCryptoProvider() { + if (cryptoProviderConfigured) return; + + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME); + Security.insertProviderAt(new BouncyCastleProvider(), 1); + JCEProvider.enableBouncyCastle(true); + cryptoProviderConfigured = true; + } + + private static void configureClient(SshClientContext sshContext) { + FileSystemPolicy policy = sshContext.getPolicy(FileSystemPolicy.class); + policy.setSftpMaxWindowSize( + new UnsignedInteger32(SFTP_MAX_WINDOW_SIZE) + ); + policy.setSftpMinWindowSize( + new UnsignedInteger32(SFTP_MIN_WINDOW_SIZE) + ); + policy.setMaximumNumberofAsyncSFTPRequests(4); + } + + private void closeConnectionQuietly() { + SftpClient previousSftp = sftp; + SshClient previousSsh = ssh; + sftp = null; + ssh = null; + connectionID = null; + + if (previousSftp != null) { + try { + previousSftp.quit(); + } catch (SshException e) { + Log.w(TAG, "Failed to close the SFTP subsystem", e); + } + } + if (previousSsh != null) { + try { + previousSsh.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close the SSH connection", e); + } + } + } + + private boolean establishConnection( + SshClientBuilder builder, + String newConnectionID + ) throws IOException, SshException, PermissionDeniedException { + synchronized (connectionLock) { + closeConnectionQuietly(); + ssh = builder.onConfigure(Sftp::configureClient).build(); + if (!ssh.isConnected()) { + closeConnectionQuietly(); + return false; + } + + connectionID = newConnectionID; + try { + sftp = SftpClientBuilder.create().withClient(ssh).build(); + } catch (IOException | SshException | PermissionDeniedException e) { + closeConnectionQuietly(); + throw e; + } + + try { + sftp.getSubsystemChannel().setCharsetEncoding("UTF-8"); + } catch (UnsupportedEncodingException | SshException e) { + Log.w(TAG, "Failed to set UTF-8 encoding, using the default", e); + } + return true; + } } public boolean execute( @@ -103,37 +183,15 @@ public void run() { TAG, "Connecting to " + host + ":" + port + " as " + username ); - ssh = SshClientBuilder.create() + SshClientBuilder builder = SshClientBuilder.create() .withHostname(host) .withPort(port) .withUsername(username) - .withPassword(password) - .build(); - - if (ssh.isConnected()) { - connectionID = username + "@" + host; - - try { - sftp = SftpClientBuilder.create().withClient(ssh).build(); - } catch (IOException | SshException e) { - ssh.close(); - callback.error( - "Failed to initialize SFTP subsystem: " + errMessage(e) - ); - Log.e(TAG, "Failed to initialize SFTP subsystem", e); - return; - } + .withPassword(password); - try { - sftp.getSubsystemChannel().setCharsetEncoding("UTF-8"); - } catch (UnsupportedEncodingException | SshException e) { - // Fallback to default encoding if UTF-8 fails - Log.w( - TAG, - "Failed to set UTF-8 encoding, falling back to default", - e - ); - } + if ( + establishConnection(builder, username + "@" + host + ":" + port) + ) { callback.success(); Log.d(TAG, "Connected successfully to " + connectionID); return; @@ -155,6 +213,12 @@ public void run() { } catch (Exception e) { callback.error("Unexpected error: " + errMessage(e)); Log.e(TAG, "Unexpected error", e); + } catch (OutOfMemoryError e) { + synchronized (connectionLock) { + closeConnectionQuietly(); + } + callback.error("Not enough memory to initialize SFTP"); + Log.e(TAG, "Not enough memory to initialize SFTP", e); } } } @@ -179,28 +243,15 @@ public void run() { ); Uri uri = file.getUri(); ContentResolver contentResolver = context.getContentResolver(); - InputStream in = contentResolver.openInputStream(uri); - -// for `appDataDirectory`, Ref: https://developer.android.com/reference/android/content/Context#getExternalFilesDir(java.lang.String) -// the absolute path to application-specific directory. May return *null* if shared storage is not currently available. - File appDataDirectory = context.getExternalFilesDir(null); - if (appDataDirectory != null) { - com.sshtools.common.logger.Log.getDefaultContext().enableFile(com.sshtools.common.logger.Log.Level.DEBUG, new File(appDataDirectory,"synergy.log")); - } -// JCEProvider.enableBouncyCastle(false); - - Log.i(TAG, "All Available Security Providers (Security.getProviders() : " + Arrays.toString(Security.getProviders())); - Log.i(TAG, "All Available Security Providers for ED25519 (Security.getProviders(\"KeyPairGenerator.Ed25519\"\") : " + Arrays.toString(Security.getProviders("KeyPairGenerator.Ed25519"))); - Log.i(TAG, "BC Security Provider Name (`Security.getProvider(BouncyCastleProvider.PROVIDER_NAME)`) : " + Security.getProvider(BouncyCastleProvider.PROVIDER_NAME)); - Security.removeProvider("BC"); - Security.insertProviderAt(new BouncyCastleProvider(), 1); - - Log.i(TAG, "(After Inserting BC) All Available Security Providers (Security.getProviders() : " + Arrays.toString(Security.getProviders())); - Log.i(TAG, "(After Inserting BC) All Available Security Providers for ED25519 (Security.getProviders(\"KeyPairGenerator.Ed25519\"\") : " + Arrays.toString(Security.getProviders("KeyPairGenerator.Ed25519"))); - Log.i(TAG, "(After Inserting BC) BC Security Provider Name (`Security.getProvider(BouncyCastleProvider.PROVIDER_NAME)`) : " + Security.getProvider(BouncyCastleProvider.PROVIDER_NAME)); SshKeyPair keyPair = null; - try { + try ( + InputStream in = contentResolver.openInputStream(uri) + ) { + if (in == null) { + callback.error("Could not open key file"); + return; + } keyPair = SshKeyUtils.getPrivateKey(in, passphrase); } catch (InvalidPassphraseException e) { callback.error("Invalid passphrase for key file"); @@ -212,36 +263,15 @@ public void run() { return; } - ssh = SshClientBuilder.create() + SshClientBuilder builder = SshClientBuilder.create() .withHostname(host) .withPort(port) .withUsername(username) - .withIdentities(keyPair) - .build(); - - if (ssh.isConnected()) { - connectionID = username + "@" + host; - try { - sftp = SftpClientBuilder.create().withClient(ssh).build(); - } catch (IOException | SshException e) { - ssh.close(); - callback.error( - "Failed to initialize SFTP subsystem: " + errMessage(e) - ); - Log.e(TAG, "Failed to initialize SFTP subsystem", e); - return; - } + .withIdentities(keyPair); - try { - sftp.getSubsystemChannel().setCharsetEncoding("UTF-8"); - } catch (UnsupportedEncodingException | SshException e) { - // Fallback to default encoding if UTF-8 fails - Log.w( - TAG, - "Failed to set UTF-8 encoding, falling back to default", - e - ); - } + if ( + establishConnection(builder, username + "@" + host + ":" + port) + ) { callback.success(); Log.d(TAG, "Connected successfully to " + connectionID); return; @@ -266,6 +296,12 @@ public void run() { } catch (Exception e) { callback.error("Unexpected error: " + errMessage(e)); Log.e(TAG, "Unexpected error", e); + } catch (OutOfMemoryError e) { + synchronized (connectionLock) { + closeConnectionQuietly(); + } + callback.error("Not enough memory to initialize SFTP"); + Log.e(TAG, "Not enough memory to initialize SFTP", e); } } } @@ -728,16 +764,13 @@ public void close(JSONArray args, CallbackContext callback) { .execute( new Runnable() { public void run() { - try { - if (ssh != null) { - ssh.close(); - sftp.quit(); + synchronized (connectionLock) { + if (ssh != null || sftp != null) { + closeConnectionQuietly(); callback.success(); return; } - callback.error("Not connected"); - } catch (IOException | SshException e) { - callback.error(errMessage(e)); + callback.success(); } } } diff --git a/src/utils/Url.js b/src/utils/Url.js index 948503129..f79e69be0 100644 --- a/src/utils/Url.js +++ b/src/utils/Url.js @@ -43,6 +43,24 @@ export default { }); }, + /** + * Checks whether a URL is the same as, or nested below, a parent URL. + * Query parameters are ignored and path segment boundaries are preserved. + * @param {string} candidate + * @param {string} parent + * @returns {boolean} + */ + isSameOrDescendant(candidate, parent) { + const normalize = (value) => { + value = this.parse(value).url; + return value.endsWith("/") ? value.slice(0, -1) : value; + }; + candidate = normalize(candidate); + parent = normalize(parent); + if (candidate === parent) return true; + return candidate.startsWith(`${parent}/`); + }, + /** * * @param {String} url diff --git a/tests/unit/url.test.js b/tests/unit/url.test.js index f2304b659..0334ab0a0 100644 --- a/tests/unit/url.test.js +++ b/tests/unit/url.test.js @@ -85,6 +85,32 @@ describe("Url.safe", () => { }); }); +describe("Url.isSameOrDescendant", () => { + it("matches a remote root and its descendants", () => { + expect( + Url.isSameOrDescendant( + "sftp://user@host:22/project/src?keyFile=secret", + "sftp://user@host:22/project", + ), + ).toBe(true); + expect( + Url.isSameOrDescendant( + "sftp://user@host:22/project/", + "sftp://user@host:22/project", + ), + ).toBe(true); + }); + + it("preserves path segment boundaries", () => { + expect( + Url.isSameOrDescendant( + "sftp://user@host:22/project-copy", + "sftp://user@host:22/project", + ), + ).toBe(false); + }); +}); + describe("Url.formate", () => { it("builds a url from its parts", () => { expect( From 060aa787132a737a0a06c9d752cbd289eae1cbbc Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:21:44 +0530 Subject: [PATCH 2/3] feat(ssh): add remote terminal sessions --- src/components/terminal/terminal.js | 135 +++++++ src/components/terminal/terminalManager.js | 63 ++- src/lib/openFolder.js | 27 +- src/pages/fileBrowser/fileBrowser.js | 17 + src/plugins/sftp/index.d.ts | 23 +- .../sftp/src/com/foxdebug/sftp/Sftp.java | 365 ++++++++++++++++++ src/plugins/sftp/www/sftp.js | 23 +- 7 files changed, 639 insertions(+), 14 deletions(-) diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index 6b7504140..ae0ab4373 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -67,6 +67,9 @@ export default class TerminalComponent { this.pid = null; this.isConnected = false; this.serverMode = options.serverMode !== false; // Default true + this.remoteSsh = options.remoteSsh || null; + this.remoteShellId = null; + this.remoteInputDisposable = null; this.touchSelection = null; this.touchScrolling = null; this.parsedAppKeybindings = []; @@ -801,6 +804,9 @@ export default class TerminalComponent { "Terminal is in local mode, cannot connect to server session", ); } + if (this.remoteSsh) { + return this.connectToRemoteShell(); + } if (!pid) { pid = await this.createSession(); @@ -915,6 +921,100 @@ export default class TerminalComponent { }); } + /** + * Connect xterm to an interactive Maverick SSH shell. + */ + connectToRemoteShell() { + const profile = this.remoteSsh; + if (!profile) throw new Error("SSH profile is required"); + + return new Promise((resolve, reject) => { + let settled = false; + const onEvent = (event) => { + switch (event?.type) { + case "ready": + this.remoteShellId = event.sessionId; + this.pid = `ssh:${event.sessionId}`; + this.isConnected = true; + this.remoteInputDisposable = this.terminal.onData((data) => { + if (!this.isConnected || !this.remoteShellId) return; + sftp.writeShell( + this.remoteShellId, + data, + () => {}, + (error) => this.onError?.(error), + ); + }); + this.terminal.unicode.activeVersion = "11"; + this.terminal.focus(); + void this.fitAndResizeTerminal(true); + this.onConnect?.(); + settled = true; + resolve(event.sessionId); + break; + + case "data": { + const binary = atob(event.data || ""); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + this.terminal.write(bytes); + break; + } + + case "exit": + this.isConnected = false; + this.processExited = true; + if (!this.intentionalClose) { + this.onProcessExit?.({ exit_code: event.exitCode }); + } + break; + + case "error": { + const error = new Error(event.message || "SSH shell error"); + this.isConnected = false; + if (!settled) reject(error); + else if (!this.intentionalClose) this.onError?.(error); + break; + } + } + }; + + const onFailure = (message) => { + const error = new Error( + typeof message === "string" ? message : "Failed to open SSH shell", + ); + this.isConnected = false; + if (!settled) reject(error); + else if (!this.intentionalClose) this.onError?.(error); + }; + + if (profile.keyFile) { + sftp.openShellUsingKeyFile( + profile.hostname, + profile.port, + profile.username, + profile.keyFile, + profile.passPhrase || "", + this.terminal.cols, + this.terminal.rows, + onEvent, + onFailure, + ); + return; + } + + sftp.openShellUsingPassword( + profile.hostname, + profile.port, + profile.username, + profile.password || "", + this.terminal.cols, + this.terminal.rows, + onEvent, + onFailure, + ); + }); + } + /** * Resize terminal * @param {number} cols - Number of columns @@ -926,6 +1026,20 @@ export default class TerminalComponent { const resizeKey = `${cols}x${rows}`; if (!force && this.lastRequestedServerSize === resizeKey) return; this.lastRequestedServerSize = resizeKey; + if (this.remoteSsh) { + if (!this.remoteShellId) return; + sftp.resizeShell( + this.remoteShellId, + cols, + rows, + () => {}, + (error) => { + this.lastRequestedServerSize = null; + this.onError?.(error); + }, + ); + return; + } try { await new Promise((resolve, reject) => { @@ -987,6 +1101,15 @@ export default class TerminalComponent { * @param {string} data - Data to write */ write(data) { + if (this.remoteSsh && this.isConnected && this.remoteShellId) { + sftp.writeShell( + this.remoteShellId, + data, + () => {}, + (error) => this.onError?.(error), + ); + return; + } if ( this.serverMode && this.isConnected && @@ -1285,6 +1408,18 @@ export default class TerminalComponent { */ async terminate() { this.intentionalClose = true; + this.remoteInputDisposable?.dispose?.(); + this.remoteInputDisposable = null; + + if (this.remoteShellId) { + const shellID = this.remoteShellId; + this.remoteShellId = null; + this.isConnected = false; + await new Promise((resolve) => { + sftp.closeShell(shellID, resolve, resolve); + }); + return; + } if (this.websocket) { try { diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index 632626658..4ad94db22 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -13,6 +13,7 @@ import openFile from "lib/openFile"; import openFolder from "lib/openFolder"; import appSettings from "lib/settings"; import helpers from "utils/helpers"; +import Url from "utils/Url"; import TerminalComponent from "./terminal"; import TerminalTouchSelection from "./terminalTouchSelection"; @@ -276,6 +277,7 @@ class TerminalManager { const shouldRender = render !== false; const isServerMode = serverMode !== false; const isReconnecting = reconnecting === true; + const isRemoteSsh = !!terminalOptions.remoteSsh; const terminalId = `terminal_${++this.terminalCounter}`; const providedName = @@ -289,7 +291,7 @@ class TerminalManager { : terminalName; // Check if terminal is installed before proceeding - if (isServerMode) { + if (isServerMode && !isRemoteSsh) { const installationResult = await this.checkAndInstallTerminal(); if (!installationResult.success) { throw new Error(installationResult.error); @@ -369,7 +371,11 @@ class TerminalManager { this.terminals.set(uniqueId, instance); - if (terminalComponent.serverMode && terminalComponent.pid) { + if ( + terminalComponent.serverMode && + !terminalComponent.remoteSsh && + terminalComponent.pid + ) { await this.persistTerminalSession( terminalComponent.pid, terminalName, @@ -831,7 +837,11 @@ class TerminalManager { const formattedTitle = `${titlePrefix} - ${title}`; terminalFile.filename = formattedTitle; - if (terminalComponent.serverMode && terminalComponent.pid) { + if ( + terminalComponent.serverMode && + !terminalComponent.remoteSsh && + terminalComponent.pid + ) { await this.persistTerminalSession( terminalComponent.pid, formattedTitle, @@ -870,6 +880,9 @@ class TerminalManager { // Handle acode CLI open commands (OSC 7777) terminalComponent.onOscOpen = async (type, path) => { + // OSC 7777 is an Acode-local CLI protocol. Remote hosts must not use it + // to request access to paths on the Android device. + if (terminalComponent.remoteSsh) return; if (!path) return; // Convert proot path @@ -899,6 +912,10 @@ class TerminalManager { // Set up custom title function for terminal const getTerminalTitle = () => { + if (terminalComponent.remoteSsh) { + const { username, hostname } = terminalComponent.remoteSsh; + return `${username}@${hostname}`; + } if (terminalComponent.pid) { return `PID: ${terminalComponent.pid}`; } @@ -924,7 +941,11 @@ class TerminalManager { terminal.component.intentionalClose = true; } - if (terminal.component.serverMode && terminal.component.pid) { + if ( + terminal.component.serverMode && + !terminal.component.remoteSsh && + terminal.component.pid + ) { this.removePersistedSession(terminal.component.pid); } @@ -1091,6 +1112,40 @@ class TerminalManager { }); } + /** + * Create an SSH terminal using credentials from an SFTP storage URL. + * @param {string|{url: string, name?: string}} storage - SFTP storage + * @param {object} options - Terminal options + * @returns {Promise} Terminal instance + */ + async createRemoteTerminal(storage, options = {}) { + const url = typeof storage === "string" ? storage : storage?.url; + const storageName = typeof storage === "object" ? storage?.name : null; + + if (!url || !/^sftp:/.test(url)) { + throw new Error("A valid SFTP storage is required"); + } + + const { username, password, hostname, port, query } = Url.decodeUrl(url); + if (!hostname || !username) { + throw new Error("The SFTP storage is missing its host or username"); + } + + return this.createTerminal({ + ...options, + name: options.name || `SSH - ${storageName || hostname}`, + serverMode: true, + remoteSsh: { + hostname, + port: port || 22, + username, + password, + keyFile: query?.keyFile, + passPhrase: query?.passPhrase, + }, + }); + } + /** * Handle keyboard resize events for all terminals * This is called when the virtual keyboard opens/closes on mobile diff --git a/src/lib/openFolder.js b/src/lib/openFolder.js index 2fd75a1d1..927606a92 100644 --- a/src/lib/openFolder.js +++ b/src/lib/openFolder.js @@ -335,6 +335,11 @@ async function handleContextmenu(type, url, name, $target) { strings["install as plugin"] || "Install as Plugin", "extension", ]; + const OPEN_SSH_TERMINAL = [ + "open-ssh-terminal", + strings["open ssh terminal"] || "Open SSH Terminal", + "terminal", + ]; let options; @@ -364,6 +369,8 @@ async function handleContextmenu(type, url, name, $target) { "terminal", ]; options.push(OPEN_IN_TERMINAL); + } else if (/^sftp:/.test(url)) { + options.push(OPEN_SSH_TERMINAL); } } else if (type === "root") { options = []; @@ -381,6 +388,8 @@ async function handleContextmenu(type, url, name, $target) { "terminal", ]; options.push(OPEN_IN_TERMINAL); + } else if (/^sftp:/.test(url)) { + options.push(OPEN_SSH_TERMINAL); } options.push(CLOSE_FOLDER); @@ -401,7 +410,7 @@ async function handleContextmenu(type, url, name, $target) { /** * @param {"dir"|"file"|"root"} type - * @param {"copy"|"cut"|"delete"|"rename"|"paste"|"new file"|"new folder"|"cancel"|"open-folder"|"install-plugin"} action + * @param {"copy"|"cut"|"delete"|"rename"|"paste"|"new file"|"new folder"|"cancel"|"open-folder"|"install-plugin"|"open-in-terminal"|"open-ssh-terminal"|"copy-relative-path"} action * @param {string} url target url * @param {HTMLElement} $target target element * @param {string} name Name of file or folder @@ -447,6 +456,9 @@ function execOperation(type, action, url, $target, name) { case "open-in-terminal": return openInTerminal(); + case "open-ssh-terminal": + return openSshTerminal(); + case "copy-relative-path": return copyRelativePath(); } @@ -557,6 +569,19 @@ function execOperation(type, action, url, $target, name) { } } + async function openSshTerminal() { + try { + const { TerminalManager } = await import( + /* webpackChunkName: "terminal" */ "components/terminal" + ); + await TerminalManager.createRemoteTerminal({ url, name }); + Sidebar.hide(); + } catch (error) { + console.error("Failed to open SSH terminal:", error); + toast(`Failed to open SSH terminal: ${error.message || "Unknown error"}`); + } + } + async function deleteFile() { const msg = strings["delete entry"].replace("{name}", name); const confirmation = await confirm(strings.warning, msg); diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index 6efd71e62..51644fd72 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -1066,6 +1066,14 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { options.push(["edit", strings.edit, "edit"]); } + if (storageType === "sftp" && uuid) { + options.push([ + "ssh_terminal", + strings["open ssh terminal"] || "Open SSH Terminal", + "terminal", + ]); + } + if (helpers.isFile(type)) { options.push(["info", strings.info, "info"]); options.push(["open_with", strings["open with"], "open_in_browser"]); @@ -1114,6 +1122,15 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { break; } + case "ssh_terminal": { + const { TerminalManager } = await import( + /* webpackChunkName: "terminal" */ "components/terminal" + ); + await TerminalManager.createRemoteTerminal({ url, name }); + $page.hide(); + break; + } + case "info": acode.exec("file-info", url); break; diff --git a/src/plugins/sftp/index.d.ts b/src/plugins/sftp/index.d.ts index 62d0afeed..02f58951f 100644 --- a/src/plugins/sftp/index.d.ts +++ b/src/plugins/sftp/index.d.ts @@ -12,10 +12,18 @@ interface Stats { uri: string; } -interface ExecResult{ +interface ExecResult{ code: Number; result: String; -} +} + +interface ShellEvent { + type: "ready" | "data" | "exit" | "error"; + sessionId?: string; + data?: string; + exitCode?: number; + message?: string; +} interface Sftp { /** @@ -78,7 +86,12 @@ interface Sftp { * @param onSuccess * @param onFail */ - isConnected(onSuccess: (connectionId: String) => void, onFail: (err: any) => void): void; -} + isConnected(onSuccess: (connectionId: String) => void, onFail: (err: any) => void): void; + openShellUsingPassword(host: String, port: Number, username: String, password: String, cols: Number, rows: Number, onEvent: (event: ShellEvent) => void, onFail: (err: any) => void): void; + openShellUsingKeyFile(host: String, port: Number, username: String, keyFile: String, passphrase: String, cols: Number, rows: Number, onEvent: (event: ShellEvent) => void, onFail: (err: any) => void): void; + writeShell(sessionId: String, data: String, onSuccess: () => void, onFail: (err: any) => void): void; + resizeShell(sessionId: String, cols: Number, rows: Number, onSuccess: () => void, onFail: (err: any) => void): void; + closeShell(sessionId: String, onSuccess: () => void, onFail: (err: any) => void): void; +} -declare var sftp: Sftp; \ No newline at end of file +declare var sftp: Sftp; diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java index eff9f96c7..c34716d9b 100644 --- a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java +++ b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java @@ -4,11 +4,13 @@ import android.content.ContentResolver; import android.content.Context; import android.net.Uri; +import android.util.Base64; import android.util.Log; import androidx.documentfile.provider.DocumentFile; import com.sshtools.client.SshClient; import com.sshtools.client.SshClient.SshClientBuilder; import com.sshtools.client.SshClientContext; +import com.sshtools.client.SessionChannelNG; import com.sshtools.client.sftp.SftpClient; import com.sshtools.client.sftp.SftpClient.SftpClientBuilder; import com.sshtools.client.sftp.SftpFile; @@ -20,6 +22,9 @@ import com.sshtools.common.sftp.SftpFileAttributes; import com.sshtools.common.sftp.SftpStatusException; import com.sshtools.common.ssh.SshException; +import com.sshtools.common.ssh.Channel; +import com.sshtools.common.ssh.ChannelEventListener; +import com.sshtools.common.ssh.RequestFuture; import com.sshtools.common.ssh.components.SshKeyPair; import com.sshtools.common.ssh.components.jce.JCEProvider; import com.sshtools.common.util.UnsignedInteger32; @@ -33,13 +38,22 @@ import java.net.URISyntaxException; import java.net.URLDecoder; import java.net.URLEncoder; +import java.nio.ByteBuffer; import java.nio.channels.UnresolvedAddressException; import java.nio.charset.StandardCharsets; import java.security.Security; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.cordova.CallbackContext; import org.apache.cordova.CordovaInterface; import org.apache.cordova.CordovaPlugin; import org.apache.cordova.CordovaWebView; +import org.apache.cordova.PluginResult; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -54,12 +68,106 @@ public class Sftp extends CordovaPlugin { private static final long SFTP_MIN_WINDOW_SIZE = 128L * 1024L; private static boolean cryptoProviderConfigured; private final Object connectionLock = new Object(); + private final Map remoteShells = new ConcurrentHashMap<>(); private SshClient ssh; private SftpClient sftp; private Context context; private Activity activity; private String connectionID; + private final class RemoteShell { + + private final String id; + private final SshClient client; + private final SessionChannelNG channel; + private final CallbackContext streamCallback; + private final AtomicBoolean finished = new AtomicBoolean(false); + private final ExecutorService inputWriter = Executors.newSingleThreadExecutor(); + + private RemoteShell( + String id, + SshClient client, + SessionChannelNG channel, + CallbackContext streamCallback + ) { + this.id = id; + this.client = client; + this.channel = channel; + this.streamCallback = streamCallback; + } + + private void sendData(ByteBuffer source) { + if (finished.get() || source == null || !source.hasRemaining()) return; + + ByteBuffer data = source.asReadOnlyBuffer(); + byte[] bytes = new byte[data.remaining()]; + data.get(bytes); + try { + JSONObject event = new JSONObject(); + event.put("type", "data"); + event.put("data", Base64.encodeToString(bytes, Base64.NO_WRAP)); + sendShellEvent(streamCallback, event, true); + } catch (JSONException e) { + finish(null, e.getMessage()); + } + } + + private void finish(Integer exitCode, String error) { + if (!finished.compareAndSet(false, true)) return; + remoteShells.remove(id, this); + inputWriter.shutdownNow(); + + try { + channel.close(); + } catch (Exception e) { + Log.w(TAG, "Failed to close SSH shell channel " + id, e); + } + try { + client.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close SSH shell connection " + id, e); + } + + try { + JSONObject event = new JSONObject(); + event.put("type", error == null ? "exit" : "error"); + if (exitCode != null) event.put("exitCode", exitCode); + if (error != null) event.put("message", error); + sendShellEvent(streamCallback, event, false); + } catch (JSONException e) { + streamCallback.error(errMessage(e)); + } + } + + private void write(String input, CallbackContext callback) { + if (finished.get()) { + callback.error("SSH shell is not connected"); + return; + } + + try { + inputWriter.execute( + new Runnable() { + @Override + public void run() { + try { + byte[] data = input.getBytes(StandardCharsets.UTF_8); + channel.getOutputStream().write(data); + channel.getOutputStream().flush(); + callback.success(); + } catch (IOException e) { + finish(null, errMessage(e)); + callback.error(errMessage(e)); + } + } + } + ); + } catch (RejectedExecutionException e) { + callback.error("SSH shell is not connected"); + } + } + } + public void initialize(CordovaInterface cordova, CordovaWebView webView) { super.initialize(cordova, webView); context = cordova.getContext(); @@ -140,6 +248,263 @@ private boolean establishConnection( } } + private static void sendShellEvent( + CallbackContext callback, + JSONObject event, + boolean keepCallback + ) { + PluginResult result = new PluginResult(PluginResult.Status.OK, event); + result.setKeepCallback(keepCallback); + callback.sendPluginResult(result); + } + + private void closeRemoteShells() { + for (RemoteShell shell : remoteShells.values()) { + shell.finish(null, null); + } + remoteShells.clear(); + } + + @Override + public void onReset() { + closeRemoteShells(); + super.onReset(); + } + + @Override + public void onDestroy() { + closeRemoteShells(); + super.onDestroy(); + } + + private SshClient buildShellPasswordClient( + String host, + int port, + String username, + String password + ) throws IOException, SshException, PermissionDeniedException { + return SshClientBuilder.create() + .withHostname(host) + .withPort(port) + .withUsername(username) + .withPassword(password) + .onConfigure(Sftp::configureClient) + .build(); + } + + private SshClient buildShellKeyClient( + String host, + int port, + String username, + String keyFile, + String passphrase + ) + throws IOException, SshException, PermissionDeniedException, InvalidPassphraseException { + DocumentFile file = DocumentFile.fromSingleUri(context, Uri.parse(keyFile)); + if (file == null) throw new IOException("Could not open key file"); + + SshKeyPair keyPair; + try ( + InputStream in = context + .getContentResolver() + .openInputStream(file.getUri()) + ) { + if (in == null) throw new IOException("Could not open key file"); + keyPair = SshKeyUtils.getPrivateKey(in, passphrase); + } + + return SshClientBuilder.create() + .withHostname(host) + .withPort(port) + .withUsername(username) + .withIdentities(keyPair) + .onConfigure(Sftp::configureClient) + .build(); + } + + private void openRemoteShell( + SshClient shellClient, + int columns, + int rows, + CallbackContext callback + ) throws SshException, JSONException, IOException { + if (!shellClient.isConnected()) { + shellClient.close(); + throw new IOException("Failed to establish SSH connection"); + } + + String shellID = UUID.randomUUID().toString(); + SessionChannelNG channel; + try { + channel = shellClient.openSessionChannel(true); + } catch (SshException e) { + shellClient.close(); + throw e; + } + RemoteShell shell = new RemoteShell(shellID, shellClient, channel, callback); + remoteShells.put(shellID, shell); + + channel.addEventListener( + new ChannelEventListener() { + @Override + public void onChannelDataIn(Channel source, ByteBuffer data) { + shell.sendData(data); + } + + @Override + public void onChannelExtendedData( + Channel source, + ByteBuffer data, + int type + ) { + shell.sendData(data); + } + + @Override + public void onChannelClose(Channel source) { + int exitCode = channel.getExitCode(); + shell.finish( + exitCode == SessionChannelNG.EXITCODE_NOT_RECEIVED ? null : exitCode, + null + ); + } + + @Override + public void onChannelError(Channel source, Throwable error) { + shell.finish(null, error == null ? "SSH shell error" : error.toString()); + } + } + ); + + RequestFuture pty = channel + .allocatePseudoTerminal("xterm-256color", columns, rows) + .waitFor(30000L); + if (!pty.isSuccess()) { + shell.finish(null, "Remote server rejected PTY allocation"); + return; + } + if (shell.finished.get()) return; + + RequestFuture start = channel.startShell().waitFor(30000L); + if (!start.isSuccess()) { + shell.finish(null, "Remote server rejected the interactive shell"); + return; + } + if (shell.finished.get()) return; + + JSONObject ready = new JSONObject(); + ready.put("type", "ready"); + ready.put("sessionId", shellID); + sendShellEvent(callback, ready, true); + } + + public void openShellUsingPassword(JSONArray args, CallbackContext callback) { + cordova + .getThreadPool() + .execute( + new Runnable() { + public void run() { + try { + String host = args.optString(0); + int port = args.optInt(1, 22); + String username = args.optString(2); + String password = args.optString(3); + int columns = Math.max(1, args.optInt(4, 80)); + int rows = Math.max(1, args.optInt(5, 24)); + openRemoteShell( + buildShellPasswordClient( + host, + port, + username, + password + ), + columns, + rows, + callback + ); + } catch (Exception e) { + callback.error("Failed to open SSH shell: " + errMessage(e)); + Log.e(TAG, "Failed to open SSH shell", e); + } catch (OutOfMemoryError e) { + callback.error("Not enough memory to open SSH shell"); + Log.e(TAG, "Not enough memory to open SSH shell", e); + } + } + } + ); + } + + public void openShellUsingKeyFile(JSONArray args, CallbackContext callback) { + cordova + .getThreadPool() + .execute( + new Runnable() { + public void run() { + try { + String host = args.optString(0); + int port = args.optInt(1, 22); + String username = args.optString(2); + String keyFile = args.optString(3); + String passphrase = args.optString(4); + int columns = Math.max(1, args.optInt(5, 80)); + int rows = Math.max(1, args.optInt(6, 24)); + openRemoteShell( + buildShellKeyClient( + host, + port, + username, + keyFile, + passphrase + ), + columns, + rows, + callback + ); + } catch (InvalidPassphraseException e) { + callback.error("Invalid passphrase for key file"); + } catch (Exception e) { + callback.error("Failed to open SSH shell: " + errMessage(e)); + Log.e(TAG, "Failed to open SSH shell", e); + } catch (OutOfMemoryError e) { + callback.error("Not enough memory to open SSH shell"); + Log.e(TAG, "Not enough memory to open SSH shell", e); + } + } + } + ); + } + + public void writeShell(JSONArray args, CallbackContext callback) { + RemoteShell shell = remoteShells.get(args.optString(0)); + if (shell == null || shell.finished.get()) { + callback.error("SSH shell is not connected"); + return; + } + shell.write(args.optString(1), callback); + } + + public void resizeShell(JSONArray args, CallbackContext callback) { + String shellID = args.optString(0); + RemoteShell shell = remoteShells.get(shellID); + if (shell == null || shell.finished.get()) { + callback.error("SSH shell is not connected"); + return; + } + shell.channel.changeTerminalDimensions( + Math.max(1, args.optInt(1, 80)), + Math.max(1, args.optInt(2, 24)), + 0, + 0 + ); + callback.success(); + } + + public void closeShell(JSONArray args, CallbackContext callback) { + RemoteShell shell = remoteShells.get(args.optString(0)); + if (shell != null) shell.finish(null, null); + callback.success(); + } + public boolean execute( String action, JSONArray args, diff --git a/src/plugins/sftp/www/sftp.js b/src/plugins/sftp/www/sftp.js index cd1859ea6..657be7dab 100644 --- a/src/plugins/sftp/www/sftp.js +++ b/src/plugins/sftp/www/sftp.js @@ -48,7 +48,22 @@ module.exports = { close: function (onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'Sftp', 'close', []); }, - isConnected: function (onSuccess, onFail) { - cordova.exec(onSuccess, onFail, 'Sftp', 'isConnected', []); - } -}; + isConnected: function (onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'isConnected', []); + }, + openShellUsingPassword: function (host, port, username, password, cols, rows, onEvent, onFail) { + cordova.exec(onEvent, onFail, 'Sftp', 'openShellUsingPassword', [host, port, username, password, cols, rows]); + }, + openShellUsingKeyFile: function (host, port, username, keyFile, passphrase, cols, rows, onEvent, onFail) { + cordova.exec(onEvent, onFail, 'Sftp', 'openShellUsingKeyFile', [host, port, username, keyFile, passphrase, cols, rows]); + }, + writeShell: function (sessionId, data, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'writeShell', [sessionId, data]); + }, + resizeShell: function (sessionId, cols, rows, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'resizeShell', [sessionId, cols, rows]); + }, + closeShell: function (sessionId, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'closeShell', [sessionId]); + } +}; From eaa195ec69071acc943640fc2d622db9f520cc7c Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:39:29 +0530 Subject: [PATCH 3/3] feat(sftp): secure profiles and verify host keys --- src/components/terminal/terminal.js | 61 +++- src/components/terminal/terminalManager.js | 27 +- src/fileSystem/sftp.js | 62 +++- src/lib/remoteStorage.js | 82 +++-- src/lib/sftpProfiles.js | 172 ++++++++++ src/lib/sshHostKey.js | 86 +++++ src/main.js | 4 + src/pages/fileBrowser/fileBrowser.js | 14 +- src/plugins/sftp/index.d.ts | 15 +- src/plugins/sftp/plugin.xml | 3 +- .../sftp/src/com/foxdebug/sftp/Sftp.java | 324 +++++++++++++++++- .../com/foxdebug/sftp/SftpSecurityStore.java | 160 +++++++++ src/plugins/sftp/www/sftp.js | 24 +- tests/unit/sftpProfiles.test.js | 74 ++++ 14 files changed, 1017 insertions(+), 91 deletions(-) create mode 100644 src/lib/sftpProfiles.js create mode 100644 src/lib/sshHostKey.js create mode 100644 src/plugins/sftp/src/com/foxdebug/sftp/SftpSecurityStore.java create mode 100644 tests/unit/sftpProfiles.test.js diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index ae0ab4373..e84af7827 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -19,6 +19,7 @@ import { import confirm from "dialogs/confirm"; import fonts from "lib/fonts"; import appSettings from "lib/settings"; +import { resolveHostKeyError } from "lib/sshHostKey"; import LigaturesAddon from "./ligatures"; import { DEFAULT_TERMINAL_SETTINGS, @@ -978,7 +979,18 @@ export default class TerminalComponent { } }; - const onFailure = (message) => { + const onFailure = async (message) => { + try { + if (!settled && (await resolveHostKeyError(message))) { + openShell(); + return; + } + } catch (error) { + this.isConnected = false; + if (!settled) reject(error); + else if (!this.intentionalClose) this.onError?.(error); + return; + } const error = new Error( typeof message === "string" ? message : "Failed to open SSH shell", ); @@ -987,31 +999,46 @@ export default class TerminalComponent { else if (!this.intentionalClose) this.onError?.(error); }; - if (profile.keyFile) { - sftp.openShellUsingKeyFile( + const openShell = () => { + if (profile.profileId) { + sftp.openShellUsingProfile( + profile.profileId, + this.terminal.cols, + this.terminal.rows, + onEvent, + onFailure, + ); + return; + } + + if (profile.keyFile) { + sftp.openShellUsingKeyFile( + profile.hostname, + profile.port, + profile.username, + profile.keyFile, + profile.passPhrase || "", + this.terminal.cols, + this.terminal.rows, + onEvent, + onFailure, + ); + return; + } + + sftp.openShellUsingPassword( profile.hostname, profile.port, profile.username, - profile.keyFile, - profile.passPhrase || "", + profile.password || "", this.terminal.cols, this.terminal.rows, onEvent, onFailure, ); - return; - } + }; - sftp.openShellUsingPassword( - profile.hostname, - profile.port, - profile.username, - profile.password || "", - this.terminal.cols, - this.terminal.rows, - onEvent, - onFailure, - ); + openShell(); }); } diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index 4ad94db22..a2119f0ed 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -405,7 +405,7 @@ class TerminalManager { } // Show alert for terminal creation failure - if (!isReconnecting) { + if (!isReconnecting && !error?.reported) { const errorMessage = error?.message || "Unknown error"; alert( strings["error"], @@ -913,8 +913,8 @@ class TerminalManager { // Set up custom title function for terminal const getTerminalTitle = () => { if (terminalComponent.remoteSsh) { - const { username, hostname } = terminalComponent.remoteSsh; - return `${username}@${hostname}`; + const { username, hostname, displayName } = terminalComponent.remoteSsh; + return displayName || `${username}@${hostname}`; } if (terminalComponent.pid) { return `PID: ${terminalComponent.pid}`; @@ -1127,7 +1127,8 @@ class TerminalManager { } const { username, password, hostname, port, query } = Url.decodeUrl(url); - if (!hostname || !username) { + const profileId = hostname?.startsWith("profile-") ? hostname : null; + if (!profileId && (!hostname || !username)) { throw new Error("The SFTP storage is missing its host or username"); } @@ -1135,14 +1136,16 @@ class TerminalManager { ...options, name: options.name || `SSH - ${storageName || hostname}`, serverMode: true, - remoteSsh: { - hostname, - port: port || 22, - username, - password, - keyFile: query?.keyFile, - passPhrase: query?.passPhrase, - }, + remoteSsh: profileId + ? { profileId, displayName: storageName || "SSH" } + : { + hostname, + port: port || 22, + username, + password, + keyFile: query?.keyFile, + passPhrase: query?.passPhrase, + }, }); } diff --git a/src/fileSystem/sftp.js b/src/fileSystem/sftp.js index 4b6aea399..30d156c4f 100644 --- a/src/fileSystem/sftp.js +++ b/src/fileSystem/sftp.js @@ -1,4 +1,5 @@ import settings from "lib/settings"; +import { resolveHostKeyError } from "lib/sshHostKey"; import mimeType from "mime-types"; import { decode, encode } from "utils/encodings"; import helpers from "utils/helpers"; @@ -18,6 +19,7 @@ class SftpClient { #password; #keyFile; #passPhrase; + #profileID; #base; #connectionID; #path; @@ -31,26 +33,35 @@ class SftpClient { * @param {{password?: String, passPhrase?: String, keyFile?: String}} authentication */ constructor(hostname, port = 22, username, authentication) { + authentication ||= {}; + this.#profileID = authentication.profileID; this.#hostname = hostname; this.#port = port; this.#username = username; - this.#authenticationType = !!authentication.keyFile ? "key" : "password"; + this.#authenticationType = this.#profileID + ? "profile" + : authentication.keyFile + ? "key" + : "password"; this.#keyFile = authentication.keyFile; this.#passPhrase = authentication.passPhrase; this.#password = authentication.password; - this.#base = Url.formate({ - protocol: "sftp:", - hostname: this.#hostname, - port: this.#port, - username: this.#username, - password: this.#password, - query: { - passPhrase: this.#passPhrase, - keyFile: this.#keyFile, - }, - }); - - this.#connectionID = `${this.#username}@${this.#hostname}:${this.#port}`; + this.#base = this.#profileID + ? Url.formate({ protocol: "sftp:", hostname: this.#profileID }) + : Url.formate({ + protocol: "sftp:", + hostname: this.#hostname, + port: this.#port, + username: this.#username, + password: this.#password, + query: { + passPhrase: this.#passPhrase, + keyFile: this.#keyFile, + }, + }); + + this.#connectionID = + this.#profileID || `${this.#username}@${this.#hostname}:${this.#port}`; } setPath(path) { @@ -433,8 +444,9 @@ class SftpClient { for (let attempt = 0; attempt < attempts; attempt++) { try { - return await this.#connectOnce(); + return await this.#connectWithHostVerification(); } catch (error) { + if (error?.nonRetryable) throw error; lastError = error; } } @@ -442,8 +454,23 @@ class SftpClient { throw lastError; } + async #connectWithHostVerification() { + try { + return await this.#connectOnce(); + } catch (error) { + if (await resolveHostKeyError(error)) { + return this.#connectOnce(); + } + throw error; + } + } + #connectOnce() { return new Promise((resolve, reject) => { + if (this.#authenticationType === "profile") { + sftp.connectUsingProfile(this.#profileID, resolve, reject); + return; + } if (this.#authenticationType === "key") { sftp.connectUsingKeyFile( this.#hostname, @@ -622,6 +649,11 @@ function Sftp(host, port, username, authentication) { Sftp.fromUrl = (url) => { const { username, password, hostname, pathname, port, query } = Url.decodeUrl(url); + if (hostname?.startsWith("profile-")) { + const sftp = new SftpClient(null, 22, null, { profileID: hostname }); + sftp.setPath(pathname); + return createFs(sftp); + } const { keyFile, passPhrase } = query; const sftp = new SftpClient(hostname, port || 22, username, { diff --git a/src/lib/remoteStorage.js b/src/lib/remoteStorage.js index 5185de0ce..961483649 100644 --- a/src/lib/remoteStorage.js +++ b/src/lib/remoteStorage.js @@ -1,4 +1,3 @@ -import fsOperation from "fileSystem"; import Ftp from "fileSystem/ftp"; import Sftp from "fileSystem/sftp"; import loader from "dialogs/loader"; @@ -6,6 +5,12 @@ import multiPrompt from "dialogs/multiPrompt"; import URLParse from "url-parse"; import helpers from "utils/helpers"; import Url from "utils/Url"; +import { + createSftpProfileUrl, + getSftpProfileId, + getSftpProfileInfo, + saveSftpProfile, +} from "./sftpProfiles"; import { interstitialAd } from "./startAd"; export default { @@ -166,6 +171,7 @@ export default { */ async addSftp(...args) { let stopConnection = false; + const existingProfile = args[8] || null; const { hostname, @@ -176,8 +182,27 @@ export default { port, alias, usePassword, - } = await prompt(...args); + } = await prompt(...args.slice(0, 8)); const authType = usePassword ? "password" : "keyFile"; + const nativeAuthType = usePassword ? "password" : "key"; + + if ( + existingProfile && + !password && + !keyFile && + hostname === existingProfile.hostname && + username === existingProfile.username && + Number.parseInt(port, 10) === existingProfile.port && + nativeAuthType === existingProfile.authType + ) { + return { + alias, + name: alias, + url: existingProfile.url, + type: "sftp", + home: existingProfile.home, + }; + } loader.create(strings["add sftp"], strings["connecting..."], { timeout: 10000, @@ -199,37 +224,17 @@ export default { return; } - let localKeyFile = ""; - if (keyFile) { - let fs = fsOperation(keyFile); - const text = await fs.readFile("utf8"); - - //Original key file sometimes gives permission error - //To solve permission error - const filename = keyFile.hashCode(); - localKeyFile = Url.join(DATA_STORAGE, filename); - fs = fsOperation(localKeyFile); - const exists = await fs.exists(); - if (exists) { - await fs.writeFile(text); - } else { - let fs = fsOperation(DATA_STORAGE); - await fs.createFile(filename, text); - } - } - - const url = Url.formate({ - protocol: "sftp:", + const profileId = await saveSftpProfile({ + profileId: existingProfile?.profileId, hostname, username, + authType: nativeAuthType, password, port, - path: "/", - query: { - keyFile: localKeyFile, - passPhrase, - }, + keyFile, + passPhrase, }); + const url = createSftpProfileUrl(profileId); loader.destroy(); await helpers.showInterstitialIfReady(); return { @@ -246,7 +251,7 @@ export default { } loader.destroy(); - await helpers.error(err); + if (!err?.reported) await helpers.error(err); return await this.addSftp( hostname, username, @@ -256,6 +261,7 @@ export default { port, alias, authType, + existingProfile, ); } @@ -366,7 +372,23 @@ export default { return multiPrompt(strings["add sftp"], inputs); } }, - edit({ name, storageType, url }) { + async edit({ name, storageType, url, home }) { + const profileId = getSftpProfileId(url); + if (storageType === "sftp" && profileId) { + const profile = await getSftpProfileInfo(profileId); + return this.addSftp( + profile.hostname, + profile.username, + "", + "", + "", + profile.port, + name, + profile.authType, + { ...profile, profileId, url, home }, + ); + } + let { username, password, hostname, port, query } = URLParse(url, true); if (username) { diff --git a/src/lib/sftpProfiles.js b/src/lib/sftpProfiles.js new file mode 100644 index 000000000..624cdb725 --- /dev/null +++ b/src/lib/sftpProfiles.js @@ -0,0 +1,172 @@ +import fsOperation from "fileSystem"; +import Url from "utils/Url"; + +const PROFILE_PREFIX = "profile-"; +const MIGRATED_STORAGE_KEYS = [ + "storageList", + "folders", + "files", + "recentFiles", + "recentFolders", + "fileBrowserState", +]; + +export function getSftpProfileId(url) { + if (!/^sftp:/.test(url || "")) return null; + const { hostname } = Url.decodeUrl(url); + return hostname?.startsWith(PROFILE_PREFIX) ? hostname : null; +} + +export function createSftpProfileUrl(profileId, pathname = "/") { + return Url.formate({ + protocol: "sftp:", + hostname: profileId, + path: pathname || "/", + }); +} + +export function saveSftpProfile({ + profileId = null, + hostname, + port = 22, + username, + authType, + password = "", + keyFile = "", + passPhrase = "", +}) { + return new Promise((resolve, reject) => { + sftp.saveProfile( + profileId, + hostname, + Number.parseInt(port, 10) || 22, + username, + authType, + password, + keyFile, + passPhrase, + resolve, + reject, + ); + }); +} + +export function getSftpProfileInfo(profileId) { + return new Promise((resolve, reject) => { + sftp.getProfileInfo(profileId, resolve, reject); + }); +} + +export function deleteSftpProfile(profileId) { + return new Promise((resolve) => { + sftp.deleteProfile(profileId, resolve, resolve); + }); +} + +/** + * Moves legacy credential-bearing SFTP URLs into encrypted native profiles. + * A failed individual migration is left untouched so users are never locked out. + */ +export async function migrateLegacySftpProfiles() { + const profileCache = new Map(); + const copiedKeys = new Set(); + + for (const storageKey of MIGRATED_STORAGE_KEYS) { + const raw = localStorage.getItem(storageKey); + if (!raw) continue; + let value; + try { + value = JSON.parse(raw); + } catch { + continue; + } + + const migrated = await migrateValue(value); + if (migrated.changed) { + localStorage.setItem(storageKey, JSON.stringify(migrated.value)); + } + } + + for (const keyFile of copiedKeys) { + if (!keyFile.startsWith(globalThis.DATA_STORAGE || "\0")) continue; + try { + await fsOperation(keyFile).delete(); + } catch (error) { + console.warn("Could not remove migrated SFTP key copy", error); + } + } + + async function migrateValue(value) { + if (typeof value === "string") return migrateUrl(value); + if (Array.isArray(value)) { + let changed = false; + const next = []; + for (const item of value) { + const migrated = await migrateValue(item); + changed ||= migrated.changed; + next.push(migrated.value); + } + return { value: next, changed }; + } + if (value && typeof value === "object") { + let changed = false; + const next = {}; + for (const [key, item] of Object.entries(value)) { + const migrated = await migrateValue(item); + changed ||= migrated.changed; + next[key] = migrated.value; + } + return { value: next, changed }; + } + return { value, changed: false }; + } + + async function migrateUrl(value) { + if (!/^sftp:/.test(value) || getSftpProfileId(value)) { + return { value, changed: false }; + } + + try { + const { username, password, hostname, pathname, port, query } = + Url.decodeUrl(value); + if (!hostname || !username) return { value, changed: false }; + const keyFile = normalizeLegacyValue(query?.keyFile); + const passPhrase = normalizeLegacyValue(query?.passPhrase); + const authType = keyFile ? "key" : "password"; + const signature = JSON.stringify({ + hostname, + port: port || 22, + username, + password: password || "", + keyFile, + passPhrase, + }); + + let profileId = profileCache.get(signature); + if (!profileId) { + profileId = await saveSftpProfile({ + hostname, + port: port || 22, + username, + authType, + password: password || "", + keyFile, + passPhrase, + }); + profileCache.set(signature, profileId); + if (keyFile) copiedKeys.add(keyFile); + } + return { + value: createSftpProfileUrl(profileId, pathname || "/"), + changed: true, + }; + } catch (error) { + console.warn("Could not migrate legacy SFTP URL", error); + return { value, changed: false }; + } + } +} + +function normalizeLegacyValue(value) { + return value && value !== "undefined" && value !== "null" ? value : ""; +} diff --git a/src/lib/sshHostKey.js b/src/lib/sshHostKey.js new file mode 100644 index 000000000..33707f6e6 --- /dev/null +++ b/src/lib/sshHostKey.js @@ -0,0 +1,86 @@ +import alert from "dialogs/alert"; +import confirm from "dialogs/confirm"; + +/** + * Handles structured host-key failures returned by the native SSH client. + * @param {unknown} error + * @returns {Promise} true when the host was trusted and the caller should retry + */ +export async function resolveHostKeyError(error) { + const details = parseHostKeyError(error); + if (!details) return false; + + if (details.code === "HOST_KEY_CHANGED") { + alert( + strings["ssh host key changed"] || "SSH Host Key Changed", + [ + `The identity of ${details.host} has changed.`, + `Expected: ${details.expectedFingerprint || "unknown"}`, + `Received: ${details.fingerprint}`, + "The connection was blocked. Verify the server before changing its trusted key.", + ].join("\n\n"), + ); + throw nonRetryableError( + `SSH host key changed for ${details.host}`, + details.code, + true, + ); + } + + if (details.code !== "HOST_KEY_UNKNOWN") return false; + + const trusted = await confirm( + strings["unknown ssh host"] || "Unknown SSH Host", + [ + `This is the first connection to ${details.host}.`, + `Key type: ${details.algorithm}`, + `Fingerprint: ${details.fingerprint}`, + "Trust this host and continue?", + ].join("\n\n"), + ); + if (!trusted) { + throw nonRetryableError( + `SSH host ${details.host} was not trusted`, + details.code, + ); + } + + await new Promise((resolve, reject) => { + sftp.trustHost( + details.host, + details.algorithm, + details.fingerprint, + details.publicKey, + resolve, + reject, + ); + }); + return true; +} + +function parseHostKeyError(error) { + let value = error?.error ?? error; + if (typeof value === "string") { + try { + value = JSON.parse(value); + } catch { + return null; + } + } + if ( + value && + typeof value === "object" && + ["HOST_KEY_UNKNOWN", "HOST_KEY_CHANGED"].includes(value.code) + ) { + return value; + } + return null; +} + +function nonRetryableError(message, code, reported = false) { + const error = new Error(message); + error.code = code; + error.nonRetryable = true; + error.reported = reported; + return error; +} diff --git a/src/main.js b/src/main.js index c29eee871..071757803 100644 --- a/src/main.js +++ b/src/main.js @@ -53,6 +53,7 @@ import openFolder, { addedFolder } from "lib/openFolder"; import { registerPrettierFormatter } from "lib/registerPrettierFormatter"; import restoreFiles from "lib/restoreFiles"; import settings from "lib/settings"; +import { migrateLegacySftpProfiles } from "lib/sftpProfiles"; import startAd, { BANNER_SUPPRESSION_REASON, setBannerSuppressed, @@ -266,6 +267,9 @@ async function onDeviceReady() { acode.setLoadingMessage("Loading language..."); await lang.set(settings.value.lang); + acode.setLoadingMessage("Securing SFTP profiles..."); + await migrateLegacySftpProfiles(); + if (settings.value.developerMode) { try { const devTools = (await import("lib/devTools")).default; diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index 51644fd72..0f374cbb1 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -21,6 +21,7 @@ import projects from "lib/projects"; import recents from "lib/recents"; import remoteStorage from "lib/remoteStorage"; import appSettings from "lib/settings"; +import { deleteSftpProfile, getSftpProfileId } from "lib/sftpProfiles"; import mimeTypes from "mime-types"; import mustache from "mustache"; import filesSettings from "settings/filesSettings"; @@ -1254,8 +1255,9 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { (removedStorage.storageType === "sftp" || removedStorage.type === "sftp") ) { + const profileId = getSftpProfileId(storageUrl); const { username, hostname, port = 22 } = Url.decodeUrl(storageUrl); - const connectionID = `${username}@${hostname}:${port}`; + const connectionID = profileId || `${username}@${hostname}:${port}`; await new Promise((resolve) => { sftp.isConnected((activeConnectionID) => { if (activeConnectionID !== connectionID) { @@ -1265,13 +1267,21 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { sftp.close(resolve, resolve); }, resolve); }); + const profileStillUsed = storageList.some( + (storage) => + storage.uuid !== uuid && + getSftpProfileId(storage.url) === profileId, + ); + if (profileId && !profileStillUsed) { + await deleteSftpProfile(profileId); + } } storageList = storageList.filter((storage) => { if (storage.uuid !== uuid) { return true; } - if (storage.url) { + if (storage.url && !getSftpProfileId(storage.url)) { const parsedUrl = URLParse(storage.url, true); const keyFile = decodeURIComponent( parsedUrl.query["keyFile"] || "", diff --git a/src/plugins/sftp/index.d.ts b/src/plugins/sftp/index.d.ts index 02f58951f..96913435f 100644 --- a/src/plugins/sftp/index.d.ts +++ b/src/plugins/sftp/index.d.ts @@ -24,6 +24,13 @@ interface ShellEvent { exitCode?: number; message?: string; } + +interface SftpProfileInfo { + hostname: string; + port: number; + username: string; + authType: "password" | "key"; +} interface Sftp { /** @@ -54,7 +61,12 @@ interface Sftp { * @param onSuccess Callback function on success returns url of copied file/dir * @param onFail Callback function on error returns error object */ - connectUsingKeyFile(host: String, port: Number, username: String, keyFile: String, passphrase: String, onSuccess: () => void, onFail: (err: any) => void): void; + connectUsingKeyFile(host: String, port: Number, username: String, keyFile: String, passphrase: String, onSuccess: () => void, onFail: (err: any) => void): void; + connectUsingProfile(profileId: String, onSuccess: () => void, onFail: (err: any) => void): void; + saveProfile(profileId: String | null, host: String, port: Number, username: String, authType: String, password: String, keyFile: String, passphrase: String, onSuccess: (profileId: String) => void, onFail: (err: any) => void): void; + getProfileInfo(profileId: String, onSuccess: (profile: SftpProfileInfo) => void, onFail: (err: any) => void): void; + deleteProfile(profileId: String, onSuccess: () => void, onFail: (err: any) => void): void; + trustHost(host: String, algorithm: String, fingerprint: String, publicKey: String, onSuccess: () => void, onFail: (err: any) => void): void; /** * Gets file from the server. @@ -89,6 +101,7 @@ interface Sftp { isConnected(onSuccess: (connectionId: String) => void, onFail: (err: any) => void): void; openShellUsingPassword(host: String, port: Number, username: String, password: String, cols: Number, rows: Number, onEvent: (event: ShellEvent) => void, onFail: (err: any) => void): void; openShellUsingKeyFile(host: String, port: Number, username: String, keyFile: String, passphrase: String, cols: Number, rows: Number, onEvent: (event: ShellEvent) => void, onFail: (err: any) => void): void; + openShellUsingProfile(profileId: String, cols: Number, rows: Number, onEvent: (event: ShellEvent) => void, onFail: (err: any) => void): void; writeShell(sessionId: String, data: String, onSuccess: () => void, onFail: (err: any) => void): void; resizeShell(sessionId: String, cols: Number, rows: Number, onSuccess: () => void, onFail: (err: any) => void): void; closeShell(sessionId: String, onSuccess: () => void, onFail: (err: any) => void): void; diff --git a/src/plugins/sftp/plugin.xml b/src/plugins/sftp/plugin.xml index ed1e75ccc..527d729b3 100644 --- a/src/plugins/sftp/plugin.xml +++ b/src/plugins/sftp/plugin.xml @@ -21,7 +21,8 @@ - + + diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java index c34716d9b..10091ac60 100644 --- a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java +++ b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java @@ -15,6 +15,7 @@ import com.sshtools.client.sftp.SftpClient.SftpClientBuilder; import com.sshtools.client.sftp.SftpFile; import com.sshtools.client.sftp.TransferCancelledException; +import com.sshtools.common.knownhosts.HostKeyVerification; import com.sshtools.common.permissions.PermissionDeniedException; import com.sshtools.common.policy.FileSystemPolicy; import com.sshtools.common.publickey.InvalidPassphraseException; @@ -26,8 +27,11 @@ import com.sshtools.common.ssh.ChannelEventListener; import com.sshtools.common.ssh.RequestFuture; import com.sshtools.common.ssh.components.SshKeyPair; +import com.sshtools.common.ssh.components.SshPublicKey; import com.sshtools.common.ssh.components.jce.JCEProvider; import com.sshtools.common.util.UnsignedInteger32; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -74,6 +78,76 @@ public class Sftp extends CordovaPlugin { private Context context; private Activity activity; private String connectionID; + private SftpSecurityStore securityStore; + + private final class ConnectionSecurity { + + private volatile JSONObject failure; + + private void configure(SshClientContext sshContext) { + configureClient(sshContext); + sshContext.setHostKeyVerification( + new HostKeyVerification() { + @Override + public boolean verifyHost(String host, SshPublicKey publicKey) + throws SshException { + try { + String fingerprint = publicKey.getFingerprint(); + String algorithm = publicKey.getAlgorithm(); + String encodedKey = Base64.encodeToString( + publicKey.getEncoded(), + Base64.NO_WRAP + ); + JSONObject trusted = securityStore.getKnownHost(host); + if (trusted == null) { + failure = hostKeyFailure( + "HOST_KEY_UNKNOWN", + host, + algorithm, + fingerprint, + encodedKey, + null + ); + return false; + } + + String expected = trusted.optString("fingerprint"); + String expectedKey = trusted.optString("publicKey"); + if ( + (!expectedKey.isEmpty() && !encodedKey.equals(expectedKey)) || + (expectedKey.isEmpty() && !fingerprint.equals(expected)) + ) { + failure = hostKeyFailure( + "HOST_KEY_CHANGED", + host, + algorithm, + fingerprint, + encodedKey, + expected + ); + return false; + } + return true; + } catch (JSONException e) { + throw new SshException( + "Could not verify the SSH host key", + SshException.HOST_KEY_ERROR, + e + ); + } + } + } + ); + } + + private boolean report(CallbackContext callback) { + JSONObject error = failure; + failure = null; + if (error == null) return false; + callback.error(error); + return true; + } + } private final class RemoteShell { @@ -172,6 +246,7 @@ public void initialize(CordovaInterface cordova, CordovaWebView webView) { super.initialize(cordova, webView); context = cordova.getContext(); activity = cordova.getActivity(); + securityStore = new SftpSecurityStore(context); System.setProperty("maverick.log.nothread", "true"); configureCryptoProvider(); } @@ -221,11 +296,12 @@ private void closeConnectionQuietly() { private boolean establishConnection( SshClientBuilder builder, - String newConnectionID + String newConnectionID, + ConnectionSecurity security ) throws IOException, SshException, PermissionDeniedException { synchronized (connectionLock) { closeConnectionQuietly(); - ssh = builder.onConfigure(Sftp::configureClient).build(); + ssh = builder.onConfigure(security::configure).build(); if (!ssh.isConnected()) { closeConnectionQuietly(); return false; @@ -258,6 +334,26 @@ private static void sendShellEvent( callback.sendPluginResult(result); } + private static JSONObject hostKeyFailure( + String code, + String host, + String algorithm, + String fingerprint, + String publicKey, + String expectedFingerprint + ) throws JSONException { + JSONObject error = new JSONObject(); + error.put("code", code); + error.put("host", host); + error.put("algorithm", algorithm); + error.put("fingerprint", fingerprint); + error.put("publicKey", publicKey); + if (expectedFingerprint != null) { + error.put("expectedFingerprint", expectedFingerprint); + } + return error; + } + private void closeRemoteShells() { for (RemoteShell shell : remoteShells.values()) { shell.finish(null, null); @@ -281,14 +377,15 @@ private SshClient buildShellPasswordClient( String host, int port, String username, - String password + String password, + ConnectionSecurity security ) throws IOException, SshException, PermissionDeniedException { return SshClientBuilder.create() .withHostname(host) .withPort(port) .withUsername(username) .withPassword(password) - .onConfigure(Sftp::configureClient) + .onConfigure(security::configure) .build(); } @@ -297,7 +394,8 @@ private SshClient buildShellKeyClient( int port, String username, String keyFile, - String passphrase + String passphrase, + ConnectionSecurity security ) throws IOException, SshException, PermissionDeniedException, InvalidPassphraseException { DocumentFile file = DocumentFile.fromSingleUri(context, Uri.parse(keyFile)); @@ -318,10 +416,53 @@ private SshClient buildShellKeyClient( .withPort(port) .withUsername(username) .withIdentities(keyPair) - .onConfigure(Sftp::configureClient) + .onConfigure(security::configure) .build(); } + private SshClientBuilder buildProfileBuilder(JSONObject profile) + throws IOException, InvalidPassphraseException, JSONException { + SshClientBuilder builder = SshClientBuilder.create() + .withHostname(profile.getString("hostname")) + .withPort(profile.optInt("port", 22)) + .withUsername(profile.getString("username")); + + if ("key".equals(profile.optString("authType"))) { + byte[] privateKey = Base64.decode( + profile.getString("privateKey"), + Base64.NO_WRAP + ); + SshKeyPair keyPair = SshKeyUtils.getPrivateKey( + new ByteArrayInputStream(privateKey), + profile.optString("passphrase") + ); + builder.withIdentities(keyPair); + } else { + builder.withPassword(profile.optString("password")); + } + return builder; + } + + private byte[] readUri(String uriString) throws IOException { + if (uriString == null || uriString.isEmpty()) { + throw new IOException("Private key file is required"); + } + try ( + InputStream input = context + .getContentResolver() + .openInputStream(Uri.parse(uriString)); + ByteArrayOutputStream output = new ByteArrayOutputStream() + ) { + if (input == null) throw new IOException("Could not open key file"); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } + } + private void openRemoteShell( SshClient shellClient, int columns, @@ -404,6 +545,7 @@ public void openShellUsingPassword(JSONArray args, CallbackContext callback) { .execute( new Runnable() { public void run() { + ConnectionSecurity security = new ConnectionSecurity(); try { String host = args.optString(0); int port = args.optInt(1, 22); @@ -416,13 +558,15 @@ public void run() { host, port, username, - password + password, + security ), columns, rows, callback ); } catch (Exception e) { + if (security.report(callback)) return; callback.error("Failed to open SSH shell: " + errMessage(e)); Log.e(TAG, "Failed to open SSH shell", e); } catch (OutOfMemoryError e) { @@ -440,6 +584,7 @@ public void openShellUsingKeyFile(JSONArray args, CallbackContext callback) { .execute( new Runnable() { public void run() { + ConnectionSecurity security = new ConnectionSecurity(); try { String host = args.optString(0); int port = args.optInt(1, 22); @@ -454,7 +599,8 @@ public void run() { port, username, keyFile, - passphrase + passphrase, + security ), columns, rows, @@ -463,6 +609,7 @@ public void run() { } catch (InvalidPassphraseException e) { callback.error("Invalid passphrase for key file"); } catch (Exception e) { + if (security.report(callback)) return; callback.error("Failed to open SSH shell: " + errMessage(e)); Log.e(TAG, "Failed to open SSH shell", e); } catch (OutOfMemoryError e) { @@ -474,6 +621,104 @@ public void run() { ); } + public void openShellUsingProfile(JSONArray args, CallbackContext callback) { + cordova + .getThreadPool() + .execute( + new Runnable() { + public void run() { + ConnectionSecurity security = new ConnectionSecurity(); + try { + String profileID = args.optString(0); + int columns = Math.max(1, args.optInt(1, 80)); + int rows = Math.max(1, args.optInt(2, 24)); + JSONObject profile = securityStore.getProfile(profileID); + openRemoteShell( + buildProfileBuilder(profile) + .onConfigure(security::configure) + .build(), + columns, + rows, + callback + ); + } catch (InvalidPassphraseException e) { + callback.error("Invalid passphrase for stored key"); + } catch (Exception e) { + if (security.report(callback)) return; + callback.error("Failed to open SSH shell: " + errMessage(e)); + Log.e(TAG, "Failed to open SSH shell from profile", e); + } + } + } + ); + } + + public void saveProfile(JSONArray args, CallbackContext callback) { + cordova + .getThreadPool() + .execute( + new Runnable() { + public void run() { + try { + String requestedID = args.optString(0, null); + String authType = args.optString(4, "password"); + JSONObject profile = new JSONObject(); + profile.put("hostname", args.getString(1)); + profile.put("port", args.optInt(2, 22)); + profile.put("username", args.getString(3)); + profile.put("authType", authType); + if ("key".equals(authType)) { + profile.put( + "privateKey", + Base64.encodeToString(readUri(args.optString(6)), Base64.NO_WRAP) + ); + profile.put("passphrase", args.optString(7)); + } else { + profile.put("password", args.optString(5)); + } + callback.success(securityStore.saveProfile(requestedID, profile)); + } catch (Exception e) { + callback.error("Could not securely save SFTP profile: " + errMessage(e)); + Log.e(TAG, "Could not save SFTP profile", e); + } + } + } + ); + } + + public void getProfileInfo(JSONArray args, CallbackContext callback) { + try { + JSONObject profile = securityStore.getProfile(args.optString(0)); + JSONObject info = new JSONObject(); + info.put("hostname", profile.getString("hostname")); + info.put("port", profile.optInt("port", 22)); + info.put("username", profile.getString("username")); + info.put("authType", profile.optString("authType", "password")); + callback.success(info); + } catch (Exception e) { + callback.error("Could not load SFTP profile: " + errMessage(e)); + } + } + + public void deleteProfile(JSONArray args, CallbackContext callback) { + securityStore.deleteProfile(args.optString(0)); + callback.success(); + } + + public void trustHost(JSONArray args, CallbackContext callback) { + try { + securityStore.trustHost( + args.getString(0), + args.getString(1), + args.getString(2), + args.getString(3) + ); + callback.success(); + } catch (Exception e) { + callback.error("Could not save trusted SSH host: " + errMessage(e)); + } + } + public void writeShell(JSONArray args, CallbackContext callback) { RemoteShell shell = remoteShells.get(args.optString(0)); if (shell == null || shell.finished.get()) { @@ -538,6 +783,7 @@ public void connectUsingPassword(JSONArray args, CallbackContext callback) { .execute( new Runnable() { public void run() { + ConnectionSecurity security = new ConnectionSecurity(); try { String host = args.optString(0); int port = args.optInt(1); @@ -555,13 +801,18 @@ public void run() { .withPassword(password); if ( - establishConnection(builder, username + "@" + host + ":" + port) + establishConnection( + builder, + username + "@" + host + ":" + port, + security + ) ) { callback.success(); Log.d(TAG, "Connected successfully to " + connectionID); return; } + if (security.report(callback)) return; callback.error("Failed to establish SSH connection"); } catch (UnresolvedAddressException e) { callback.error("Cannot resolve host address"); @@ -570,12 +821,14 @@ public void run() { callback.error("Authentication failed: " + e.getMessage()); Log.e(TAG, "Authentication failed", e); } catch (SshException e) { + if (security.report(callback)) return; callback.error("SSH error: " + errMessage(e)); Log.e(TAG, "SSH error", e); } catch (IOException e) { callback.error("I/O error: " + errMessage(e)); Log.e(TAG, "I/O error", e); } catch (Exception e) { + if (security.report(callback)) return; callback.error("Unexpected error: " + errMessage(e)); Log.e(TAG, "Unexpected error", e); } catch (OutOfMemoryError e) { @@ -596,6 +849,7 @@ public void connectUsingKeyFile(JSONArray args, CallbackContext callback) { .execute( new Runnable() { public void run() { + ConnectionSecurity security = new ConnectionSecurity(); try { String host = args.optString(0); int port = args.optInt(1); @@ -606,6 +860,10 @@ public void run() { context, Uri.parse(keyFile) ); + if (file == null) { + callback.error("Could not open key file"); + return; + } Uri uri = file.getUri(); ContentResolver contentResolver = context.getContentResolver(); @@ -635,13 +893,18 @@ public void run() { .withIdentities(keyPair); if ( - establishConnection(builder, username + "@" + host + ":" + port) + establishConnection( + builder, + username + "@" + host + ":" + port, + security + ) ) { callback.success(); Log.d(TAG, "Connected successfully to " + connectionID); return; } + if (security.report(callback)) return; callback.error("Failed to establish SSH connection"); } catch (UnresolvedAddressException e) { callback.error("Cannot resolve host address"); @@ -650,6 +913,7 @@ public void run() { callback.error("Authentication failed: " + e.getMessage()); Log.e(TAG, "Authentication failed", e); } catch (SshException e) { + if (security.report(callback)) return; callback.error("SSH error: " + errMessage(e)); Log.e(TAG, "SSH error", e); } catch (IOException e) { @@ -659,6 +923,7 @@ public void run() { callback.error("Security error: " + errMessage(e)); Log.e(TAG, "Security error", e); } catch (Exception e) { + if (security.report(callback)) return; callback.error("Unexpected error: " + errMessage(e)); Log.e(TAG, "Unexpected error", e); } catch (OutOfMemoryError e) { @@ -673,6 +938,45 @@ public void run() { ); } + public void connectUsingProfile(JSONArray args, CallbackContext callback) { + cordova + .getThreadPool() + .execute( + new Runnable() { + public void run() { + ConnectionSecurity security = new ConnectionSecurity(); + String profileID = args.optString(0); + try { + JSONObject profile = securityStore.getProfile(profileID); + if ( + establishConnection( + buildProfileBuilder(profile), + profileID, + security + ) + ) { + callback.success(); + return; + } + if (security.report(callback)) return; + callback.error("Failed to establish SSH connection"); + } catch (InvalidPassphraseException e) { + callback.error("Invalid passphrase for stored key"); + } catch (Exception e) { + if (security.report(callback)) return; + callback.error("Failed to connect SFTP profile: " + errMessage(e)); + Log.e(TAG, "Failed to connect SFTP profile", e); + } catch (OutOfMemoryError e) { + synchronized (connectionLock) { + closeConnectionQuietly(); + } + callback.error("Not enough memory to initialize SFTP"); + } + } + } + ); + } + public void exec(JSONArray args, CallbackContext callback) { cordova .getThreadPool() diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/SftpSecurityStore.java b/src/plugins/sftp/src/com/foxdebug/sftp/SftpSecurityStore.java new file mode 100644 index 000000000..682e91aee --- /dev/null +++ b/src/plugins/sftp/src/com/foxdebug/sftp/SftpSecurityStore.java @@ -0,0 +1,160 @@ +package com.foxdebug.sftp; + +import android.content.Context; +import android.content.SharedPreferences; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.Base64; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.UUID; +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import org.json.JSONException; +import org.json.JSONObject; + +final class SftpSecurityStore { + + static final String PROFILE_PREFIX = "profile-"; + private static final String KEYSTORE = "AndroidKeyStore"; + private static final String KEY_ALIAS = "acode.sftp.profile.key.v1"; + private static final String PROFILE_PREFS = "acode_sftp_profiles_v1"; + private static final String HOST_PREFS = "acode_ssh_known_hosts_v1"; + private static final String CIPHER = "AES/GCM/NoPadding"; + private static final int GCM_TAG_BITS = 128; + + private final SharedPreferences profiles; + private final SharedPreferences knownHosts; + + SftpSecurityStore(Context context) { + profiles = context.getSharedPreferences(PROFILE_PREFS, Context.MODE_PRIVATE); + knownHosts = context.getSharedPreferences(HOST_PREFS, Context.MODE_PRIVATE); + } + + static boolean isProfileID(String value) { + return value != null && value.startsWith(PROFILE_PREFIX); + } + + synchronized String saveProfile(String requestedID, JSONObject profile) + throws GeneralSecurityException, JSONException { + String profileID = isProfileID(requestedID) + ? requestedID + : PROFILE_PREFIX + UUID.randomUUID(); + if ( + !profiles + .edit() + .putString(profileID, encrypt(profileID, profile.toString())) + .commit() + ) { + throw new GeneralSecurityException("Could not persist SFTP profile"); + } + return profileID; + } + + synchronized JSONObject getProfile(String profileID) + throws GeneralSecurityException, JSONException { + if (!isProfileID(profileID)) throw new GeneralSecurityException( + "Invalid SFTP profile ID" + ); + String encrypted = profiles.getString(profileID, null); + if (encrypted == null) throw new GeneralSecurityException( + "SFTP profile was not found" + ); + return new JSONObject(decrypt(profileID, encrypted)); + } + + synchronized void deleteProfile(String profileID) { + if (isProfileID(profileID)) profiles.edit().remove(profileID).commit(); + } + + synchronized JSONObject getKnownHost(String host) throws JSONException { + String value = knownHosts.getString(host, null); + return value == null ? null : new JSONObject(value); + } + + synchronized void trustHost( + String host, + String algorithm, + String fingerprint, + String publicKey + ) throws JSONException { + JSONObject record = new JSONObject(); + record.put("algorithm", algorithm); + record.put("fingerprint", fingerprint); + record.put("publicKey", publicKey); + if (!knownHosts.edit().putString(host, record.toString()).commit()) { + throw new JSONException("Could not persist trusted SSH host"); + } + } + + private String encrypt(String profileID, String plaintext) + throws GeneralSecurityException, JSONException { + Cipher cipher = Cipher.getInstance(CIPHER); + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()); + cipher.updateAAD(profileID.getBytes(StandardCharsets.UTF_8)); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] iv = cipher.getIV(); + ByteBuffer payload = ByteBuffer.allocate(1 + iv.length + encrypted.length); + payload.put((byte) iv.length); + payload.put(iv); + payload.put(encrypted); + return Base64.encodeToString(payload.array(), Base64.NO_WRAP); + } + + private String decrypt(String profileID, String encoded) + throws GeneralSecurityException { + byte[] payload = Base64.decode(encoded, Base64.NO_WRAP); + ByteBuffer buffer = ByteBuffer.wrap(payload); + int ivLength = buffer.get() & 0xff; + if (ivLength < 12 || ivLength > 16 || buffer.remaining() <= ivLength) { + throw new GeneralSecurityException("Invalid encrypted SFTP profile"); + } + byte[] iv = new byte[ivLength]; + byte[] encrypted = new byte[buffer.remaining() - ivLength]; + buffer.get(iv); + buffer.get(encrypted); + + Cipher cipher = Cipher.getInstance(CIPHER); + cipher.init( + Cipher.DECRYPT_MODE, + getOrCreateKey(), + new GCMParameterSpec(GCM_TAG_BITS, iv) + ); + cipher.updateAAD(profileID.getBytes(StandardCharsets.UTF_8)); + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } + + private SecretKey getOrCreateKey() throws GeneralSecurityException { + KeyStore keyStore = KeyStore.getInstance(KEYSTORE); + try { + keyStore.load(null); + } catch (java.io.IOException e) { + throw new GeneralSecurityException("Could not load Android Keystore", e); + } + + if (keyStore.containsAlias(KEY_ALIAS)) { + return (SecretKey) keyStore.getKey(KEY_ALIAS, null); + } + + KeyGenerator generator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + KEYSTORE + ); + generator.init( + new KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + .build() + ); + return generator.generateKey(); + } +} diff --git a/src/plugins/sftp/www/sftp.js b/src/plugins/sftp/www/sftp.js index 657be7dab..8e1c88643 100644 --- a/src/plugins/sftp/www/sftp.js +++ b/src/plugins/sftp/www/sftp.js @@ -10,14 +10,29 @@ module.exports = { port = Number.parseInt(port); cordova.exec(onSuccess, onFail, 'Sftp', 'connectUsingPassword', [host, port, username, password]); }, - connectUsingKeyFile: function (host, port, username, keyFile, passphrase, onSuccess, onFail) { + connectUsingKeyFile: function (host, port, username, keyFile, passphrase, onSuccess, onFail) { if (typeof port != 'number') { throw new Error('Port must be number'); } port = Number.parseInt(port); - cordova.exec(onSuccess, onFail, 'Sftp', 'connectUsingKeyFile', [host, port, username, keyFile, passphrase]); - }, + cordova.exec(onSuccess, onFail, 'Sftp', 'connectUsingKeyFile', [host, port, username, keyFile, passphrase]); + }, + connectUsingProfile: function (profileId, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'connectUsingProfile', [profileId]); + }, + saveProfile: function (profileId, host, port, username, authType, password, keyFile, passphrase, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'saveProfile', [profileId, host, port, username, authType, password, keyFile, passphrase]); + }, + getProfileInfo: function (profileId, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'getProfileInfo', [profileId]); + }, + deleteProfile: function (profileId, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'deleteProfile', [profileId]); + }, + trustHost: function (host, algorithm, fingerprint, publicKey, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'trustHost', [host, algorithm, fingerprint, publicKey]); + }, getFile: function (filename, localFilename, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'Sftp', 'getFile', [filename, localFilename]); }, @@ -57,6 +72,9 @@ module.exports = { openShellUsingKeyFile: function (host, port, username, keyFile, passphrase, cols, rows, onEvent, onFail) { cordova.exec(onEvent, onFail, 'Sftp', 'openShellUsingKeyFile', [host, port, username, keyFile, passphrase, cols, rows]); }, + openShellUsingProfile: function (profileId, cols, rows, onEvent, onFail) { + cordova.exec(onEvent, onFail, 'Sftp', 'openShellUsingProfile', [profileId, cols, rows]); + }, writeShell: function (sessionId, data, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'Sftp', 'writeShell', [sessionId, data]); }, diff --git a/tests/unit/sftpProfiles.test.js b/tests/unit/sftpProfiles.test.js new file mode 100644 index 000000000..b9b9813fa --- /dev/null +++ b/tests/unit/sftpProfiles.test.js @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { deleteMock } = vi.hoisted(() => ({ deleteMock: vi.fn() })); + +vi.mock("fileSystem", () => ({ + default: () => ({ delete: deleteMock }), +})); + +import { + createSftpProfileUrl, + getSftpProfileId, + migrateLegacySftpProfiles, +} from "lib/sftpProfiles"; + +describe("SFTP secure profiles", () => { + beforeEach(() => { + const values = new Map(); + globalThis.localStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + }; + globalThis.DATA_STORAGE = "file:///data/"; + deleteMock.mockReset(); + }); + + it("creates and recognizes opaque profile URLs", () => { + const url = createSftpProfileUrl("profile-123", "/project/file.js"); + expect(url).toBe("sftp://profile-123/project/file.js"); + expect(getSftpProfileId(url)).toBe("profile-123"); + expect(getSftpProfileId("sftp://user:secret@example.com/project")).toBeNull(); + }); + + it("migrates repeated credential URLs once and removes credentials", async () => { + const saveProfile = vi.fn((...args) => { + const onSuccess = args.at(-2); + onSuccess("profile-abcd"); + }); + globalThis.sftp = { saveProfile }; + const root = "sftp://user:p%40ss@example.com:2222/"; + const file = "sftp://user:p%40ss@example.com:2222/project/app.js"; + localStorage.setItem( + "storageList", + JSON.stringify([{ storageType: "sftp", url: root }]), + ); + localStorage.setItem("recentFiles", JSON.stringify([file])); + + await migrateLegacySftpProfiles(); + + expect(saveProfile).toHaveBeenCalledTimes(1); + expect(JSON.parse(localStorage.getItem("storageList"))[0].url).toBe( + "sftp://profile-abcd/", + ); + expect(JSON.parse(localStorage.getItem("recentFiles"))[0]).toBe( + "sftp://profile-abcd/project/app.js", + ); + expect(localStorage.getItem("storageList")).not.toContain("p%40ss"); + }); + + it("keeps an unmigratable legacy URL instead of locking the user out", async () => { + globalThis.sftp = { + saveProfile: (...args) => args.at(-1)("Keystore unavailable"), + }; + const legacy = "sftp://user:secret@example.com/"; + localStorage.setItem( + "storageList", + JSON.stringify([{ storageType: "sftp", url: legacy }]), + ); + + await migrateLegacySftpProfiles(); + + expect(JSON.parse(localStorage.getItem("storageList"))[0].url).toBe(legacy); + }); +});