Files
NetMesh/electron/bridges/credentialBridge.cjs

189 lines
6.5 KiB
JavaScript
Raw Normal View History

/**
* Credential Bridge - Field-level encryption for sensitive data at rest
*
* Uses Electron's safeStorage API to encrypt individual sensitive fields
* (passwords, tokens, private keys) before they are persisted to localStorage.
*
* Sentinel prefix "enc:v1:" on encrypted values enables:
* - Detection of already-encrypted vs plaintext (migration)
* - No double-encryption
* - Future re-keying with enc:v2: etc.
*
* When safeStorage is unavailable (e.g. Linux without libsecret), all values
* pass through unmodified so the app still works.
*/
const ENC_PREFIX = "enc:v1:";
/**
* Chromium/Electron safeStorage ciphertext carries known platform headers:
* - macOS/Linux: plaintext bytes start with "v10" or "v11"
* - Windows (legacy DPAPI blob): leading bytes are 0x01 0x00 0x00 0x00
*
* Detect headers on *decoded* bytes. A four-byte DPAPI version alone is not
* enough real blobs continue with provider GUID
* {df9d8cd0-1501-11d1-8c7a-00c04fc297eb} (base64 `AQAAANCMnd8...`).
*
* Keep in sync with domain/credentials.ts.
*
* v10/v11 CBC blobs are at least header(3) + one AES block(16) = 19 bytes.
* AES-GCM blobs are larger (nonce+tag), but CBC is the lower bound we must
* accept so short credentials are not mistaken for plaintext coincidences.
*/
const V10_HEADER = Buffer.from("v10", "utf8");
const V11_HEADER = Buffer.from("v11", "utf8");
// Version (4) + provider GUID {df9d8cd0-1501-11d1-8c7a-00c04fc297eb} (16).
const DPAPI_BLOB_PREFIX = Buffer.from([
0x01, 0x00, 0x00, 0x00,
0xd0, 0x8c, 0x9d, 0xdf, 0x01, 0x15, 0xd1, 0x11,
0x8c, 0x7a, 0x00, 0xc0, 0x4f, 0xc2, 0x97, 0xeb,
]);
const MIN_V10_V11_CIPHERTEXT_BYTES = 19;
const MIN_V10_V11_GCM_CIPHERTEXT_BYTES = 31; // header(3) + nonce(12) + tag(16)
// Header alone is 20 bytes; require at least one trailing payload byte.
const MIN_DPAPI_CIPHERTEXT_BYTES = DPAPI_BLOB_PREFIX.byteLength + 1;
const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
let safeStorage = null;
function startsWithBytes(buffer, prefix) {
if (buffer.byteLength < prefix.byteLength) return false;
return buffer.subarray(0, prefix.byteLength).equals(prefix);
}
/** CBC is header(3) + 16-byte blocks; GCM is at least header+nonce+tag (31). */
function isValidV10V11CiphertextLength(byteLength) {
if (byteLength >= MIN_V10_V11_GCM_CIPHERTEXT_BYTES) return true;
return byteLength >= MIN_V10_V11_CIPHERTEXT_BYTES
&& (byteLength - 3) % 16 === 0;
}
function looksLikeEncryptedCredential(value) {
if (typeof value !== "string" || !value.startsWith(ENC_PREFIX)) {
return false;
}
const payload = value.slice(ENC_PREFIX.length);
if (!payload || !BASE64_RE.test(payload)) return false;
// Reject header-only / truncated base64 that is not a full platform blob.
let decoded;
try {
decoded = Buffer.from(payload, "base64");
} catch {
return false;
}
if (startsWithBytes(decoded, V10_HEADER) || startsWithBytes(decoded, V11_HEADER)) {
return isValidV10V11CiphertextLength(decoded.byteLength);
}
if (startsWithBytes(decoded, DPAPI_BLOB_PREFIX)) {
return decoded.byteLength >= MIN_DPAPI_CIPHERTEXT_BYTES;
}
return false;
}
/**
* Encrypt a credential field. Never wraps an existing complete device-bound
* enc:v1 blob again even when trial decrypt fails (e.g. OSCrypt key
* rotated). Ambiguous `enc:v1:` prefixes that are not full ciphertext are
* treated as coincidental plaintext and encrypted normally.
*
* @param {string} plaintext
* @param {{ isEncryptionAvailable?: () => boolean, encryptString?: (v: string) => Buffer, decryptString?: (b: Buffer) => string } | null} storage
*/
function encryptCredentialValue(plaintext, storage = safeStorage) {
if (typeof plaintext !== "string" || plaintext.length === 0) {
return plaintext ?? "";
}
if (!storage?.isEncryptionAvailable?.()) {
return plaintext;
}
// Complete device-bound ciphertext: return as-is. Do not trial-decrypt then
// fall through to encryptString — that double-wraps when decrypt fails.
if (looksLikeEncryptedCredential(plaintext)) {
return plaintext;
}
// Ambiguous enc:v1: prefix (header-only / short coincidence): try decrypt.
// Success means a real blob we failed to classify — keep it. Failure means
// plaintext that happens to look prefixed — encrypt it.
if (plaintext.startsWith(ENC_PREFIX) && typeof storage.decryptString === "function") {
try {
const base64 = plaintext.slice(ENC_PREFIX.length);
const buf = Buffer.from(base64, "base64");
storage.decryptString(buf);
return plaintext;
} catch {
// coincidental plaintext — fall through
}
}
try {
const encrypted = storage.encryptString(plaintext);
return ENC_PREFIX + encrypted.toString("base64");
} catch (err) {
console.warn("[Credentials] encrypt failed, returning plaintext:", err?.message || err);
return plaintext;
}
}
/**
* Decrypt a credential field. On failure returns the ciphertext unchanged so
* callers can detect enc:v1 placeholders via looksLikeEncryptedCredential /
* isEncryptedCredentialPlaceholder.
*
* @param {string} value
* @param {{ isEncryptionAvailable?: () => boolean, decryptString?: (b: Buffer) => string } | null} storage
*/
function decryptCredentialValue(value, storage = safeStorage) {
if (typeof value !== "string" || value.length === 0) {
return value ?? "";
}
if (!value.startsWith(ENC_PREFIX)) {
return value;
}
if (!storage?.isEncryptionAvailable?.()) {
return value;
}
try {
const base64 = value.slice(ENC_PREFIX.length);
const buf = Buffer.from(base64, "base64");
return storage.decryptString(buf);
} catch (err) {
console.warn("[Credentials] decrypt failed:", err?.message || err);
return value;
}
}
/**
* Register IPC handlers for credential encryption/decryption
* @param {Electron.IpcMain} ipcMain
* @param {typeof Electron} electronModule
*/
function registerHandlers(ipcMain, electronModule) {
safeStorage = electronModule?.safeStorage ?? null;
ipcMain.handle("netcatty:credentials:available", () => {
return Boolean(safeStorage?.isEncryptionAvailable?.());
});
ipcMain.handle("netcatty:credentials:encrypt", (_event, plaintext) => {
return encryptCredentialValue(plaintext, safeStorage);
});
ipcMain.handle("netcatty:credentials:decrypt", (_event, value) => {
return decryptCredentialValue(value, safeStorage);
});
}
module.exports = {
ENC_PREFIX,
MIN_V10_V11_CIPHERTEXT_BYTES,
MIN_DPAPI_CIPHERTEXT_BYTES,
looksLikeEncryptedCredential,
encryptCredentialValue,
decryptCredentialValue,
registerHandlers,
};