Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/fileSystem/externalFs.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import loader from "dialogs/loader";
import secureStorageList from "lib/secureStorageList";
import { decode, encode, getEncodingName } from "utils/encodings";
import helpers from "utils/helpers";
import Url from "utils/Url";
Expand Down Expand Up @@ -105,7 +106,7 @@ const externalFs = {
},

async stats(uri) {
const storageList = helpers.parseJSON(localStorage.getItem("storageList"));
const storageList = secureStorageList.get();

if (Array.isArray(storageList)) {
const storage = storageList.find((s) => s.uri === uri);
Expand Down
91 changes: 91 additions & 0 deletions src/lib/secureStorageList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Secure persistence for the saved remote-storage list (FTP/SFTP servers).
*
* Keeps the list in a native, hardware-backed encrypted store (AES256-GCM, via
* the `system.secure*` bridge) instead of localStorage. The in-memory shape is
* unchanged, so callers behave the same; only the on-disk storage differs.
*
* Access is synchronous via an in-memory cache, hydrated once at startup by
* `hydrate()` — which must be awaited early in onDeviceReady, before any UI
* reads the list. See #2561.
*/

const SECURE_KEY = "storageList";
const LEGACY_KEY = "storageList"; // the old localStorage key

/** @type {Array|null} in-memory source of truth; null until hydrated */
let cache = null;

function parse(json) {
try {
const value = JSON.parse(json);
return Array.isArray(value) ? value : [];
} catch (_) {
return [];
}
}

/**
* Load the list into memory, migrating any legacy plaintext localStorage copy
* into the encrypted store first. Safe to call more than once.
* @returns {Promise<void>}
*/
async function hydrate() {
// 1. One-time migration: if a legacy plaintext list exists in localStorage,
// move it into the encrypted store, then remove the plaintext copy.
const legacy = localStorage.getItem(LEGACY_KEY);
if (legacy != null) {
try {
// Only drop the plaintext copy AFTER the encrypted write succeeds,
// so a failure here never loses the user's saved servers.
await window.system.secureSet(SECURE_KEY, legacy);
localStorage.removeItem(LEGACY_KEY);
} catch (error) {
// Migration failed — keep the legacy copy and fall back to it this
// session rather than lose data. Try again next launch.
window.log?.("error", `secureStorageList migration failed: ${error}`);
cache = parse(legacy);
return;
}
}

// 2. Load from the encrypted store.
try {
const stored = await window.system.secureGet(SECURE_KEY);
cache = stored ? parse(stored) : [];
} catch (error) {
window.log?.("error", `secureStorageList hydrate failed: ${error}`);
cache = [];
}
}

/**
* The saved remote-storage list (synchronous).
* Falls back to a one-shot legacy read if called before hydrate() (defensive;
* should not happen in normal boot order).
* @returns {Array}
*/
function get() {
if (cache == null) {
const legacy = localStorage.getItem(LEGACY_KEY);
return legacy != null ? parse(legacy) : [];
}
return cache;
}

/**
* Persist the list. Updates the in-memory cache immediately and flushes to the
* encrypted store. The returned promise resolves once the flush completes;
* synchronous callers may ignore it (the cache is already updated).
* @param {Array} list
* @returns {Promise<void>}
*/
function set(list) {
cache = Array.isArray(list) ? list : [];
const json = JSON.stringify(cache);
return window.system.secureSet(SECURE_KEY, json).catch((error) => {
window.log?.("error", `secureStorageList save failed: ${error}`);
});
}

export default { hydrate, get, set };
5 changes: 5 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import notificationManager from "lib/notificationManager";
import openFolder, { addedFolder } from "lib/openFolder";
import { registerPrettierFormatter } from "lib/registerPrettierFormatter";
import restoreFiles from "lib/restoreFiles";
import secureStorageList from "lib/secureStorageList";
import settings from "lib/settings";
import startAd, { hideAd } from "lib/startAd";
import mustache from "mustache";
Expand Down Expand Up @@ -98,6 +99,10 @@ document.addEventListener("menubutton", menuButtonHandler);

async function onDeviceReady() {
await initEncodings(); // important to load encodings before anything else
// Load saved remote-storage (FTP/SFTP) list from the encrypted native store,
// migrating any legacy plaintext localStorage copy. Must run before any UI
// that reads the storage list. See issue #2561.
await secureStorageList.hydrate();

const isFreePackage = /(free)$/.test(BuildInfo.packageName);
const oldResolveURL = window.resolveLocalFileSystemURL;
Expand Down
11 changes: 6 additions & 5 deletions src/pages/fileBrowser/fileBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import openFolder from "lib/openFolder";
import projects from "lib/projects";
import recents from "lib/recents";
import remoteStorage from "lib/remoteStorage";
import secureStorageList from "lib/secureStorageList";
import appSettings from "lib/settings";
import { hideAd } from "lib/startAd";
import mimeTypes from "mime-types";
Expand Down Expand Up @@ -65,7 +66,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
const state = [];
/**@type {Array<Storage>} */
const allStorages = [];
let storageList = helpers.parseJSON(localStorage.storageList);
let storageList = secureStorageList.get();
if (!Array.isArray(storageList)) storageList = [];

let isSelectionMode = false;
Expand Down Expand Up @@ -1242,7 +1243,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
}
return false;
});
localStorage.storageList = JSON.stringify(storageList);
secureStorageList.set(storageList);
reload();
}

