Files
NetMesh/electron/plugins/credentialCatalog.cjs

104 lines
3.8 KiB
JavaScript
Raw Normal View History

"use strict";
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { MAX_SECRET_BYTES } = require("./secretStore.cjs");
const ENCRYPTED_CREDENTIAL_PREFIX = "enc:v1:";
const MAX_CREDENTIAL_CATALOG_ENTRIES = 4_096;
const MAX_CREDENTIAL_CIPHERTEXT_BYTES = 4 * 1024 * 1024;
function assertCredentialId(id) {
if (typeof id !== "string" || id.length < 16 || id.length > 256 || id.includes("\0")) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential reference is invalid");
}
return id;
}
function decodeCiphertext(value) {
if (typeof value !== "string" || !value.startsWith(ENCRYPTED_CREDENTIAL_PREFIX)) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential must use OS-backed encryption");
}
const encoded = value.slice(ENCRYPTED_CREDENTIAL_PREFIX.length);
if (!encoded || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential ciphertext is invalid");
}
const ciphertext = Buffer.from(encoded, "base64");
if (ciphertext.byteLength < 1 || ciphertext.byteLength > MAX_CREDENTIAL_CIPHERTEXT_BYTES) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential ciphertext is invalid or too large");
}
return ciphertext;
}
class PluginCredentialCatalog {
constructor(options = {}) {
this.safeStorage = options.safeStorage ?? null;
this.records = new Map();
}
#assertAvailable() {
const backend = this.safeStorage?.getSelectedStorageBackend?.();
if (!this.safeStorage?.isEncryptionAvailable?.()
|| backend === "basic_text"
|| typeof this.safeStorage.decryptString !== "function") {
throw new PluginRpcError(
RPC_ERRORS.unavailable,
"Secure OS-backed Vault credential decryption is unavailable",
);
}
}
update(entries) {
if (!Array.isArray(entries) || entries.length > MAX_CREDENTIAL_CATALOG_ENTRIES) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential catalog is invalid or too large");
}
try {
this.#assertAvailable();
} catch (error) {
this.records.clear();
throw error;
}
const next = new Map();
for (const entry of entries) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)
|| Object.keys(entry).some((key) => key !== "id" && key !== "ciphertext")) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential catalog entry is invalid");
}
const id = assertCredentialId(entry.id);
if (next.has(id)) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential catalog contains duplicate references");
next.set(id, Buffer.from(decodeCiphertext(entry.ciphertext)));
}
this.records = next;
return this.records.size;
}
async assertReference(reference) {
const id = assertCredentialId(reference?.id);
if (!this.records.has(id)) throw new PluginRpcError(RPC_ERRORS.notFound, "Vault credential reference was not found");
}
async resolve(reference) {
this.#assertAvailable();
const id = assertCredentialId(reference?.id);
const ciphertext = this.records.get(id);
if (!ciphertext) throw new PluginRpcError(RPC_ERRORS.notFound, "Vault credential reference was not found");
let value;
try { value = this.safeStorage.decryptString(Buffer.from(ciphertext)); }
catch { throw new PluginRpcError(RPC_ERRORS.dataLoss, "Vault credential could not be decrypted"); }
if (typeof value !== "string" || value.length < 1 || Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) {
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Vault credential is invalid or too large");
}
return value;
}
shutdown() {
this.records.clear();
}
}
module.exports = {
ENCRYPTED_CREDENTIAL_PREFIX,
MAX_CREDENTIAL_CATALOG_ENTRIES,
PluginCredentialCatalog,
assertCredentialId,
};