Skip to content

fix: encrypt saved FTP/SFTP credentials - #2566

Open
MYounas126 wants to merge 5 commits into
Acode-Foundation:mainfrom
MYounas126:fix/encrypt-remote-credentials
Open

fix: encrypt saved FTP/SFTP credentials#2566
MYounas126 wants to merge 5 commits into
Acode-Foundation:mainfrom
MYounas126:fix/encrypt-remote-credentials

Conversation

@MYounas126

Copy link
Copy Markdown

Fixes #2561.

Saved FTP/SFTP servers are kept in localStorage.storageList, and the WebView writes localStorage to disk in plaintext — so the server passwords end up sitting unencrypted in app_webview/.../Local Storage/leveldb.

This PR moves that list into a native encrypted store (AES256-GCM via AndroidX Security-Crypto), which is the same mechanism the auth plugin already uses for the account token.

I started out planning to just pull the password out of the stored URL and keep it somewhere separate, but that doesn't work here: Url.parse(url).url keeps the password, and helpers.getVirtualPath matches active file URIs against that full URL to rewrite remote paths, so dropping the password breaks path display. So instead I left the in-memory data exactly as it was (the URL still has its credentials in memory, which is fine) and only changed where it's persisted — plaintext localStorage becomes an encrypted native store. That keeps the diff small and every existing reader behaves the same.

What's in here:

  • A small system.secureSet / secureGet / secureRemove bridge (SecureStore.java, backed by EncryptedSharedPreferences).
  • src/lib/secureStorageList.js — an in-memory cache loaded once at startup from the encrypted store, plus a one-time migration of any existing plaintext localStorage.storageList. The plaintext copy is only deleted after the encrypted write succeeds, so nobody loses their saved servers if something goes wrong mid-migration.
  • The read/write sites in Uri.js, helpers.js, externalFs.js and fileBrowser.js now go through that module instead of touching localStorage.storageList directly.
  • onDeviceReady awaits secureStorageList.hydrate() before anything reads the list.

Existing users' saved servers migrate automatically on the next launch — no re-entry.

I don't have the Cordova/Android build set up on my machine yet, so I haven't been able to run this on a device. Could you trigger a preview build, or tell me how you'd prefer it verified? The main things to check are: add an SFTP/FTP server, restart the app, and confirm it reconnects without asking for the password again — and that the credentials no longer appear in the localStorage leveldb.

@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR moves saved FTP/SFTP secrets from plaintext URLs into Android encrypted preferences.

  • Adds an Android secure-storage bridge backed by EncryptedSharedPreferences.
  • Migrates legacy URL-embedded passwords only after a synchronous encrypted write succeeds.
  • Strips passwords from persisted remote URLs and restores credentials when connecting or editing.
  • Hydrates the in-memory credential cache during application startup.
  • Registers the new Android source and Security Crypto dependency in the Cordova plugin.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the missing Android source registration is present, and migration now removes legacy credentials only after the encrypted write completes successfully.

Important Files Changed

Filename Overview
src/lib/secureCredentials.js Implements encrypted credential caching, legacy migration, URL sanitization, and connection-identity lookups without leaving either previously reported issue unresolved.
src/plugins/system/android/com/foxdebug/system/SecureStore.java Uses synchronous commit() operations so successful callbacks represent completed SharedPreferences writes.
src/plugins/system/android/com/foxdebug/system/System.java Exposes the secure-store bridge and correctly reports failed secure writes to JavaScript.
src/plugins/system/plugin.xml Registers SecureStore.java and the matching AndroidX Security Crypto dependency, resolving the prior Android compilation issue.
src/lib/remoteStorage.js Persists FTP/SFTP secrets separately while retaining credential-free URLs in the saved server list.
src/main.js Hydrates and migrates secure credentials before startup paths can access saved remote servers.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Application startup] --> B[Hydrate encrypted credential cache]
    B --> C{Legacy URL contains password?}
    C -- Yes --> D[Commit credentials to encrypted preferences]
    D --> E{Commit succeeded?}
    E -- Yes --> F[Strip password from persisted URL]
    E -- No --> G[Retain legacy plaintext URL for retry]
    C -- No --> H[Use cached credentials]
    F --> H
    G --> H
    H --> I[Restore secret when connecting or editing]
