125 lines
3.7 KiB
JavaScript
125 lines
3.7 KiB
JavaScript
import { randomBytes, randomUUID } from "node:crypto";
|
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
const KEY_BYTES = 32;
|
|
|
|
/**
|
|
* @param {string} filePath
|
|
* @param {Buffer} encrypted
|
|
*/
|
|
async function replaceProtectedKey(filePath, encrypted) {
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
try {
|
|
await writeFile(temporary, encrypted, { mode: 0o600 });
|
|
await chmod(temporary, 0o600).catch(() => undefined);
|
|
await rename(temporary, filePath);
|
|
await chmod(filePath, 0o600).catch(() => undefined);
|
|
} finally {
|
|
await rm(temporary, { force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compact filename-safe UTC timestamp mirroring the `*.openwork-backup-<ts>`
|
|
* naming used by the server legacy-config sweep.
|
|
*
|
|
* @param {Date} date
|
|
*/
|
|
function backupTimestamp(date) {
|
|
const parts = [
|
|
date.getUTCFullYear(),
|
|
date.getUTCMonth() + 1,
|
|
date.getUTCDate(),
|
|
date.getUTCHours(),
|
|
date.getUTCMinutes(),
|
|
date.getUTCSeconds(),
|
|
];
|
|
return parts.map((part, index) => String(part).padStart(index === 0 ? 4 : 2, "0")).join("");
|
|
}
|
|
|
|
/**
|
|
* @param {string} encoded
|
|
*/
|
|
function decodeKey(encoded) {
|
|
const key = Buffer.from(encoded, "base64");
|
|
if (key.byteLength !== KEY_BYTES) {
|
|
throw new Error("The protected OpenWork credential key is invalid.");
|
|
}
|
|
return key;
|
|
}
|
|
|
|
/**
|
|
* Creates a lazy key provider so Electron does not initialize secure storage
|
|
* until a user opts into OpenWork-managed OAuth.
|
|
*
|
|
* @param {{
|
|
* filePath: string;
|
|
* loadSafeStorage: () => import("electron").SafeStorage;
|
|
* platform?: NodeJS.Platform;
|
|
* }} options
|
|
*/
|
|
export function createDesktopVaultKeyProvider({
|
|
filePath,
|
|
loadSafeStorage,
|
|
platform = process.platform,
|
|
}) {
|
|
/** @type {Promise<Buffer> | null} */
|
|
let pending = null;
|
|
|
|
async function loadKey() {
|
|
const safeStorage = loadSafeStorage();
|
|
if (!safeStorage || !(await safeStorage.isAsyncEncryptionAvailable())) {
|
|
throw new Error("Operating-system secure storage is unavailable for OpenWork-managed OAuth.");
|
|
}
|
|
if (platform === "linux" && safeStorage.getSelectedStorageBackend() === "basic_text") {
|
|
throw new Error("A secure Linux password store is required for OpenWork-managed OAuth.");
|
|
}
|
|
|
|
/** @type {Buffer | undefined} */
|
|
let encrypted;
|
|
try {
|
|
encrypted = await readFile(filePath);
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
|
|
if (encrypted) {
|
|
/** @type {Awaited<ReturnType<typeof safeStorage.decryptStringAsync>> | undefined} */
|
|
let decrypted;
|
|
/** @type {Buffer | undefined} */
|
|
let key;
|
|
try {
|
|
decrypted = await safeStorage.decryptStringAsync(encrypted);
|
|
key = decodeKey(decrypted.result);
|
|
} catch {
|
|
// Secure storage is available but can no longer decrypt the blob (for
|
|
// example the OS keychain secret changed), so quarantine the original
|
|
// bytes and mint a fresh key instead of failing on every launch.
|
|
await rename(filePath, `${filePath}.openwork-backup-${backupTimestamp(new Date())}`);
|
|
}
|
|
if (decrypted && key) {
|
|
if (decrypted.shouldReEncrypt) {
|
|
await replaceProtectedKey(filePath, await safeStorage.encryptStringAsync(decrypted.result));
|
|
}
|
|
return key;
|
|
}
|
|
}
|
|
|
|
const key = randomBytes(KEY_BYTES);
|
|
await replaceProtectedKey(filePath, await safeStorage.encryptStringAsync(key.toString("base64")));
|
|
return key;
|
|
}
|
|
|
|
return async () => {
|
|
pending ??= loadKey();
|
|
try {
|
|
return Buffer.from(await pending);
|
|
} catch (error) {
|
|
pending = null;
|
|
throw error;
|
|
}
|
|
};
|
|
}
|