From f67ea6fb3686b3b9784e6ff0ad6d6e3ef2a2eeea Mon Sep 17 00:00:00 2001 From: Gaurav Chaudhary Date: Tue, 22 Sep 2026 16:32:08 +0000 Subject: [PATCH] Protect desktop secrets with OS-backed storage (#561) --- README.md | 10 +- e2e/desktop/smoke.spec.ts | 49 ++++- package.json | 2 + packages/desktop-secure-storage/README.md | 13 ++ .../electron/dist/plugin.mjs | 87 ++++++++ .../electron/dist/store.mjs | 144 +++++++++++++ packages/desktop-secure-storage/package.json | 14 ++ .../test/plugin.test.mjs | 63 ++++++ .../test/store.test.mjs | 197 ++++++++++++++++++ pnpm-lock.yaml | Bin 400290 -> 400675 bytes src/app/storage.ts | 62 ++++-- 11 files changed, 617 insertions(+), 24 deletions(-) create mode 100644 packages/desktop-secure-storage/README.md create mode 100644 packages/desktop-secure-storage/electron/dist/plugin.mjs create mode 100644 packages/desktop-secure-storage/electron/dist/store.mjs create mode 100644 packages/desktop-secure-storage/package.json create mode 100644 packages/desktop-secure-storage/test/plugin.test.mjs create mode 100644 packages/desktop-secure-storage/test/store.test.mjs diff --git a/README.md b/README.md index 34de82bf..3126d467 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and workflow. ### Desktop development (Linux) The Electron target and its unsigned packages are for development and testing. Release publishing -and auto-updates are not configured. +and auto-updates are not configured. Desktop secrets are encrypted using the OS keyring or keychain. +If protected storage is unavailable or unreadable, the app warns and keeps secrets only in +memory until it closes, leaving the saved file untouched. Unlock or configure the OS keyring +and restart to restore persistence. Linux’s insecure `basic_text` backend is never used. -**Use disposable accounts only.** The secure-storage plugin falls back to unencrypted -`localStorage` on desktop, so it is not secure credential or private-key storage. Packages must -remain development-only until OS-protected secret storage and release signing are addressed. +**Use disposable accounts only with unsigned development packages.** Production distribution +still requires release signing and publishing configuration. The Electron subproject installs separately, so ordinary web and mobile installs don't download Electron: diff --git a/e2e/desktop/smoke.spec.ts b/e2e/desktop/smoke.spec.ts index a1673aa7..a610310a 100644 --- a/e2e/desktop/smoke.spec.ts +++ b/e2e/desktop/smoke.spec.ts @@ -4,6 +4,19 @@ import {tmpdir} from "node:os" import {join, resolve} from "node:path" import {_electron, expect, test} from "@playwright/test" +declare global { + interface Window { + Capacitor: { + Plugins: { + DesktopSecureStorage: { + get(options: {key: string}): Promise<{value?: string}> + set(options: {key: string; value: string}): Promise + } + } + } + } +} + test("the desktop app renders, navigates, and keeps external pages outside", async () => { const profile = await mkdtemp(join(tmpdir(), "flotilla-desktop-")) @@ -11,7 +24,7 @@ test("the desktop app renders, navigates, and keeps external pages outside", asy const packaged = process.env.FLOTILLA_DESKTOP_EXECUTABLE const executablePath: string = packaged || createRequire(import.meta.url)(resolve("electron/node_modules/electron")) - const app = await _electron.launch({ + let app = await _electron.launch({ executablePath, // Chromium refuses to start as root with its sandbox on, which is what a CI container is. chromiumSandbox: packaged ? true : process.getuid?.() !== 0, @@ -118,6 +131,40 @@ test("the desktop app renders, navigates, and keeps external pages outside", asy }) await expect(page.locator("html")).toHaveAttribute("data-csp-violation", "script-src-elem") await expect(page.locator("html")).not.toHaveAttribute("data-inline-script-executed") + + if (packaged) { + const contract = await app.evaluate(async ({safeStorage}) => ({ + available: await safeStorage.isAsyncEncryptionAvailable(), + decrypted: await safeStorage.decryptStringAsync( + await safeStorage.encryptStringAsync("api-contract"), + ), + })) + expect(contract.available).toBe(true) + expect(contract.decrypted.result).toBe("api-contract") + expect(typeof contract.decrypted.shouldReEncrypt).toBe("boolean") + const fixture = "packaged-desktop-secret" + await page.evaluate(async value => { + await window.Capacitor.Plugins.DesktopSecureStorage.set({key: "session", value}) + }, fixture) + expect((await readFile(join(profile, "secure-storage.bin"))).includes(fixture)).toBe(false) + + await app.close() + app = await _electron.launch({ + executablePath, + chromiumSandbox: true, + args: [`--user-data-dir=${profile}`], + }) + await expect + .poll(async () => { + const relaunched = app + .windows() + .find(candidate => candidate.url().startsWith("capacitor-electron://")) + return relaunched?.evaluate(async () => + window.Capacitor.Plugins.DesktopSecureStorage.get({key: "session"}), + ) + }) + .toEqual({value: fixture}) + } } finally { await app.close() } diff --git a/package.json b/package.json index 4ba73de7..8e7cff8e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/release.mjs scripts/release/*.mjs && eslint src e2e packages fdroid capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts", + "test:desktop-storage": "node --test packages/desktop-secure-storage/test/*.test.mjs", "test": "playwright test", "test:desktop": "playwright test --config e2e/desktop/playwright.config.ts", "test:ui": "playwright test --ui", @@ -74,6 +75,7 @@ "@capawesome/capacitor-android-dark-mode-support": "^8.0.0", "@capawesome/capacitor-badge": "^8.0.0", "@capawesome/capacitor-electron": "0.1.1", + "@flotilla/desktop-secure-storage": "file:packages/desktop-secure-storage", "@flotilla/desktop-window": "file:packages/desktop-window", "@getalby/lightning-tools": "^6.1.0", "@getalby/sdk": "^5.1.2", diff --git a/packages/desktop-secure-storage/README.md b/packages/desktop-secure-storage/README.md new file mode 100644 index 00000000..408f09cb --- /dev/null +++ b/packages/desktop-secure-storage/README.md @@ -0,0 +1,13 @@ +# Desktop secure storage plugin + +This package provides Flotilla's Electron implementation of protected secret storage. Capawesome +discovers the implementation at `electron/dist/plugin.mjs`; the encrypted store is kept in the +Electron user-data directory and requires an operating-system keyring or keychain. + +The [Electron 43 safeStorage API](https://github.com/electron/electron/blob/v43.0.0/docs/api/safe-storage.md) +defines `isAsyncEncryptionAvailable()` as `Promise` and `decryptStringAsync()` as +`Promise<{result: string, shouldReEncrypt: boolean}>`. + +Initialization failures show one warning and use memory only for that process. The saved file +is preserved for recovery; explicit logout still clears it. No desktop plaintext migration is +needed because desktop has not been deployed. Web and mobile keep their existing adapter. diff --git a/packages/desktop-secure-storage/electron/dist/plugin.mjs b/packages/desktop-secure-storage/electron/dist/plugin.mjs new file mode 100644 index 00000000..fecb9c31 --- /dev/null +++ b/packages/desktop-secure-storage/electron/dist/plugin.mjs @@ -0,0 +1,87 @@ +import {app, dialog, safeStorage} from "electron" +import {unlink} from "node:fs/promises" +import {join} from "node:path" +import {createEncryptedStore} from "./store.mjs" + +export class DesktopSecureStorage { + static __capacitorElectronPlugin = { + name: "DesktopSecureStorage", + methods: ["get", "set", "remove", "clear"], + } + + filePath + store + ready + + async initialize() { + await app.whenReady() + this.filePath = join(app.getPath("userData"), "secure-storage.bin") + + if ( + process.platform === "linux" && + ["basic_text", "unknown"].includes(safeStorage.getSelectedStorageBackend()) + ) { + throw new Error("A protected Linux storage backend is unavailable") + } + + if (!(await safeStorage.isAsyncEncryptionAvailable())) { + throw new Error("Protected storage encryption is unavailable") + } + + this.store = createEncryptedStore({ + filePath: this.filePath, + encrypt: plainText => safeStorage.encryptStringAsync(plainText), + decrypt: encrypted => safeStorage.decryptStringAsync(encrypted), + }) + await this.store.ready + } + + async load() { + this.ready ??= this.initialize().catch(error => { + console.error("Desktop protected storage could not initialize", error) + const values = new Map() + this.store = { + get: key => values.get(key), + set: (key, value) => { + values.set(key, value) + }, + remove: key => { + values.delete(key) + }, + clear: async () => { + await unlink(this.filePath).catch(error => { + if (error.code !== "ENOENT") { + throw error + } + }) + values.clear() + }, + } + dialog.showErrorBox( + app.getName(), + "Protected storage could not be opened. This session will not be saved after closing the app. Unlock or configure the operating system keyring or keychain and restart. Any previously saved credentials have been left untouched.", + ) + }) + await this.ready + } + + async get({key}) { + await this.load() + return {value: await this.store.get(key)} + } + + async set({key, value}) { + await this.load() + return this.store.set(key, value) + } + + async remove({key}) { + await this.load() + return this.store.remove(key) + } + + async clear() { + await this.load() + return this.store.clear() + } +} diff --git a/packages/desktop-secure-storage/electron/dist/store.mjs b/packages/desktop-secure-storage/electron/dist/store.mjs new file mode 100644 index 00000000..b065ab00 --- /dev/null +++ b/packages/desktop-secure-storage/electron/dist/store.mjs @@ -0,0 +1,144 @@ +import {dirname} from "node:path" +import {randomUUID} from "node:crypto" +import {open, readFile, rename, unlink} from "node:fs/promises" + +const isMissing = error => error?.code === "ENOENT" + +const readValues = async ({filePath, decrypt, fs}) => { + let encrypted + + try { + encrypted = await fs.readFile(filePath) + } catch (error) { + if (isMissing(error)) { + return {values: {}, rewrite: false} + } + throw error + } + + const {result, shouldReEncrypt} = await decrypt(encrypted) + let parsed + + try { + parsed = JSON.parse(result) + } catch { + // JSON parse errors can include decrypted secrets in their messages. + throw new Error("Encrypted desktop storage is invalid") + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Encrypted desktop storage is invalid") + } + + for (const value of Object.values(parsed)) { + if (typeof value !== "string") { + throw new Error("Encrypted desktop storage is invalid") + } + } + + if (shouldReEncrypt) { + return {values: parsed, rewrite: true} + } + + return {values: parsed, rewrite: false} +} + +export const createEncryptedStore = ({ + filePath, + encrypt, + decrypt, + platform = process.platform, + randomId = randomUUID, + fs = {open, readFile, rename, unlink}, +}) => { + let values = {} + let queue = Promise.resolve() + + const persist = async next => { + const encrypted = await encrypt(JSON.stringify(next)) + const temporaryPath = `${filePath}.${randomId()}.tmp` + + try { + const file = await fs.open(temporaryPath, "wx", 0o600) + try { + await file.writeFile(encrypted) + await file.sync() + } finally { + await file.close() + } + await fs.rename(temporaryPath, filePath) + if (platform !== "win32") { + const directory = await fs.open(dirname(filePath), "r") + try { + await directory.sync() + } finally { + await directory.close() + } + } + } finally { + await fs.unlink(temporaryPath).catch(() => undefined) + } + } + + const load = async () => { + const loaded = await readValues({filePath, decrypt, fs}) + values = loaded.values + + if (loaded.rewrite) { + await persist(values) + } + } + + const ready = load() + + const enqueue = operation => { + const result = queue.then(async () => { + await ready + return operation() + }) + queue = result.catch(() => undefined) + return result + } + + const get = key => enqueue(() => values[key]) + + const set = (key, value) => + enqueue(async () => { + if (typeof value !== "string") { + throw new TypeError("Desktop secret values must be strings") + } + const next = {...values, [key]: value} + await persist(next) + values = next + }) + + const remove = key => + enqueue(async () => { + if (!Object.hasOwn(values, key)) { + return + } + const next = {...values} + delete next[key] + await persist(next) + values = next + }) + + const clear = () => + enqueue(async () => { + const previous = values + + try { + await fs.unlink(filePath).catch(error => { + if (!isMissing(error)) { + throw error + } + }) + values = {} + } catch (error) { + values = previous + throw error + } + }) + + return {ready, get, set, remove, clear} +} diff --git a/packages/desktop-secure-storage/package.json b/packages/desktop-secure-storage/package.json new file mode 100644 index 00000000..f7e09336 --- /dev/null +++ b/packages/desktop-secure-storage/package.json @@ -0,0 +1,14 @@ +{ + "name": "@flotilla/desktop-secure-storage", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./electron/dist/plugin.mjs": "./electron/dist/plugin.mjs" + }, + "capacitor": { + "electron": { + "src": "electron" + } + } +} diff --git a/packages/desktop-secure-storage/test/plugin.test.mjs b/packages/desktop-secure-storage/test/plugin.test.mjs new file mode 100644 index 00000000..050203c8 --- /dev/null +++ b/packages/desktop-secure-storage/test/plugin.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict" +import {mkdtemp, readFile, rm, writeFile} from "node:fs/promises" +import {registerHooks} from "node:module" +import {tmpdir} from "node:os" +import {join} from "node:path" +import {it, mock} from "node:test" + +it("keeps failed initialization usable without overwriting saved credentials", async () => { + const directory = await mkdtemp(join(tmpdir(), "desktop-plugin-")) + const filePath = join(directory, "secure-storage.bin") + const warnings = [] + const electron = { + app: {whenReady: async () => {}, getPath: () => directory, getName: () => "Test"}, + dialog: {showErrorBox: (...args) => warnings.push(args)}, + safeStorage: { + getSelectedStorageBackend: () => "gnome_libsecret", + isAsyncEncryptionAvailable: async () => true, + encryptStringAsync: async () => { + throw new Error("must not overwrite") + }, + decryptStringAsync: async () => { + throw new Error("key unavailable") + }, + }, + } + globalThis.testElectron = electron + const hooks = registerHooks({ + resolve(specifier, context, next) { + return specifier === "electron" + ? { + url: "data:text/javascript,export const {app, dialog, safeStorage} = globalThis.testElectron", + shortCircuit: true, + } + : next(specifier, context) + }, + }) + const log = mock.method(console, "error", () => {}) + + try { + const {DesktopSecureStorage} = await import("../electron/dist/plugin.mjs") + for (const backend of ["gnome_libsecret", "basic_text", "unknown"]) { + electron.safeStorage.getSelectedStorageBackend = () => backend + await writeFile(filePath, "original encrypted credentials") + const plugin = new DesktopSecureStorage() + await Promise.all([plugin.load(), plugin.load()]) + assert.equal((await plugin.get({key: "session"})).value, undefined) + await plugin.set({key: "session", value: "temporary secret"}) + assert.equal((await plugin.get({key: "session"})).value, "temporary secret") + assert.equal(await readFile(filePath, "utf8"), "original encrypted credentials") + await plugin.remove({key: "session"}) + assert.equal((await plugin.get({key: "session"})).value, undefined) + await plugin.clear() + await assert.rejects(readFile(filePath), {code: "ENOENT"}) + } + assert.equal(warnings.length, 3) + assert.equal(log.mock.callCount(), 3) + } finally { + log.mock.restore() + hooks.deregister() + delete globalThis.testElectron + await rm(directory, {recursive: true, force: true}) + } +}) diff --git a/packages/desktop-secure-storage/test/store.test.mjs b/packages/desktop-secure-storage/test/store.test.mjs new file mode 100644 index 00000000..155174ba --- /dev/null +++ b/packages/desktop-secure-storage/test/store.test.mjs @@ -0,0 +1,197 @@ +import {open, mkdtemp, readFile, rename, rm, stat, unlink, writeFile} from "node:fs/promises" +import {tmpdir} from "node:os" +import {join} from "node:path" +import {afterEach, describe, it} from "node:test" +import assert from "node:assert/strict" +import {createEncryptedStore} from "../electron/dist/store.mjs" + +const directories = [] + +const makeDirectory = async () => { + const directory = await mkdtemp(join(tmpdir(), "flotilla-secure-storage-")) + directories.push(directory) + return directory +} + +const makeCrypto = ({reEncrypt = false} = {}) => ({ + encrypt: async value => Buffer.from(`enc:${Buffer.from(value).toString("base64")}`), + decrypt: async value => ({ + result: Buffer.from(value.toString().slice(4), "base64").toString(), + shouldReEncrypt: reEncrypt, + }), +}) + +const makeStore = async (options = {}) => { + const directory = await makeDirectory() + const crypto = makeCrypto(options) + const store = createEncryptedStore({ + filePath: join(directory, "secure-storage.bin"), + ...crypto, + ...options, + }) + await store.ready + return {directory, store} +} + +afterEach(async () => { + await Promise.all( + directories.splice(0).map(directory => rm(directory, {recursive: true, force: true})), + ) +}) + +describe("encrypted desktop store", () => { + it("starts with an empty map when the encrypted file is missing", async () => { + const {store} = await makeStore() + assert.equal(await store.get("session"), undefined) + }) + + it("persists values without plaintext", async () => { + const {directory, store} = await makeStore() + await store.set("session", "fixture-secret") + assert.equal(await store.get("session"), "fixture-secret") + const file = await readFile(join(directory, "secure-storage.bin")) + assert.equal(file.includes("fixture-secret"), false) + if (process.platform !== "win32") { + assert.equal((await stat(join(directory, "secure-storage.bin"))).mode & 0o777, 0o600) + } + }) + + it("serializes concurrent writes", async () => { + const {directory, store} = await makeStore() + await Promise.all([ + store.set("session", "a"), + store.set("wallet", "b"), + store.set("sessions", "c"), + ]) + const reopened = createEncryptedStore({ + filePath: join(directory, "secure-storage.bin"), + ...makeCrypto(), + }) + await reopened.ready + assert.deepEqual( + await Promise.all([ + reopened.get("session"), + reopened.get("wallet"), + reopened.get("sessions"), + ]), + ["a", "b", "c"], + ) + }) + + it("preserves empty string values", async () => { + const {store} = await makeStore() + await store.set("empty", "") + assert.equal(await store.get("empty"), "") + }) + + it("removes individual values and clears the encrypted file", async () => { + const {directory, store} = await makeStore() + await store.set("session", "fixture-secret") + await store.set("wallet", "wallet-secret") + await store.remove("session") + assert.equal(await store.get("session"), undefined) + assert.equal(await store.get("wallet"), "wallet-secret") + await store.clear() + assert.equal(await store.get("wallet"), undefined) + await assert.rejects(readFile(join(directory, "secure-storage.bin")), {code: "ENOENT"}) + }) + + it("leaves the previous file when a replacement fails", async () => { + const {directory, store} = await makeStore() + await store.set("session", "before") + const failing = createEncryptedStore({ + filePath: join(directory, "secure-storage.bin"), + ...makeCrypto(), + fs: { + open, + readFile, + rename: async () => { + throw new Error("replacement failed") + }, + unlink: async () => undefined, + writeFile, + }, + }) + await failing.ready + await assert.rejects(failing.set("session", "after"), /replacement failed/) + const reopened = createEncryptedStore({ + filePath: join(directory, "secure-storage.bin"), + ...makeCrypto(), + }) + await reopened.ready + assert.equal(await reopened.get("session"), "before") + }) + + it("keeps the previous file when flushing the replacement fails", async () => { + const {directory, store} = await makeStore() + await store.set("session", "before") + const filePath = join(directory, "secure-storage.bin") + const original = await readFile(filePath) + const failing = createEncryptedStore({ + filePath, + ...makeCrypto(), + fs: { + readFile, + rename, + unlink, + open: async (...args) => { + const file = await open(...args) + return { + writeFile: value => file.writeFile(value), + sync: async () => { + throw new Error("flush failed") + }, + close: () => file.close(), + } + }, + }, + }) + await assert.rejects(failing.set("session", "after"), /flush failed/) + assert.deepEqual(await readFile(filePath), original) + assert.equal(await failing.get("session"), "before") + }) + + it("rewrites values when the key provider requests re-encryption", async () => { + const {directory, store} = await makeStore() + await store.set("session", "fixture-secret") + let writes = 0 + const reopened = createEncryptedStore({ + filePath: join(directory, "secure-storage.bin"), + ...makeCrypto({reEncrypt: true}), + fs: { + open, + readFile, + unlink, + rename: async (...args) => { + writes += 1 + return rename(...args) + }, + }, + }) + await reopened.ready + assert.equal(await reopened.get("session"), "fixture-secret") + assert.equal(writes, 1) + }) + + it("rejects corrupt encrypted data", async () => { + const directory = await makeDirectory() + const filePath = join(directory, "secure-storage.bin") + await writeFile(filePath, "corrupt") + const store = createEncryptedStore({filePath, ...makeCrypto()}) + await assert.rejects(store.ready, /Encrypted desktop storage is invalid/) + }) + + it("rejects undecryptable encrypted data", async () => { + const directory = await makeDirectory() + const filePath = join(directory, "secure-storage.bin") + await writeFile(filePath, "encrypted") + const store = createEncryptedStore({ + filePath, + encrypt: async value => Buffer.from(value), + decrypt: async () => { + throw new Error("decrypt failed") + }, + }) + await assert.rejects(store.ready, /decrypt failed/) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d6aed8cddc3bd0b9fac215205eef8d4a6e994ac..8a4f4f32ab7fe7626906bd282b9974ba2f313b79 100644 GIT binary patch delta 187 zcmZ3qU1ISri4CjRChk(1e2h(uuQ)Ziv?x`#xFo+QF+FwiBQ^~*q0JN6Om?G*O<(WE zB910D+0jH~^6!H(=)$sxB%8M!Y~ON_5r~<#Z#l^PwFONL*f=3nm#AAQR8L-W(6;@Y N8_V`{Zmd_X008q3O;!K^ delta 46 zcmZ3yOJdP>i4CjRHaoFP?VhgX!J^tMeYjovFe4B%ZI?dGe4wQrD7szCgZ1YX0I=c` AOaK4? diff --git a/src/app/storage.ts b/src/app/storage.ts index e08822fb..f27c8fb9 100644 --- a/src/app/storage.ts +++ b/src/app/storage.ts @@ -2,6 +2,7 @@ import {writable} from "svelte/store" import type {Unsubscriber} from "svelte/store" import {deleteDB} from "idb" import {SecureStorage} from "@aparajita/capacitor-secure-storage" +import {Capacitor, registerPlugin} from "@capacitor/core" import {Preferences} from "@capacitor/preferences" import {WEEK, ago, noop, now, on, throttle, batch, call, makeQueue} from "@welshman/lib" import type {Maybe} from "@welshman/lib" @@ -77,22 +78,50 @@ export const kv = call(() => { return {get, set, clear} }) +const secretStorage = + Capacitor.getPlatform() === "electron" + ? call(() => { + const plugin = registerPlugin<{ + get(options: {key: string}): Promise<{value?: string}> + set(options: {key: string; value: string}): Promise + remove(options: {key: string}): Promise + clear(): Promise + }>("DesktopSecureStorage") + + return { + get: async (key: string) => (await plugin.get({key})).value, + set: (key: string, value: string | undefined) => + value === undefined ? plugin.remove({key}) : plugin.set({key, value}), + clear: () => plugin.clear(), + } + }) + : { + get: async (key: string) => { + let value = await SecureStorage.getItem(key) + + if (!value) { + const legacy = await Preferences.get({key}) + + if (legacy.value) { + value = legacy.value + await SecureStorage.setItem(key, legacy.value) + await Preferences.remove({key}) + } + } + + return value + }, + // Android's SecureStorage rejects undefined + set: (key: string, value: string | undefined) => + value === undefined ? SecureStorage.removeItem(key) : SecureStorage.setItem(key, value), + clear: () => SecureStorage.clear(), + } + export const ss = call(() => { const enqueue = makeQueue() const get = async (key: string): Promise => { - let value = await SecureStorage.getItem(key) - - if (!value) { - const legacy = await Preferences.get({key}) - - if (legacy.value) { - value = legacy.value - await SecureStorage.setItem(key, legacy.value) - await Preferences.remove({key}) - } - } - + const value = await secretStorage.get(key) if (!value) { return undefined } @@ -104,17 +133,12 @@ export const ss = call(() => { } } - // Android's SecureStorage rejects undefined const set = async (key: string, value: T): Promise => { - await enqueue(() => - value === undefined - ? SecureStorage.removeItem(key) - : SecureStorage.setItem(key, JSON.stringify(value)), - ) + await enqueue(() => secretStorage.set(key, JSON.stringify(value))) } const clear = async () => { - await enqueue(() => SecureStorage.clear()) + await enqueue(() => secretStorage.clear()) } return {get, set, clear}