Loading

Reviews (4): Last reviewed commit: "Merge origin/main into fix/encrypt-remot..." | Re-trigger Greptile

Comment thread src/plugins/system/plugin.xml
Comment thread src/plugins/system/android/com/foxdebug/system/SecureStore.java Outdated
…rets

Addresses Greptile review on Acode-Foundation#2566:
- Add missing <source-file> for SecureStore.java so Cordova copies it into
  the Android build (System.java references it -> was a compile failure).
- SecureStore now uses commit() instead of apply() and reports write failure,
  so the migration only deletes the plaintext copy after the encrypted value
  is durably on disk (prevents credential loss on a crash mid-migration).
@MYounas126

Copy link
Copy Markdown
Author

Thanks for the review! Pushed 57d8c16 addressing both points:

  • Added the missing <source-file> declaration for SecureStore.java in plugin.xml so Cordova compiles it (System.java references it).
  • Switched SecureStore from apply() to commit() and made secure-set report failure, so the migration only removes the plaintext storageList after the encrypted write is durably on disk — no data loss if the process is killed mid-migration.

@UnschooledGamer

This comment was marked as outdated.

Comment thread src/lib/secureStorageList.js Outdated
@UnschooledGamer

Copy link
Copy Markdown
Collaborator

I don't have the Cordova/Android build set up on my machine yet, so I haven't been able to run this on a device. Could you trigger a preview build, or tell me how you'd prefer it verified? The main things to check are: add an SFTP/FTP server, restart the app, and confirm it reconnects without asking for the password again — and that the credentials no longer appear in the localStorage leveldb.

No problem. Changes look safe for a possible preview trigger.

@UnschooledGamer UnschooledGamer added community CI: RUN ON-DEMAND PREVIEW RELEASES Triggers an on-demand preview build for this pull request via CI workflow. labels Aug 2, 2026
@github-actions github-actions Bot removed the CI: RUN ON-DEMAND PREVIEW RELEASES Triggers an on-demand preview build for this pull request via CI workflow. label Aug 2, 2026
@github-actions

This comment has been minimized.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Preview Release for this, has been built.

Click here to view that github actions build

@bajrangCoder bajrangCoder changed the title fix: encrypt saved FTP/SFTP credentials at rest (#2561) fix: encrypt saved FTP/SFTP credentials Aug 2, 2026
Comment thread src/plugins/system/android/com/foxdebug/system/SecureStore.java
…on error

Per review: the plaintext SharedPreferences fallback could report success and
cause the JS migration to delete localStorage, then become unreadable once
EncryptedSharedPreferences recovers (it encrypts lookup keys), leaving the
saved server list empty. Now encryption failure propagates (set/get return
false/null) so the legacy copy is kept and retried next launch — and no
cleartext fallback is ever written.
@MYounas126
MYounas126 requested a review from bajrangCoder August 2, 2026 09:48
@bajrangCoder

This comment was marked as outdated.

@UnschooledGamer

Copy link
Copy Markdown
Collaborator

A side note: this can break any plugins that use storageList from localStorage!

@MYounas126

Copy link
Copy Markdown
Author

Good point, thanks for flagging it. A couple of thoughts:

storageList isn't part of the public acode plugin API (it's not in acode.define(...)), so any plugin reading it is going through localStorage directly — an internal detail rather than a supported surface.

And there's a security angle to that same access: right now any installed plugin can read localStorage.storageList and pull every saved SFTP/FTP password in cleartext. Moving it into the encrypted store removes that, which is really part of the point here — untrusted plugin code shouldn't have free read access to the user's server credentials.

That said, if there are plugins that legitimately need the saved-server list, I'm happy to add a proper accessor to the acode API that returns just the non-secret fields (alias/host/type, no password) so those keep working while credentials stay protected. Do you know of any plugins relying on it, or an API shape you'd prefer?

@bajrangCoder

Copy link
Copy Markdown
Member

Good point, thanks for flagging it. A couple of thoughts:

storageList isn't part of the public acode plugin API (it's not in acode.define(...)), so any plugin reading it is going through localStorage directly — an internal detail rather than a supported surface.

