-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix: encrypt saved FTP/SFTP credentials #2566
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8a91240
57d8c16
07ae258
e57354e
f7e8fe8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| /** | ||
| * Encrypted storage for remote-server secrets (FTP/SFTP passwords and key | ||
| * passphrases). | ||
| * | ||
| * The saved-server list itself stays in `localStorage.storageList` so plugins | ||
| * that read it keep working; only the secrets are moved out into the native | ||
| * encrypted store, keyed by connection identity. Secrets are put back into the | ||
| * URL at connect time. See #2561. | ||
| */ | ||
|
|
||
| const SECURE_KEY = "remoteCredentials"; | ||
|
|
||
| /** @type {Record<string, {password?: string, passPhrase?: string}>} */ | ||
| let cache = {}; | ||
|
|
||
| /** | ||
| * Connection identity used as the lookup key: protocol, user, host and port. | ||
| * Deliberately excludes the path so every folder under a server shares one entry. | ||
| * @param {string} url | ||
| * @returns {string|null} | ||
| */ | ||
| function keyFor(url) { | ||
| if (!url) return null; | ||
| const m = /^([a-z0-9+.-]+:)\/\/([^@/]*@)?([^/:?#]+)(:(\d+))?/i.exec(url); | ||
| if (!m) return null; | ||
| const protocol = m[1].toLowerCase(); | ||
| const userinfo = (m[2] || "").replace(/@$/, ""); | ||
| const username = decodeURIComponent(userinfo.split(":")[0] || ""); | ||
| const host = m[3].toLowerCase(); | ||
| const port = m[5] || ""; | ||
| return `${protocol}//${username}@${host}${port ? ":" + port : ""}`; | ||
| } | ||
|
|
||
| /** | ||
| * Remove `user:password@` credentials from a URL, keeping the username. | ||
| * Used so URLs saved before the migration still prefix-match today's URLs. | ||
| * @param {string} url | ||
| * @returns {string} | ||
| */ | ||
| function stripPassword(url) { | ||
| if (!url) return url; | ||
| return url.replace( | ||
| /^([a-z0-9+.-]+:\/\/)([^@/]*?):([^@/]*)@/i, | ||
| (_, scheme, user) => `${scheme}${user}@`, | ||
| ); | ||
| } | ||
|
|
||
| /** Promisified bridge helpers — resolve to null instead of throwing. */ | ||
| function secureGet(key) { | ||
| return new Promise((resolve) => { | ||
| try { | ||
| window.system.secureGet(key, resolve, () => resolve(null)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| } catch (_) { | ||
| resolve(null); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| function secureSet(key, value) { | ||
| return new Promise((resolve, reject) => { | ||
| try { | ||
| window.system.secureSet(key, value, resolve, reject); | ||
| } catch (error) { | ||
| reject(error); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Load secrets into memory, and migrate any credentials still embedded in | ||
| * `localStorage.storageList` from older versions. | ||
| * Must be awaited during startup, before anything connects to a remote server. | ||
| */ | ||
| async function hydrate() { | ||
| try { | ||
| const stored = await secureGet(SECURE_KEY); | ||
| cache = stored ? JSON.parse(stored) || {} : {}; | ||
| } catch (error) { | ||
| cache = {}; | ||
| window.log?.("error", `secureCredentials: hydrate failed - ${error}`); | ||
| } | ||
|
|
||
| await migrateLegacy(); | ||
| } | ||
|
|
||
| /** | ||
| * One-time move of inline credentials out of `localStorage.storageList`. | ||
| * The plaintext copy is only rewritten once the encrypted write is confirmed on | ||
| * disk, so an interrupted migration can't lose a saved server. | ||
| */ | ||
| async function migrateLegacy() { | ||
| let list; | ||
| try { | ||
| list = JSON.parse(localStorage.storageList || "[]"); | ||
| } catch (_) { | ||
| return; | ||
| } | ||
| if (!Array.isArray(list) || !list.length) return; | ||
|
|
||
| let changed = false; | ||
| const pending = { ...cache }; | ||
|
|
||
| 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); | ||
|
Comment on lines
+103
to
+116
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| changed = true; | ||
| } | ||
|
|
||
| if (!changed) return; | ||
|
|
||
| try { | ||
| await secureSet(SECURE_KEY, JSON.stringify(pending)); | ||
| cache = pending; | ||
| localStorage.storageList = JSON.stringify(list); | ||
| } catch (error) { | ||
| // Keep the legacy copy and retry on the next launch rather than lose it. | ||
| window.log?.("error", `secureCredentials: migration failed - ${error}`); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Secrets for a connection, or null. Synchronous by design so the existing | ||
| * synchronous `fromUrl` paths keep working. | ||
| * @param {string} url | ||
| */ | ||
| function get(url) { | ||
| const key = keyFor(url); | ||
| return (key && cache[key]) || null; | ||
| } | ||
|
|
||
| /** | ||
| * Persist secrets for a connection. Empty values remove the entry. | ||
| * @param {string} url | ||
| * @param {{password?: string, passPhrase?: string}} secrets | ||
| */ | ||
| async function set(url, secrets) { | ||
| const key = keyFor(url); | ||
| if (!key) return; | ||
|
|
||
| const clean = {}; | ||
| if (secrets?.password) clean.password = secrets.password; | ||
| if (secrets?.passPhrase) clean.passPhrase = secrets.passPhrase; | ||
|
|
||
| const next = { ...cache }; | ||
| if (Object.keys(clean).length) next[key] = clean; | ||
| else delete next[key]; | ||
|
|
||
| await secureSet(SECURE_KEY, JSON.stringify(next)); | ||
| cache = next; | ||
| } | ||
|
|
||
| /** | ||
| * Drop stored secrets for a connection (used when a server is removed). | ||
| * @param {string} url | ||
| */ | ||
| async function remove(url) { | ||
| await set(url, {}); | ||
|
Comment on lines
+167
to
+168
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| } | ||
|
|
||
| export default { hydrate, get, set, remove, stripPassword, keyFor }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| 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; | ||
|
|
||
| /** | ||
| * Encrypted key/value store for secrets that must not sit in cleartext on disk | ||
| * (saved FTP/SFTP credentials — see #2561). Backed by AndroidX Security-Crypto | ||
| * (AES256-GCM values, AES256-SIV keys), the same mechanism the auth plugin uses | ||
| * for the account token. | ||
| * | ||
| * If the encrypted store can't be opened (keystore/crypto failure), reads and | ||
| * writes fail rather than falling back to plaintext. A plaintext fallback would | ||
| * both re-introduce cleartext credentials and become unreadable once encryption | ||
| * recovers — EncryptedSharedPreferences encrypts lookup keys, so a literal key | ||
| * written in fallback mode can't be found again. Failing instead lets the caller | ||
| * keep its source copy and retry on the next launch. | ||
| */ | ||
| 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(); | ||
| } | ||
|
|
||
| /** The encrypted preferences, or null if encryption is currently unavailable. */ | ||
| 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) { | ||
|
bajrangCoder marked this conversation as resolved.
|
||
| prefs = null; | ||
| } | ||
| 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 reports | ||
| * success. | ||
| * @return true if the write reached disk; false if encryption is unavailable. | ||
| */ | ||
| public boolean set(String key, String value) { | ||
| if (value == null) { | ||
| return remove(key); | ||
| } | ||
| SharedPreferences p = prefs(); | ||
| if (p == null) return false; | ||
| return p.edit().putString(key, value).commit(); | ||
| } | ||
|
|
||
| /** Return the stored value, or null if absent or encryption is unavailable. */ | ||
| public String get(String key) { | ||
| SharedPreferences p = prefs(); | ||
| if (p == null) return null; | ||
| return p.getString(key, null); | ||
| } | ||
|
|
||
| public boolean remove(String key) { | ||
| SharedPreferences p = prefs(); | ||
| if (p == null) return false; | ||
| return p.edit().remove(key).commit(); | ||
| } | ||
|
|
||
| public boolean contains(String key) { | ||
| SharedPreferences p = prefs(); | ||
| return p != null && p.contains(key); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.