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
674 lines
25 KiB
TypeScript
674 lines
25 KiB
TypeScript
/**
|
|
* Secure Field Adapter — Renderer-side helpers for field-level encryption
|
|
*
|
|
* Encrypts / decrypts individual sensitive fields within domain models before
|
|
* they are written to (or after they are read from) localStorage.
|
|
*
|
|
* The heavy lifting is done by Electron's safeStorage via the credential
|
|
* bridge IPC. When the bridge is unavailable (web fallback, tests) plaintext
|
|
* values pass through unmodified. Ciphertext (`enc:v1:` placeholders) stays
|
|
* ciphertext when decrypt is not ready or fails — never treat it as usable
|
|
* plaintext, and never persist empty over recoverable ciphertext.
|
|
*/
|
|
|
|
import type { GroupConfig, Host, Identity, ProxyProfile, SSHKey } from "../../domain/models";
|
|
import type { ProviderConnection, S3Config, WebDAVConfig } from "../../domain/sync";
|
|
import {
|
|
isEncryptedCredentialPlaceholder,
|
|
needsVaultStoredKeyHydration,
|
|
sanitizeCredentialValue,
|
|
} from "../../domain/credentials";
|
|
import { STORAGE_KEY_KEYS } from "../config/storageKeys";
|
|
import { netcattyBridge } from "../services/netcattyBridge";
|
|
import { localStorageAdapter } from "./localStorageAdapter";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Primitive helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const bridge = () => netcattyBridge.get();
|
|
|
|
const STORED_KEY_HYDRATE_RETRY_DELAY_MS = 50;
|
|
const STORED_KEY_HYDRATE_TIMEOUT_MS = 2000;
|
|
|
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keys write gate
|
|
//
|
|
// `useVaultState.updateKeys` / `importOrReuseKey` publish key state
|
|
// synchronously and write the encrypted snapshot to storage asynchronously.
|
|
// A connection started in that window must not hydrate from a stale
|
|
// localStorage entry — e.g. a sync/import recovery that cleared a private key
|
|
// would otherwise be undone by the previous persisted snapshot. The writer
|
|
// announces its in-flight write here; stored-key hydration awaits it before
|
|
// re-reading storage.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let keysEncryptedWritePending: Promise<unknown> | null = null;
|
|
|
|
/**
|
|
* Record the in-flight encrypted keys storage write so `hydrateStoredKeySecrets`
|
|
* cannot read an older persisted snapshot over newer application state.
|
|
* Called by the vault writer; failures of the tracked write are ignored here.
|
|
*/
|
|
export const notifyKeysEncryptedWritePending = (pending: Promise<unknown> | null): void => {
|
|
keysEncryptedWritePending = pending ?? null;
|
|
};
|
|
|
|
/**
|
|
* Drain any announced keys write. Returns true when at least one in-flight
|
|
* write was observed and has settled, which means the stored keys snapshot now
|
|
* reflects the current application state. If another write raced in while
|
|
* draining, it is awaited as well.
|
|
*/
|
|
const awaitKeysEncryptedWrite = async (): Promise<boolean> => {
|
|
let pending = keysEncryptedWritePending;
|
|
while (pending) {
|
|
try {
|
|
await pending;
|
|
} catch {
|
|
// A failed write leaves storage unchanged; proceed with what is there.
|
|
}
|
|
if (keysEncryptedWritePending !== pending) {
|
|
pending = keysEncryptedWritePending;
|
|
continue;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
export async function encryptField(value: string | undefined): Promise<string | undefined> {
|
|
if (!value) return value;
|
|
const b = bridge();
|
|
if (!b?.credentialsEncrypt) return value;
|
|
return b.credentialsEncrypt(value);
|
|
}
|
|
|
|
export type DecryptFieldResult = {
|
|
value: string | undefined;
|
|
unread: boolean;
|
|
};
|
|
|
|
/**
|
|
* Decrypt a field and distinguish plaintext from unread ciphertext.
|
|
* When decrypt is missing or fails, `unread` is true and `value` remains the
|
|
* original `enc:v1:` ciphertext so persistence can keep it.
|
|
*/
|
|
export async function decryptFieldResult(value: string | undefined): Promise<DecryptFieldResult> {
|
|
if (!value) return { value, unread: false };
|
|
const encrypted = isEncryptedCredentialPlaceholder(value);
|
|
const b = bridge();
|
|
if (!b?.credentialsDecrypt) {
|
|
return { value, unread: encrypted };
|
|
}
|
|
try {
|
|
const decrypted = await b.credentialsDecrypt(value);
|
|
if (
|
|
encrypted
|
|
&& (
|
|
!decrypted
|
|
|| decrypted === value
|
|
|| isEncryptedCredentialPlaceholder(decrypted)
|
|
)
|
|
) {
|
|
return { value, unread: true };
|
|
}
|
|
return { value: decrypted, unread: false };
|
|
} catch (err) {
|
|
if (encrypted) return { value, unread: true };
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function decryptField(value: string | undefined): Promise<string | undefined> {
|
|
return (await decryptFieldResult(value)).value;
|
|
}
|
|
|
|
const persistDecryptedSecret = (
|
|
original: string | undefined,
|
|
decrypted: string | undefined,
|
|
): string | undefined => {
|
|
if (isEncryptedCredentialPlaceholder(original) && !sanitizeCredentialValue(decrypted)) {
|
|
return original;
|
|
}
|
|
return decrypted;
|
|
};
|
|
|
|
const readStoredSshKey = (id: string): SSHKey | undefined => {
|
|
try {
|
|
const stored = localStorageAdapter.read<SSHKey[]>(STORAGE_KEY_KEYS);
|
|
if (!Array.isArray(stored)) return undefined;
|
|
return stored.find((key) => key?.id === id);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
export type HydratedStoredKey = {
|
|
key: SSHKey;
|
|
unreadable: boolean;
|
|
};
|
|
|
|
/**
|
|
* Retry decrypt for vault-stored (imported/generated) private keys instead of
|
|
* treating enc:v1: ciphertext as key material. Re-reads the encrypted snapshot
|
|
* from storage when in-memory privateKey was already stripped.
|
|
*/
|
|
export async function hydrateStoredKeySecrets(
|
|
key: SSHKey,
|
|
options?: { timeoutMs?: number; retryDelayMs?: number },
|
|
): Promise<HydratedStoredKey> {
|
|
if (key.source === "reference" || !needsVaultStoredKeyHydration(key)) {
|
|
return { key, unreadable: false };
|
|
}
|
|
|
|
const timeoutMs = options?.timeoutMs ?? STORED_KEY_HYDRATE_TIMEOUT_MS;
|
|
const retryDelayMs = options?.retryDelayMs ?? STORED_KEY_HYDRATE_RETRY_DELAY_MS;
|
|
const startedAt = Date.now();
|
|
let candidate = key;
|
|
let sawCiphertext = isEncryptedCredentialPlaceholder(candidate.privateKey);
|
|
|
|
while (true) {
|
|
if (!candidate.privateKey) {
|
|
// Coordinate with the vault writer: application state may have just
|
|
// cleared or replaced this key before its encrypted write landed in
|
|
// storage. Never overwrite the current state value with an older
|
|
// persisted snapshot.
|
|
const writerSettled = await awaitKeysEncryptedWrite();
|
|
const stored = readStoredSshKey(key.id);
|
|
if (stored?.privateKey && stored.privateKey !== candidate.privateKey) {
|
|
candidate = {
|
|
...candidate,
|
|
privateKey: stored.privateKey,
|
|
passphrase: stored.passphrase ?? candidate.passphrase,
|
|
};
|
|
} else if (writerSettled) {
|
|
// The settled writer's storage has no private key either — the
|
|
// credential was deliberately cleared upstream (sync / import
|
|
// recovery). Return empty immediately instead of spinning until the
|
|
// timeout so the removal stays authoritative.
|
|
return {
|
|
key: {
|
|
...candidate,
|
|
privateKey: "",
|
|
passphrase: isEncryptedCredentialPlaceholder(candidate.passphrase)
|
|
? undefined
|
|
: candidate.passphrase,
|
|
},
|
|
unreadable: sawCiphertext,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (isEncryptedCredentialPlaceholder(candidate.privateKey)) {
|
|
sawCiphertext = true;
|
|
}
|
|
|
|
const decryptedPrivate = await decryptFieldResult(candidate.privateKey || undefined);
|
|
const privateKey = decryptedPrivate.unread
|
|
? undefined
|
|
: sanitizeCredentialValue(decryptedPrivate.value);
|
|
if (privateKey) {
|
|
const decryptedPassphrase = candidate.passphrase != null
|
|
? await decryptFieldResult(candidate.passphrase)
|
|
: undefined;
|
|
return {
|
|
key: {
|
|
...candidate,
|
|
privateKey,
|
|
passphrase: decryptedPassphrase && !decryptedPassphrase.unread
|
|
? (sanitizeCredentialValue(decryptedPassphrase.value) ?? (
|
|
isEncryptedCredentialPlaceholder(candidate.passphrase)
|
|
? undefined
|
|
: candidate.passphrase
|
|
))
|
|
: (isEncryptedCredentialPlaceholder(candidate.passphrase)
|
|
? undefined
|
|
: candidate.passphrase),
|
|
},
|
|
unreadable: false,
|
|
};
|
|
}
|
|
|
|
const decryptReady = Boolean(bridge()?.credentialsDecrypt);
|
|
if (!decryptReady || Date.now() - startedAt >= timeoutMs) {
|
|
return {
|
|
key: {
|
|
...candidate,
|
|
privateKey: sanitizeCredentialValue(candidate.privateKey) ?? "",
|
|
passphrase: isEncryptedCredentialPlaceholder(candidate.passphrase)
|
|
? undefined
|
|
: candidate.passphrase,
|
|
},
|
|
unreadable: sawCiphertext,
|
|
};
|
|
}
|
|
|
|
await sleep(retryDelayMs);
|
|
const writerSettled = await awaitKeysEncryptedWrite();
|
|
const stored = readStoredSshKey(key.id);
|
|
if (writerSettled && !stored?.privateKey) {
|
|
// The settled writer's storage has no private key for this id — the
|
|
// credential was deliberately cleared upstream (sync / import
|
|
// recovery). Do not keep retrying the previous in-memory snapshot.
|
|
return {
|
|
key: {
|
|
...candidate,
|
|
privateKey: "",
|
|
passphrase: isEncryptedCredentialPlaceholder(candidate.passphrase)
|
|
? undefined
|
|
: candidate.passphrase,
|
|
},
|
|
unreadable: sawCiphertext,
|
|
};
|
|
}
|
|
if (stored?.privateKey) {
|
|
candidate = {
|
|
...candidate,
|
|
privateKey: stored.privateKey,
|
|
passphrase: stored.passphrase ?? candidate.passphrase,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function hydrateVaultStoredKeys(
|
|
keys: SSHKey[],
|
|
options?: { timeoutMs?: number; retryDelayMs?: number },
|
|
): Promise<{ keys: SSHKey[]; unreadableKeyIds: Set<string> }> {
|
|
const unreadableKeyIds = new Set<string>();
|
|
const next = await Promise.all(keys.map(async (key) => {
|
|
if (!needsVaultStoredKeyHydration(key)) return key;
|
|
const hydrated = await hydrateStoredKeySecrets(key, options);
|
|
if (hydrated.unreadable) unreadableKeyIds.add(key.id);
|
|
return hydrated.key;
|
|
}));
|
|
return { keys: next, unreadableKeyIds };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Host
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function encryptHostSecrets(host: Host): Promise<Host> {
|
|
const out = { ...host };
|
|
out.password = await encryptField(out.password);
|
|
out.telnetPassword = await encryptField(out.telnetPassword);
|
|
if (out.proxyConfig?.password) {
|
|
out.proxyConfig = { ...out.proxyConfig, password: await encryptField(out.proxyConfig.password) };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function decryptHostSecrets(host: Host): Promise<Host> {
|
|
const out = { ...host };
|
|
out.password = persistDecryptedSecret(out.password, await decryptField(out.password));
|
|
out.telnetPassword = persistDecryptedSecret(out.telnetPassword, await decryptField(out.telnetPassword));
|
|
if (out.proxyConfig?.password) {
|
|
out.proxyConfig = {
|
|
...out.proxyConfig,
|
|
password: persistDecryptedSecret(out.proxyConfig.password, await decryptField(out.proxyConfig.password)),
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSHKey
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function encryptKeySecrets(key: SSHKey): Promise<SSHKey> {
|
|
const out = { ...key };
|
|
out.passphrase = await encryptField(out.passphrase);
|
|
out.privateKey = (await encryptField(out.privateKey)) ?? "";
|
|
return out;
|
|
}
|
|
|
|
export async function decryptKeySecrets(key: SSHKey): Promise<SSHKey> {
|
|
const out = { ...key };
|
|
out.passphrase = persistDecryptedSecret(out.passphrase, await decryptField(out.passphrase));
|
|
out.privateKey = persistDecryptedSecret(out.privateKey, await decryptField(out.privateKey)) ?? out.privateKey ?? "";
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Identity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function encryptIdentitySecrets(identity: Identity): Promise<Identity> {
|
|
const out = { ...identity };
|
|
out.password = await encryptField(out.password);
|
|
return out;
|
|
}
|
|
|
|
export async function decryptIdentitySecrets(identity: Identity): Promise<Identity> {
|
|
const out = { ...identity };
|
|
out.password = persistDecryptedSecret(out.password, await decryptField(out.password));
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GroupConfig
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function encryptGroupConfigSecrets(config: GroupConfig): Promise<GroupConfig> {
|
|
const out = { ...config };
|
|
out.password = await encryptField(out.password);
|
|
out.telnetPassword = await encryptField(out.telnetPassword);
|
|
if (out.proxyConfig?.password) {
|
|
out.proxyConfig = { ...out.proxyConfig, password: await encryptField(out.proxyConfig.password) };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function decryptGroupConfigSecrets(config: GroupConfig): Promise<GroupConfig> {
|
|
const out = { ...config };
|
|
out.password = persistDecryptedSecret(out.password, await decryptField(out.password));
|
|
out.telnetPassword = persistDecryptedSecret(out.telnetPassword, await decryptField(out.telnetPassword));
|
|
if (out.proxyConfig?.password) {
|
|
out.proxyConfig = {
|
|
...out.proxyConfig,
|
|
password: persistDecryptedSecret(out.proxyConfig.password, await decryptField(out.proxyConfig.password)),
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function encryptGroupConfigs(configs: GroupConfig[]): Promise<GroupConfig[]> {
|
|
return Promise.all(configs.map(encryptGroupConfigSecrets));
|
|
}
|
|
|
|
export function decryptGroupConfigs(configs: GroupConfig[]): Promise<GroupConfig[]> {
|
|
return Promise.all(configs.map(decryptGroupConfigSecrets));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ProxyProfile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function encryptProxyProfileSecrets(profile: ProxyProfile): Promise<ProxyProfile> {
|
|
const out = { ...profile, config: { ...profile.config } };
|
|
out.config.password = await encryptField(out.config.password);
|
|
return out;
|
|
}
|
|
|
|
export async function decryptProxyProfileSecrets(profile: ProxyProfile): Promise<ProxyProfile> {
|
|
const out = { ...profile, config: { ...profile.config } };
|
|
out.config.password = persistDecryptedSecret(out.config.password, await decryptField(out.config.password));
|
|
return out;
|
|
}
|
|
|
|
export function encryptProxyProfiles(profiles: ProxyProfile[]): Promise<ProxyProfile[]> {
|
|
return Promise.all(profiles.map(encryptProxyProfileSecrets));
|
|
}
|
|
|
|
export function decryptProxyProfiles(profiles: ProxyProfile[]): Promise<ProxyProfile[]> {
|
|
return Promise.all(profiles.map(decryptProxyProfileSecrets));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider Connection (Cloud Sync)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Host-owned sealed-config envelope. Must be unambiguous against plugin-owned
|
|
* JSON: exactly one reserved key, no extra properties. Never treat a plugin
|
|
* object that merely contains a similar key as already sealed.
|
|
*/
|
|
const PLUGIN_CONFIG_ENVELOPE_KEY = "__netcatty_plugin_config_v1" as const;
|
|
const LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY = "__encryptedPluginConfig" as const;
|
|
/** At-rest envelope for ProviderConnection.credential (opaque refs only). */
|
|
const PLUGIN_CREDENTIAL_ENVELOPE_KEY = "__netcatty_plugin_credential_v1" as const;
|
|
|
|
type PluginConfigEnvelope = {
|
|
[PLUGIN_CONFIG_ENVELOPE_KEY]: string;
|
|
};
|
|
|
|
type PluginCredentialEnvelope = {
|
|
[PLUGIN_CREDENTIAL_ENVELOPE_KEY]: string;
|
|
};
|
|
|
|
function isPluginConfigEnvelope(value: unknown): value is PluginConfigEnvelope {
|
|
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const record = value as Record<string, unknown>;
|
|
const keys = Object.keys(record);
|
|
return keys.length === 1
|
|
&& keys[0] === PLUGIN_CONFIG_ENVELOPE_KEY
|
|
&& typeof record[PLUGIN_CONFIG_ENVELOPE_KEY] === "string";
|
|
}
|
|
|
|
/** Legacy envelope shape (still accepted on decrypt for one migration hop). */
|
|
function isLegacyPluginConfigEnvelope(value: unknown): value is { __encryptedPluginConfig: string } {
|
|
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const record = value as Record<string, unknown>;
|
|
const keys = Object.keys(record);
|
|
return keys.length === 1
|
|
&& keys[0] === LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY
|
|
&& typeof record[LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY] === "string";
|
|
}
|
|
|
|
function isPluginCredentialEnvelope(value: unknown): value is PluginCredentialEnvelope {
|
|
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const record = value as Record<string, unknown>;
|
|
const keys = Object.keys(record);
|
|
return keys.length === 1
|
|
&& keys[0] === PLUGIN_CREDENTIAL_ENVELOPE_KEY
|
|
&& typeof record[PLUGIN_CREDENTIAL_ENVELOPE_KEY] === "string";
|
|
}
|
|
|
|
export async function encryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
|
|
const out = { ...conn };
|
|
|
|
if (out.tokens) {
|
|
const t = { ...out.tokens };
|
|
t.accessToken = (await encryptField(t.accessToken)) ?? "";
|
|
t.refreshToken = await encryptField(t.refreshToken);
|
|
out.tokens = t;
|
|
}
|
|
|
|
// Config may be a valid falsy scalar (false, 0, "") — only null/undefined means absent.
|
|
if (out.config != null) {
|
|
const providerId = String(out.provider ?? "");
|
|
const isBuiltin = providerId === "webdav"
|
|
|| providerId === "s3"
|
|
|| providerId === "github"
|
|
|| providerId === "google"
|
|
|| providerId === "onedrive";
|
|
// Built-in providers use field-level encryption; plugin IDs always seal
|
|
// the whole opaque config so field-name collisions cannot leak secrets.
|
|
if (isBuiltin && typeof out.config === "object" && "authType" in out.config) {
|
|
const c = { ...out.config } as WebDAVConfig;
|
|
c.password = await encryptField(c.password);
|
|
c.token = await encryptField(c.token);
|
|
out.config = c;
|
|
} else if (isBuiltin && typeof out.config === "object" && "secretAccessKey" in out.config) {
|
|
const c = { ...out.config } as S3Config;
|
|
c.secretAccessKey = (await encryptField(c.secretAccessKey)) ?? "";
|
|
c.sessionToken = await encryptField(c.sessionToken);
|
|
out.config = c;
|
|
} else if (!isBuiltin) {
|
|
// Always (re)seal opaque plugin config. An exact marker-shaped object may
|
|
// be either a trusted host envelope or plugin-owned JSON that collides
|
|
// with our key — try unwrap; on failure seal the whole value as opaque.
|
|
let toSeal: unknown = out.config;
|
|
if (isPluginConfigEnvelope(out.config) || isLegacyPluginConfigEnvelope(out.config)) {
|
|
const sealedValue = isPluginConfigEnvelope(out.config)
|
|
? out.config[PLUGIN_CONFIG_ENVELOPE_KEY]
|
|
: out.config[LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY];
|
|
const plain = await decryptField(sealedValue);
|
|
if (plain != null && plain !== "") {
|
|
try {
|
|
toSeal = JSON.parse(plain);
|
|
} catch {
|
|
toSeal = out.config;
|
|
}
|
|
} else {
|
|
toSeal = out.config;
|
|
}
|
|
}
|
|
const sealed = await encryptField(JSON.stringify(toSeal));
|
|
if (sealed) {
|
|
out.config = {
|
|
[PLUGIN_CONFIG_ENVELOPE_KEY]: sealed,
|
|
} as ProviderConnection["config"];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Seal durable plugin credential refs as one opaque blob (same threat model
|
|
// as plugin config: do not leave kind/id/key plaintext in localStorage).
|
|
if (out.credential != null && typeof out.credential === "object") {
|
|
let toSeal: unknown = out.credential;
|
|
if (isPluginCredentialEnvelope(out.credential)) {
|
|
const plain = await decryptField(out.credential[PLUGIN_CREDENTIAL_ENVELOPE_KEY]);
|
|
if (plain != null && plain !== "") {
|
|
try {
|
|
toSeal = JSON.parse(plain);
|
|
} catch {
|
|
toSeal = out.credential;
|
|
}
|
|
} else {
|
|
toSeal = out.credential;
|
|
}
|
|
}
|
|
const kind = (toSeal as { kind?: unknown }).kind;
|
|
const id = (toSeal as { id?: unknown }).id;
|
|
const key = (toSeal as { key?: unknown }).key;
|
|
if ((kind === "secret" || kind === "credential") && typeof id === "string" && id.length > 0) {
|
|
const normalized = {
|
|
kind,
|
|
id,
|
|
...(typeof key === "string" ? { key } : {}),
|
|
};
|
|
const sealed = await encryptField(JSON.stringify(normalized));
|
|
if (sealed) {
|
|
out.credential = {
|
|
[PLUGIN_CREDENTIAL_ENVELOPE_KEY]: sealed,
|
|
} as unknown as ProviderConnection["credential"];
|
|
}
|
|
} else if (isPluginCredentialEnvelope(toSeal)) {
|
|
// Marker-collision object that is not a durable ref — seal as opaque JSON.
|
|
const sealed = await encryptField(JSON.stringify(toSeal));
|
|
if (sealed) {
|
|
out.credential = {
|
|
[PLUGIN_CREDENTIAL_ENVELOPE_KEY]: sealed,
|
|
} as unknown as ProviderConnection["credential"];
|
|
}
|
|
} else {
|
|
// Drop leases / malformed shapes — never persist them at rest.
|
|
delete out.credential;
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
export async function decryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
|
|
const out = { ...conn };
|
|
|
|
if (out.tokens) {
|
|
const t = { ...out.tokens };
|
|
t.accessToken = persistDecryptedSecret(t.accessToken, await decryptField(t.accessToken)) ?? t.accessToken ?? "";
|
|
t.refreshToken = persistDecryptedSecret(t.refreshToken, await decryptField(t.refreshToken));
|
|
out.tokens = t;
|
|
}
|
|
|
|
// Config may be a valid falsy scalar — only null/undefined means absent.
|
|
if (out.config != null) {
|
|
const providerId = String(out.provider ?? "");
|
|
const isBuiltin = providerId === "webdav"
|
|
|| providerId === "s3"
|
|
|| providerId === "github"
|
|
|| providerId === "google"
|
|
|| providerId === "onedrive";
|
|
if (isBuiltin && typeof out.config === "object" && "authType" in out.config) {
|
|
const c = { ...out.config } as WebDAVConfig;
|
|
c.password = persistDecryptedSecret(c.password, await decryptField(c.password));
|
|
c.token = persistDecryptedSecret(c.token, await decryptField(c.token));
|
|
out.config = c;
|
|
} else if (isBuiltin && typeof out.config === "object" && "secretAccessKey" in out.config) {
|
|
const c = { ...out.config } as S3Config;
|
|
c.secretAccessKey = persistDecryptedSecret(c.secretAccessKey, await decryptField(c.secretAccessKey))
|
|
?? c.secretAccessKey
|
|
?? "";
|
|
c.sessionToken = persistDecryptedSecret(c.sessionToken, await decryptField(c.sessionToken));
|
|
out.config = c;
|
|
} else if (isPluginConfigEnvelope(out.config) || isLegacyPluginConfigEnvelope(out.config)) {
|
|
const sealed = isPluginConfigEnvelope(out.config)
|
|
? out.config[PLUGIN_CONFIG_ENVELOPE_KEY]
|
|
: out.config[LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY];
|
|
const plain = await decryptFieldResult(sealed);
|
|
// Unread ciphertext must stay sealed. JSON "false"/"0"/'""' are valid plains.
|
|
if (!plain.unread && plain.value != null && plain.value !== "") {
|
|
try {
|
|
out.config = JSON.parse(plain.value) as ProviderConnection["config"];
|
|
} catch {
|
|
// leave sealed if corrupt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isPluginCredentialEnvelope(out.credential)) {
|
|
const plain = await decryptFieldResult(out.credential[PLUGIN_CREDENTIAL_ENVELOPE_KEY]);
|
|
if (!plain.unread && plain.value != null && plain.value !== "") {
|
|
try {
|
|
const parsed = JSON.parse(plain.value) as {
|
|
kind?: unknown;
|
|
id?: unknown;
|
|
key?: unknown;
|
|
};
|
|
if (
|
|
(parsed.kind === "secret" || parsed.kind === "credential")
|
|
&& typeof parsed.id === "string"
|
|
&& parsed.id.length > 0
|
|
) {
|
|
out.credential = {
|
|
kind: parsed.kind,
|
|
id: parsed.id,
|
|
...(typeof parsed.key === "string" ? { key: parsed.key } : {}),
|
|
};
|
|
} else {
|
|
delete out.credential;
|
|
}
|
|
} catch {
|
|
// leave sealed if corrupt
|
|
}
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Batch helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function encryptHosts(hosts: Host[]): Promise<Host[]> {
|
|
return Promise.all(hosts.map(encryptHostSecrets));
|
|
}
|
|
|
|
export function decryptHosts(hosts: Host[]): Promise<Host[]> {
|
|
return Promise.all(hosts.map(decryptHostSecrets));
|
|
}
|
|
|
|
export function encryptKeys(keys: SSHKey[]): Promise<SSHKey[]> {
|
|
return Promise.all(keys.map(encryptKeySecrets));
|
|
}
|
|
|
|
export function decryptKeys(keys: SSHKey[]): Promise<SSHKey[]> {
|
|
return Promise.all(keys.map(decryptKeySecrets));
|
|
}
|
|
|
|
export function encryptIdentities(identities: Identity[]): Promise<Identity[]> {
|
|
return Promise.all(identities.map(encryptIdentitySecrets));
|
|
}
|
|
|
|
export function decryptIdentities(identities: Identity[]): Promise<Identity[]> {
|
|
return Promise.all(identities.map(decryptIdentitySecrets));
|
|
}
|