Expand All @@ -1251,7 +1252,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
if (storage.uuid === uuid) storage.name = newname;
return storage;
});
localStorage.storageList = JSON.stringify(storageList);
secureStorageList.set(storageList);
reload();
}

Expand Down Expand Up @@ -1706,7 +1707,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
}

storageList.push(storage);
localStorage.storageList = JSON.stringify(storageList);
secureStorageList.set(storageList);
if (doesReload) reload();
}

Expand Down Expand Up @@ -1761,7 +1762,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
.addPath()
.then((res) => {
storageList.push(res);
localStorage.storageList = JSON.stringify(storageList);
secureStorageList.set(storageList);
reload();
})
.catch((err) => {
Expand Down
76 changes: 76 additions & 0 deletions src/plugins/system/android/com/foxdebug/system/SecureStore.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.foxdebug.system;

import android.content.Context;
import android.content.SharedPreferences;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKeys;
import java.io.IOException;
import java.security.GeneralSecurityException;

/**
* Small encrypted key/value store for secrets that must not sit in cleartext on
* disk (e.g. saved SFTP/FTP server credentials, previously kept in the WebView's
* localStorage.storageList). Values are encrypted at rest with a hardware-backed
* master key via AndroidX Security-Crypto (AES256-GCM), the same mechanism the
* auth plugin already uses for the account token.
*
* Lazily initialised so a crypto/keystore failure never blocks plugin startup.
*/
public class SecureStore {

private static final String PREF_NAME = "acode_secure_store";

private final Context context;
private SharedPreferences prefs;

public SecureStore(Context context) {
this.context = context.getApplicationContext();
}

private SharedPreferences prefs() {
if (prefs != null) return prefs;
try {
String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
prefs = EncryptedSharedPreferences.create(
PREF_NAME,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
} catch (GeneralSecurityException | IOException e) {
Comment thread
bajrangCoder marked this conversation as resolved.
// Same fallback the existing EncryptedPreferenceManager uses: a private
// (app-sandbox) prefs file. Not encrypted, but still off the WebView's
// localStorage and unreadable by other apps.
prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
}
return prefs;
}

/**
* Store a value durably. Passing null removes the key.
* Uses commit() (not apply()) so the write is on disk before returning:
* the JS migration deletes the legacy plaintext copy only after this
* resolves, so an unpersisted write here must not report success. See #2561.
* @return true if the write reached disk.
*/
public boolean set(String key, String value) {
if (value == null) {
return remove(key);
}
return prefs().edit().putString(key, value).commit();
}

/** Return the stored value, or null if absent. */
public String get(String key) {
return prefs().getString(key, null);
}

public boolean remove(String key) {
return prefs().edit().remove(key).commit();
}

public boolean contains(String key) {
return prefs().contains(key);
}
}
27 changes: 27 additions & 0 deletions src/plugins/system/android/com/foxdebug/system/System.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,15 @@ public class System extends CordovaPlugin {
private CordovaWebView webView;
private String fileProviderAuthority;
private RewardPassManager rewardPassManager;
private SecureStore secureStore;

public void initialize(CordovaInterface cordova, CordovaWebView webView) {
super.initialize(cordova, webView);
this.context = cordova.getContext();
this.activity = cordova.getActivity();
this.webView = webView;
this.rewardPassManager = new RewardPassManager(this.context);
this.secureStore = new SecureStore(this.context);
this.activity.runOnUiThread(
new Runnable() {
@Override
Expand Down Expand Up @@ -217,6 +219,31 @@ public void run() {
case "getFilesDir":
callbackContext.success(getFilesDir());
return true;
case "secure-set":
// arg1 = key, arg2 = value (null clears the key). Report failure if the
// durable write did not reach disk, so the JS migration keeps its
// fallback copy rather than deleting it. See #2561.
if (secureStore.set(arg1, args.isNull(1) ? null : arg2)) {
callbackContext.success();
} else {
callbackContext.error("secure write failed");
}
return true;
case "secure-get":
{
String storedValue = secureStore.get(arg1);
// success(String) with null would throw; send an explicit empty result
if (storedValue == null) {
callbackContext.success((String) null);
} else {
callbackContext.success(storedValue);
}
}
return true;
case "secure-remove":
secureStore.remove(arg1);
callbackContext.success();
return true;
case "getRewardStatus":
callbackContext.success(rewardPassManager.getRewardStatus());
return true;
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/system/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@
<resource-file src="res/android/icon.ttf" target="assets/font/icon.ttf" />
<source-file src="android/com/foxdebug/system/Ui.java" target-dir="src/com/foxdebug/system"/>
<source-file src="android/com/foxdebug/system/System.java" target-dir="src/com/foxdebug/system"/>
<source-file src="android/com/foxdebug/system/SecureStore.java" target-dir="src/com/foxdebug/system"/>
<source-file src="android/com/foxdebug/system/SoftInputAssist.java" target-dir="src/com/foxdebug/system"/>

<framework src="androidx.core:core:1.6.0" />
<framework src="androidx.core:core-google-shortcuts:1.0.0" />
<framework src="androidx.documentfile:documentfile:1.0.1" />
<framework src="androidx.security:security-crypto:1.1.0" />
Comment thread
greptile-apps[bot] marked this conversation as resolved.
<source-file src="android/com/foxdebug/system/RewardPassManager.java" target-dir="src/com/foxdebug/system"/>
</platform>
</plugin>
39 changes: 39 additions & 0 deletions src/plugins/system/www/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,5 +272,44 @@ module.exports = {
[text1, text2]
);
});
},
/**
* Store a secret encrypted at rest (AES256-GCM, hardware-backed key).
* Use for credentials that must not sit in cleartext in the WebView's
* localStorage. Passing an empty/undefined value clears the key.
* @param {string} key
* @param {string} value
* @returns {Promise<void>}
*/
secureSet: function (key, value) {
return new Promise((resolve, reject) => {
cordova.exec(resolve, reject, 'System', 'secure-set', [key, value == null ? null : String(value)]);
});
},
/**
* Read a secret previously stored with secureSet.
* @param {string} key
* @returns {Promise<string|null>} the value, or null if absent
*/
secureGet: function (key) {
return new Promise((resolve, reject) => {
cordova.exec(
function (result) { resolve(result == null || result === '' ? null : result); },
reject,
'System',
'secure-get',
[key]
);
});
},
/**
* Remove a stored secret.
* @param {string} key
* @returns {Promise<void>}
*/
secureRemove: function (key) {
return new Promise((resolve, reject) => {
cordova.exec(resolve, reject, 'System', 'secure-remove', [key]);
});
}
};
3 changes: 2 additions & 1 deletion src/utils/Uri.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import escapeStringRegexp from "escape-string-regexp";
import secureStorageList from "lib/secureStorageList";
import path from "./Path";

function parseStorageList() {
try {
const storageList = JSON.parse(localStorage.storageList || "[]");
const storageList = secureStorageList.get();
return Array.isArray(storageList) ? storageList : [];
} catch (_) {
return [];
Expand Down
3 changes: 2 additions & 1 deletion src/utils/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import alert from "dialogs/alert";
import escapeStringRegexp from "escape-string-regexp";
import adRewards from "lib/adRewards";
import config from "lib/config";
import secureStorageList from "lib/secureStorageList";
import { bannerAd, interstitialAd } from "lib/startAd";
import { isBinaryFile } from "./binaryExtensions";
import { isPlayStoreInstall } from "./installSource";
Expand Down Expand Up @@ -245,7 +246,7 @@ export default {
}

/**@type {string[]} */
const storageList = this.parseJSON(localStorage.storageList);
const storageList = secureStorageList.get();
if (!Array.isArray(storageList)) return url;
const storageListLen = storageList.length;

Expand Down