And there's a security angle to that same access: right now any installed plugin can read localStorage.storageList and pull every saved SFTP/FTP password in cleartext. Moving it into the encrypted store removes that, which is really part of the point here — untrusted plugin code shouldn't have free read access to the user's server credentials.

That said, if there are plugins that legitimately need the saved-server list, I'm happy to add a proper accessor to the acode API that returns just the non-secret fields (alias/host/type, no password) so those keep working while credentials stay protected. Do you know of any plugins relying on it, or an API shape you'd prefer?

Yeah, there is no need for a plugin to read.

@UnschooledGamer

Copy link
Copy Markdown
Collaborator

@MYounas126 Well, yeah. That's the usage for these types things comes from edgey cases. Security is first, While I cannot link any plugins code related it as I'm not in a state to do so currently. It might be fine to move forward; As it was just a side note.

@UnschooledGamer

Copy link
Copy Markdown
Collaborator

The legitimate plugin I found other than deprecated ones due to our CM 6 Migration:

https://github.com/overskul/acode-better-filebrowser/blob/a83aaf8b4d50c12b6130141faef04b58fc6e5f81/src/BetterFileBroswer.js#L1-L98

Also, With the proposed implementation the plugins can still access them just like Acode can as long as the Java bridge provides a decoded, decrypted to the Javascript side.

@UnschooledGamer

Copy link
Copy Markdown
Collaborator

Yeah, there is no need for a plugin to read.

@bajrangCoder Well, no. As a Plugin needs know which folders the App has access to, which helps for URI restructuring, addedFolder doesn't provide that completely as far as I know.

@MYounas126

Copy link
Copy Markdown
Author

Thanks, went and looked at better-filebrowser — fair point, it's a real case. It reads and writes storageList but only touches uuid/name/uri (no creds, it's just registering a local folder), so my change would break it since I'm pulling storageList out of localStorage entirely.

And you're right about the bridge as well. secureGet is reachable from any JS in the origin, so a plugin can still read the encrypted list, passwords included, the same way Acode does. So realistically all this PR buys is getting the cleartext copy off disk, which was the original #2561 concern (backups, forensics, other apps reading the webview data dir). It doesn't keep the list away from plugins, and I don't think that's really doable in a single-origin webview anyway.

So maybe a cleaner approach: keep the non-secret list in localStorage so those plugins keep working (uuid/name/uri/type), and only move the passwords into the encrypted store, keyed per entry and put back at connect time. That still fixes the at-rest issue, doesn't break the plugins, and actually keeps passwords out of the plugin-visible list. It's more work than what's here now — I avoided it at first because the password-in-url matching in getVirtualPath gets in the way, but that's fixable by stripping creds off both sides before comparing.

I'm happy to redo it that way, or keep this one as the at-rest fix and do the split as a follow-up. Whatever you two think is best.

@MYounas126

Copy link
Copy Markdown
Author

@UnschooledGamer @bajrangCoder I am waiting for your reply. Once I got your suggestion I will modify my strategy accordingly. Please
Regards.

@UnschooledGamer

Copy link
Copy Markdown
Collaborator

CC: @bajrangCoder , as I'm busy currently & unable to decide that.

@bajrangCoder

Copy link
Copy Markdown
Member

So maybe a cleaner approach: keep the non-secret list in localStorage so those plugins keep working (uuid/name/uri/type), and only move the passwords into the encrypted store, keyed per entry and put back at connect time. That still fixes the at-rest issue, doesn't break the plugins, and actually keeps passwords out of the plugin-visible list. It's more work than what's here now — I avoided it at first because the password-in-url matching in getVirtualPath gets in the way, but that's fixable by stripping creds off both sides before comparing.

Looks fine

@MYounas126

Copy link
Copy Markdown
Author

So maybe a cleaner approach: keep the non-secret list in localStorage so those plugins keep working (uuid/name/uri/type), and only move the passwords into the encrypted store, keyed per entry and put back at connect time. That still fixes the at-rest issue, doesn't break the plugins, and actually keeps passwords out of the plugin-visible list. It's more work than what's here now — I avoided it at first because the password-in-url matching in getVirtualPath gets in the way, but that's fixable by stripping creds off both sides before comparing.

Looks fine

Pushed the reworked version in f7e8fe8 (also merged main in, the branch had drifted).

