123 lines
4.7 KiB
JavaScript
123 lines
4.7 KiB
JavaScript
|
|
// Quick-unlock PIN for the password vault.
|
||
|
|
//
|
||
|
|
// The PIN is an alias for the master password, never a replacement: it
|
||
|
|
// encrypts the master password (PBKDF2-SHA256 -> AES-256-GCM), and the
|
||
|
|
// result is sealed again with Electron safeStorage (DPAPI on Windows,
|
||
|
|
// Keychain on macOS, libsecret on Linux) where available, so a copied
|
||
|
|
// vault-pin.json is useless on another machine or OS account. A 6-digit PIN
|
||
|
|
// alone would fall to an offline search in minutes; the OS seal is what
|
||
|
|
// stops that.
|
||
|
|
//
|
||
|
|
// Three wrong PINs in a row switch to "master password required". That flag
|
||
|
|
// lives in the same file, so restarting Theseus does not reset it; only a
|
||
|
|
// successful master-password unlock does.
|
||
|
|
//
|
||
|
|
// File: { v: 1, sealed: bool, data: <b64 safeStorage blob> | blob, fails, requireMaster }
|
||
|
|
// blob = { salt, iv, ct, iters } (all b64 except iters)
|
||
|
|
|
||
|
|
"use strict";
|
||
|
|
|
||
|
|
const fs = require("node:fs");
|
||
|
|
const crypto = require("node:crypto");
|
||
|
|
|
||
|
|
const MAX_FAILS = 3;
|
||
|
|
const ITERATIONS = 600_000;
|
||
|
|
const PIN_RE = /^\d{6}$/;
|
||
|
|
|
||
|
|
function createVaultPin({ file, safeStorage }) {
|
||
|
|
const sealAvailable = () => {
|
||
|
|
try { return !!(safeStorage && safeStorage.isEncryptionAvailable()); } catch { return false; }
|
||
|
|
};
|
||
|
|
|
||
|
|
function read() {
|
||
|
|
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
||
|
|
}
|
||
|
|
function write(rec) {
|
||
|
|
const tmp = file + ".tmp";
|
||
|
|
fs.writeFileSync(tmp, JSON.stringify(rec), { mode: 0o600 });
|
||
|
|
fs.renameSync(tmp, file);
|
||
|
|
}
|
||
|
|
|
||
|
|
function blobOf(rec) {
|
||
|
|
if (!rec) return null;
|
||
|
|
if (!rec.sealed) return rec.data;
|
||
|
|
if (!sealAvailable()) throw new Error("this PIN was sealed by the system keystore, which is not available now");
|
||
|
|
return JSON.parse(safeStorage.decryptString(Buffer.from(rec.data, "base64")));
|
||
|
|
}
|
||
|
|
|
||
|
|
const keyFor = (pin, salt, iters) => crypto.pbkdf2Sync(String(pin), salt, iters, 32, "sha256");
|
||
|
|
|
||
|
|
return {
|
||
|
|
MAX_FAILS,
|
||
|
|
|
||
|
|
status() {
|
||
|
|
const rec = read();
|
||
|
|
return {
|
||
|
|
pinSet: !!rec,
|
||
|
|
fails: rec ? rec.fails || 0 : 0,
|
||
|
|
requireMaster: !!(rec && rec.requireMaster),
|
||
|
|
sealed: !!(rec && rec.sealed),
|
||
|
|
};
|
||
|
|
},
|
||
|
|
|
||
|
|
// Caller must have verified masterPassword against the vault first.
|
||
|
|
set(pin, masterPassword) {
|
||
|
|
if (!PIN_RE.test(String(pin || ""))) throw new Error("the PIN must be 6 digits");
|
||
|
|
if (!masterPassword) throw new Error("master password required");
|
||
|
|
const salt = crypto.randomBytes(16);
|
||
|
|
const iv = crypto.randomBytes(12);
|
||
|
|
const cipher = crypto.createCipheriv("aes-256-gcm", keyFor(pin, salt, ITERATIONS), iv);
|
||
|
|
const ct = Buffer.concat([cipher.update(String(masterPassword), "utf8"), cipher.final(), cipher.getAuthTag()]);
|
||
|
|
const blob = { salt: salt.toString("base64"), iv: iv.toString("base64"), ct: ct.toString("base64"), iters: ITERATIONS };
|
||
|
|
const sealed = sealAvailable();
|
||
|
|
write({
|
||
|
|
v: 1,
|
||
|
|
sealed,
|
||
|
|
data: sealed ? safeStorage.encryptString(JSON.stringify(blob)).toString("base64") : blob,
|
||
|
|
fails: 0,
|
||
|
|
requireMaster: false,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
|
||
|
|
clear() {
|
||
|
|
try { fs.unlinkSync(file); } catch {}
|
||
|
|
},
|
||
|
|
|
||
|
|
// Returns the master password, or throws:
|
||
|
|
// { code: "no-pin" | "master-required" | "wrong-pin", remaining }
|
||
|
|
open(pin) {
|
||
|
|
const rec = read();
|
||
|
|
if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin" });
|
||
|
|
if (rec.requireMaster) throw Object.assign(new Error("enter the master password"), { code: "master-required", remaining: 0 });
|
||
|
|
let masterPassword = null;
|
||
|
|
if (PIN_RE.test(String(pin || ""))) {
|
||
|
|
try {
|
||
|
|
const b = blobOf(rec);
|
||
|
|
const ct = Buffer.from(b.ct, "base64");
|
||
|
|
const decipher = crypto.createDecipheriv("aes-256-gcm", keyFor(pin, Buffer.from(b.salt, "base64"), b.iters), Buffer.from(b.iv, "base64"));
|
||
|
|
decipher.setAuthTag(ct.subarray(ct.length - 16));
|
||
|
|
masterPassword = Buffer.concat([decipher.update(ct.subarray(0, ct.length - 16)), decipher.final()]).toString("utf8");
|
||
|
|
} catch { masterPassword = null; }
|
||
|
|
}
|
||
|
|
if (masterPassword == null) {
|
||
|
|
rec.fails = (rec.fails || 0) + 1;
|
||
|
|
if (rec.fails >= MAX_FAILS) rec.requireMaster = true;
|
||
|
|
write(rec);
|
||
|
|
const remaining = Math.max(0, MAX_FAILS - rec.fails);
|
||
|
|
throw Object.assign(new Error(remaining ? "wrong PIN" : "too many wrong PINs, enter the master password"),
|
||
|
|
{ code: remaining ? "wrong-pin" : "master-required", remaining });
|
||
|
|
}
|
||
|
|
if (rec.fails) { rec.fails = 0; write(rec); }
|
||
|
|
return masterPassword;
|
||
|
|
},
|
||
|
|
|
||
|
|
// A successful master-password unlock clears the strikes.
|
||
|
|
resetFails() {
|
||
|
|
const rec = read();
|
||
|
|
if (rec && (rec.fails || rec.requireMaster)) { rec.fails = 0; rec.requireMaster = false; write(rec); }
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { createVaultPin, MAX_FAILS };
|