Protect desktop secrets with OS-backed storage (#561)
This commit is contained in:
parent
d67309650c
commit
f67ea6fb36
11 changed files with 617 additions and 24 deletions
10
README.md
10
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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
13
packages/desktop-secure-storage/README.md
Normal file
13
packages/desktop-secure-storage/README.md
Normal 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.
|
||||
87
packages/desktop-secure-storage/electron/dist/plugin.mjs
vendored
Normal file
87
packages/desktop-secure-storage/electron/dist/plugin.mjs
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
144
packages/desktop-secure-storage/electron/dist/store.mjs
vendored
Normal file
144
packages/desktop-secure-storage/electron/dist/store.mjs
vendored
Normal 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}
|
||||
}
|
||||
14
packages/desktop-secure-storage/package.json
Normal file
14
packages/desktop-secure-storage/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
63
packages/desktop-secure-storage/test/plugin.test.mjs
Normal file
63
packages/desktop-secure-storage/test/plugin.test.mjs
Normal 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})
|
||||
}
|
||||
})
|
||||
197
packages/desktop-secure-storage/test/store.test.mjs
Normal file
197
packages/desktop-secure-storage/test/store.test.mjs
Normal 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/)
|
||||
})
|
||||
})
|
||||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue