flotilla/packages/desktop-secure-storage/electron/dist/store.mjs
2026-09-22 16:32:08 +00:00

144 lines
3.3 KiB
JavaScript

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