144 lines
3.3 KiB
JavaScript
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}
|
|
}
|