logancyang_obsidian-copilot/src/encryptionService.ts
2026-05-15 18:26:40 -07:00

311 lines
11 KiB
TypeScript

/**
* Encryption/decryption service for Copilot secrets.
*
* After the OS Keychain migration, this module only provides:
* - Decryption of legacy encrypted values (for reading legacy encrypted disk values)
* - `isSensitiveKey()` — canonical field identification
* - `isEncryptedValue()` — prefix detection for encrypted strings
* - Base64 utilities
*
* All encryption-write functions have been removed because secrets
* are now written to the OS keychain directly (plaintext).
*/
// Reason: do NOT import from @/logger here. The logger depends on getSettings(),
// but this module runs during settings loading (before setSettings).
// Use console.* directly for all logging in this file.
// Reason: `buffer` is the npm polyfill (browser-compatible), bundled by
// esbuild so the same Buffer code path works on desktop (Electron) and
// mobile (WebView). Without this import, bare `Buffer` is undefined on
// mobile and throws "Can't find variable: Buffer" — see PR #2443.
// eslint-disable-next-line import/no-nodejs-modules
import { Buffer } from "buffer";
import { Platform } from "obsidian";
interface SafeStorage {
isEncryptionAvailable(): boolean;
encryptString(plainText: string): Buffer;
decryptString(encrypted: Buffer): string;
}
let safeStorageInternal: SafeStorage | null = null;
/**
* Safely get Electron `safeStorage` when available.
*
* @returns The Electron safeStorage instance, or `null` when unavailable.
*/
function getSafeStorage(): SafeStorage | null {
if (!Platform.isDesktopApp && !Platform.isDesktop) return null;
if (safeStorageInternal) return safeStorageInternal;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const electron = require("electron") as { remote?: { safeStorage?: SafeStorage } } | undefined;
safeStorageInternal = electron?.remote?.safeStorage ?? null;
return safeStorageInternal;
} catch {
return null;
}
}
// Prefixes to distinguish encryption methods
const DESKTOP_PREFIX = "enc_desk_";
const WEBCRYPTO_PREFIX = "enc_web_";
const ENCRYPTION_PREFIX = "enc_";
const DECRYPTION_PREFIX = "dec_";
/**
* Check whether a value looks like a well-formed encrypted Copilot secret
* (recognized prefix + plausible base64 payload).
*
* Use this when the caller wants strict detection — for example, deciding
* whether to display a value as decrypt-able in a UI. Returns false for
* values whose prefix matches but whose payload is corrupted, so callers
* that must protect such values during persistence should use
* `hasEncryptionPrefix()` instead.
*/
export function isEncryptedValue(value: string): boolean {
if (value.startsWith(DESKTOP_PREFIX)) {
return looksLikeBase64(value.slice(DESKTOP_PREFIX.length));
}
if (value.startsWith(WEBCRYPTO_PREFIX)) {
return looksLikeBase64(value.slice(WEBCRYPTO_PREFIX.length));
}
if (value.startsWith(ENCRYPTION_PREFIX)) {
return looksLikeBase64(value.slice(ENCRYPTION_PREFIX.length));
}
return false;
}
/**
* Permissive sibling of {@link isEncryptedValue}: returns true for any
* value carrying a Copilot encryption prefix, regardless of whether the
* payload still looks like valid base64.
*
* Reason: persistence code must treat "prefix matches but payload is
* corrupted" as encrypted-and-failed. Otherwise the keychain write path
* would treat a corrupted `enc_*` string as plaintext and silently
* overwrite the keychain with junk — and the disk-strip path would drop
* the only remaining copy. See `stripSecretsPreservingFailed`.
*/
export function hasEncryptionPrefix(value: string): boolean {
return (
value.startsWith(DESKTOP_PREFIX) ||
value.startsWith(WEBCRYPTO_PREFIX) ||
value.startsWith(ENCRYPTION_PREFIX)
);
}
/**
* Check whether a string is plausibly base64-encoded ciphertext.
*/
function looksLikeBase64(data: string): boolean {
if (!data) return false;
if (data.length % 4 === 1) return false;
return /^[A-Za-z0-9+/]+={0,2}$/.test(data);
}
// ---------------------------------------------------------------------------
// WebCrypto: decrypt-only support for legacy formats
// ---------------------------------------------------------------------------
const WEBCRYPTO_IV_LENGTH = 12;
const WEBCRYPTO_KEY_STORAGE_KEY = "obsidian-copilot:webcrypto-key:v1";
/** Magic header for portable payloads (hardcoded key + random IV). */
const WEBCRYPTO_PORTABLE_MAGIC = new Uint8Array([0x43, 0x50, 0x30, 0x30]); // "CP00"
/** Magic header for legacy per-device payloads (kept for backward-compat decryption). */
const WEBCRYPTO_DEVICE_MAGIC = new Uint8Array([0x43, 0x50, 0x30, 0x31]); // "CP01"
/**
* Portable AES-GCM key material shared across all devices.
* Reason: derives a 256-bit AES key via SHA-256.
*/
const PORTABLE_WEBCRYPTO_KEY_MATERIAL = new TextEncoder().encode("obsidian-copilot-v1");
const LEGACY_WEBCRYPTO_IV = new Uint8Array(WEBCRYPTO_IV_LENGTH);
function assertWebCryptoAvailable(): void {
if (!window.crypto?.subtle) {
throw new Error("WebCrypto API is not available in this environment.");
}
}
function getLocalStorageSafe(): Storage | null {
try {
return window.localStorage ?? null;
} catch {
return null;
}
}
/**
* Read an existing per-device AES-256 key from localStorage.
* Reason: kept for backward-compat decryption of CP01 payloads.
*/
async function getExistingWebCryptoKey(): Promise<CryptoKey | null> {
try {
const storage = getLocalStorageSafe();
if (!storage) return null;
const existing = storage.getItem(WEBCRYPTO_KEY_STORAGE_KEY);
if (!existing) return null;
const raw = base64ToArrayBuffer(existing);
return await crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
} catch (error) {
console.warn("Failed to load WebCrypto key from localStorage.", error);
return null;
}
}
/**
* Import the portable WebCrypto key for CP00 decryption
* and legacy fixed-IV decryption.
*/
async function getPortableWebCryptoKey(): Promise<CryptoKey> {
const keyBytes = await crypto.subtle.digest("SHA-256", PORTABLE_WEBCRYPTO_KEY_MATERIAL);
return await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]);
}
function hasWebCryptoMagic(bytes: Uint8Array, magic: Uint8Array): boolean {
if (bytes.length < magic.length) return false;
for (let i = 0; i < magic.length; i++) {
if (bytes[i] !== magic[i]) return false;
}
return true;
}
/**
* Decrypt a WEBCRYPTO_PREFIX value.
* Supports portable CP00, legacy per-device CP01, and legacy fixed-IV formats.
*/
async function decryptWebCryptoValue(base64Data: string): Promise<string> {
assertWebCryptoAvailable();
const raw = new Uint8Array(base64ToArrayBuffer(base64Data));
// Portable format: [CP00][IV][CIPHERTEXT]
if (hasWebCryptoMagic(raw, WEBCRYPTO_PORTABLE_MAGIC)) {
const ivStart = WEBCRYPTO_PORTABLE_MAGIC.length;
const ivEnd = ivStart + WEBCRYPTO_IV_LENGTH;
if (raw.length <= ivEnd) throw new Error("Invalid WebCrypto portable payload: too short.");
const iv = raw.slice(ivStart, ivEnd);
const ciphertext = raw.slice(ivEnd);
const key = await getPortableWebCryptoKey();
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
return new TextDecoder().decode(decrypted);
}
// Legacy per-device format: [CP01][IV][CIPHERTEXT]
if (hasWebCryptoMagic(raw, WEBCRYPTO_DEVICE_MAGIC)) {
const ivStart = WEBCRYPTO_DEVICE_MAGIC.length;
const ivEnd = ivStart + WEBCRYPTO_IV_LENGTH;
if (raw.length <= ivEnd) throw new Error("Invalid WebCrypto CP01 payload: too short.");
const iv = raw.slice(ivStart, ivEnd);
const ciphertext = raw.slice(ivEnd);
const key = await getExistingWebCryptoKey();
if (!key) throw new Error("Per-device WebCrypto key unavailable for CP01 decryption.");
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
return new TextDecoder().decode(decrypted);
}
// Legacy format: ciphertext only, fixed IV + hardcoded key
const key = await getPortableWebCryptoKey();
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: LEGACY_WEBCRYPTO_IV },
key,
base64ToArrayBuffer(base64Data)
);
return new TextDecoder().decode(decrypted);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Check whether a top-level settings key holds a sensitive value.
* This is the single source of truth for sensitive field identification.
*/
export function isSensitiveKey(key: string): boolean {
const lower = key.toLowerCase();
const normalized = lower.replace(/[_-]/g, "");
return (
normalized.includes("apikey") ||
lower.endsWith("token") ||
lower.endsWith("accesstoken") ||
lower.endsWith("secret") ||
lower.endsWith("password") ||
lower.endsWith("licensekey")
);
}
/**
* Decrypt an API key from any supported encryption format.
*
* Returns the plaintext value, or `""` on failure.
* For plaintext input, returns the value as-is.
*/
export async function getDecryptedKey(apiKey: string): Promise<string> {
try {
if (!apiKey || isPlainText(apiKey)) {
return apiKey;
}
if (isDecrypted(apiKey)) {
return apiKey.replace(DECRYPTION_PREFIX, "");
}
if (apiKey.startsWith(DESKTOP_PREFIX)) {
const safeStorage = getSafeStorage();
if (!safeStorage?.isEncryptionAvailable()) {
console.warn("Cannot decrypt desktop-encrypted key: safeStorage unavailable.");
return "";
}
const base64Data = apiKey.replace(DESKTOP_PREFIX, "");
const buffer = Buffer.from(base64Data, "base64");
return safeStorage.decryptString(buffer);
}
if (apiKey.startsWith(WEBCRYPTO_PREFIX)) {
const base64Data = apiKey.replace(WEBCRYPTO_PREFIX, "");
return await decryptWebCryptoValue(base64Data);
}
// Legacy enc_ prefix
const base64Data = apiKey.replace(ENCRYPTION_PREFIX, "");
if (getSafeStorage()?.isEncryptionAvailable()) {
try {
const buffer = Buffer.from(base64Data, "base64");
return getSafeStorage()!.decryptString(buffer);
} catch {
// Fall through to WebCrypto
}
}
return await decryptWebCryptoValue(base64Data);
} catch (err) {
console.error("Decryption failed:", err);
return "";
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function isPlainText(key: string): boolean {
return !key.startsWith(ENCRYPTION_PREFIX) && !key.startsWith(DECRYPTION_PREFIX);
}
function isDecrypted(keyBuffer: string): boolean {
return keyBuffer.startsWith(DECRYPTION_PREFIX);
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = window.atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}