Rebuilt it the way we discussed, so this is quite different from what was here before:

The saved-server list stays in localStorage. storageList keeps uuid, name, url, type, home exactly as before, so better-filebrowser and anything else reading it keeps working. I reverted the changes that had moved the whole list out.

Only the secrets move. Passwords and SFTP key passphrases go into the encrypted store (system.secureSet/secureGet, backed by EncryptedSharedPreferences), and the saved URL is written without the :password@ part. So localStorage no longer holds credentials, but everything else about the list is untouched.

Secrets go back in at connect time. Ftp.fromUrl and Sftp.fromUrl already call Url.decodeUrl(url), so that turned out to be a clean hook if the URL has no password, they pull it from the store. remoteStorage.edit() does the same so the edit dialog still prefills.

getVirtualPath. As expected this needed the fix you mentioned: it now strips the password off both sides before comparing. Without that, URLs saved before the migration (which still contain user:pass@) stop matching once new ones don't.

Migration. On startup it scans storageList for URLs with inline credentials, moves them into the encrypted store, then rewrites the entry without the password. The localStorage copy is only rewritten after the encrypted write is confirmed on disk if that fails it keeps the old copy and retries next launch, so nobody loses their servers mid-migration.

New file is src/lib/secureCredentials.js; secureStorageList.js is deleted.


One thing I'd like your opinion on: I key the stored secrets by connection identity (protocol//user@host:port) rather than by the entry's uuid. Reason is that fromUrl only ever receives a URL, it has no reference to the storageList entry, so keying by uuid would mean threading extra context through the fs layer.

The trade-off is that two saved entries pointing at the same user@host:port share one secret. That's almost always the same credential anyway, but it does mean editing the password on one entry changes it for the other. If you'd rather have strict per-entry isolation I can rework it to pass the uuid down, just wanted to flag it rather than have you find it in review.

Also worth being upfront: I still don't have the Cordova/Android build running locally, so this is lint-clean but I haven't run it on a device. The two paths worth exercising are adding an SFTP/FTP server then restarting to confirm it reconnects without asking for the password again, and upgrading from a current build to confirm existing servers survive the migration. Happy to get another preview build if that's easiest.

@bajrangCoder

This comment was marked as outdated.

function secureGet(key) {
return new Promise((resolve) => {
try {
window.system.secureGet(key, resolve, () => resolve(null));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window.system.secureGet and secureSet return Promises and do not accept callbacks, so the callbacks passed here are ignored. This outer Promise never resolves during normal operation, leaving hydrate() and therefore onDeviceReady() : stuck indefinitely.

Comment thread src/lib/remoteStorage.js
alias,
name: alias,
url,
url: secureCredentials.stripPassword(url),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stripPassword() only removes the userinfo password; the URL constructed above still contains passPhrase in its query string. updateStorage later serializes this URL into plaintext localStorage, so newly saved key-based SFTP passphrases remain exposed. Build the persisted URL without passPhrase or strip that query parameter as well.

Comment on lines +103 to +116
for (const entry of list) {
const url = entry?.url;
if (!url || !/^[a-z0-9+.-]+:\/\/[^@/]*:[^@/]*@/i.test(url)) continue;

const key = keyFor(url);
if (!key) continue;

const password = decodeURIComponent(
/^[a-z0-9+.-]+:\/\/[^@/]*?:([^@/]*)@/i.exec(url)?.[1] || "",
);
if (!password) continue;

pending[key] = { ...(pending[key] || {}), password };
entry.url = stripPassword(url);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition only admits URLs containing user:password@, and the migration only extracts that password. Legacy key-authenticated SFTP entries commonly have no inline password but do have passPhrase in the query, so they are skipped and remain plaintext.

Comment on lines +167 to +168
async function remove(url) {
await set(url, {});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This removal API has no callers. fileBrowser.removeStorage deletes the list entry and key file without invoking it, while editing a connection identity writes the new key without deleting the old one. As a result, credentials survive deletion and identity changes indefinitely.

@bajrangCoder

bajrangCoder commented Aug 8, 2026

Copy link
Copy Markdown
Member

@MYounas126 I have a different solution which is profiles, you can check #2694

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

Remote server passwords (FTP/SFTP) are stored in cleartext in localStorage

3 participants