Protect desktop secrets with OS-backed storage (#561)

This commit is contained in:
Gaurav Chaudhary 2026-09-22 16:32:08 +00:00 committed by hodlbod
parent d67309650c
commit f67ea6fb36
11 changed files with 617 additions and 24 deletions

View file

@ -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:

View file

@ -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<void>
}
}
}
}
}
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()
}

View file

@ -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",

View file

@ -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<boolean>` 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.

View file

@ -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()
}
}

View file

@ -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}
}

View file

@ -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"
}
}
}

View file

@ -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})
}
})

View file

@ -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/)
})
})

Binary file not shown.

View file

@ -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<void>
remove(options: {key: string}): Promise<void>
clear(): Promise<void>
}>("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 <T>(key: string): Promise<T | undefined> => {
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 <T>(key: string, value: T): Promise<void> => {
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}