Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
189 lines
6.5 KiB
JavaScript
189 lines
6.5 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|