[Init] Initial commit - NetMesh terminal manager
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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from "./localStorageAdapter.ts";
class TestCustomEvent<T = unknown> extends Event implements CustomEvent<T> {
readonly detail: T;
constructor(type: string, init?: CustomEventInit<T>) {
super(type);
this.detail = init?.detail as T;
}
initCustomEvent(): void {
// Deprecated browser API required by the CustomEvent interface.
}
}
const waitForAdapterEvents = () => new Promise((resolve) => {
setTimeout(resolve, 5);
});
function installLocalStorageEnvironment() {
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const previousCustomEvent = Object.getOwnPropertyDescriptor(globalThis, "CustomEvent");
const previousDispatchEvent = Object.getOwnPropertyDescriptor(globalThis, "dispatchEvent");
const backing = new Map<string, string>();
const events: string[] = [];
let setCalls = 0;
let removeCalls = 0;
const storage: Storage = {
get length() {
return backing.size;
},
clear() {
backing.clear();
},
getItem(key: string) {
return backing.get(key) ?? null;
},
key(index: number) {
return Array.from(backing.keys())[index] ?? null;
},
removeItem(key: string) {
removeCalls += 1;
backing.delete(key);
},
setItem(key: string, value: string) {
setCalls += 1;
backing.set(key, value);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: storage,
configurable: true,
});
Object.defineProperty(globalThis, "CustomEvent", {
value: TestCustomEvent,
configurable: true,
});
Object.defineProperty(globalThis, "dispatchEvent", {
value: (event: Event): boolean => {
if (event.type === LOCAL_STORAGE_ADAPTER_CHANGED_EVENT) {
events.push((event as CustomEvent<{ key: string }>).detail.key);
}
return true;
},
configurable: true,
});
const restoreDescriptor = (property: "localStorage" | "CustomEvent" | "dispatchEvent", descriptor?: PropertyDescriptor) => {
if (descriptor) {
Object.defineProperty(globalThis, property, descriptor);
return;
}
Reflect.deleteProperty(globalThis, property);
};
return {
events,
get setCalls() {
return setCalls;
},
get removeCalls() {
return removeCalls;
},
restore() {
restoreDescriptor("localStorage", previousLocalStorage);
restoreDescriptor("CustomEvent", previousCustomEvent);
restoreDescriptor("dispatchEvent", previousDispatchEvent);
},
};
}
test("localStorageAdapter skips unchanged writes and notifications", async (t) => {
const env = installLocalStorageEnvironment();
t.after(() => env.restore());
assert.equal(localStorageAdapter.writeString("netcatty:test", "one"), true);
await waitForAdapterEvents();
assert.deepEqual(env.events, ["netcatty:test"]);
assert.equal(env.setCalls, 1);
assert.equal(localStorageAdapter.writeString("netcatty:test", "one"), true);
await waitForAdapterEvents();
assert.deepEqual(env.events, ["netcatty:test"]);
assert.equal(env.setCalls, 1);
assert.equal(localStorageAdapter.write("netcatty:json", { ok: true }), true);
await waitForAdapterEvents();
assert.equal(localStorageAdapter.write("netcatty:json", { ok: true }), true);
await waitForAdapterEvents();
assert.deepEqual(env.events, ["netcatty:test", "netcatty:json"]);
assert.equal(env.setCalls, 2);
});
test("localStorageAdapter skips missing removes and notifications", async (t) => {
const env = installLocalStorageEnvironment();
t.after(() => env.restore());
localStorageAdapter.remove("netcatty:missing");
await waitForAdapterEvents();
assert.deepEqual(env.events, []);
assert.equal(env.removeCalls, 0);
assert.equal(localStorageAdapter.writeString("netcatty:test", "one"), true);
await waitForAdapterEvents();
localStorageAdapter.remove("netcatty:test");
await waitForAdapterEvents();
assert.deepEqual(env.events, ["netcatty:test", "netcatty:test"]);
assert.equal(env.removeCalls, 1);
});

View File

@@ -0,0 +1,111 @@
const safeParse = <T>(value: string | null): T | null => {
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
};
export const LOCAL_STORAGE_ADAPTER_CHANGED_EVENT = 'netcatty:local-storage-adapter-changed';
const pendingChangedKeys = new Set<string>();
let emitChangedKeysTimer: ReturnType<typeof setTimeout> | null = null;
function dispatchLocalStorageAdapterChanged(key: string): void {
try {
const target = globalThis as typeof globalThis & {
dispatchEvent?: (event: Event) => boolean;
CustomEvent?: typeof CustomEvent;
};
if (typeof target.dispatchEvent !== 'function' || typeof target.CustomEvent !== 'function') return;
target.dispatchEvent(new target.CustomEvent<{ key: string }>(
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
{ detail: { key } },
));
} catch {
// ignore
}
}
function emitLocalStorageAdapterChanged(key: string): void {
pendingChangedKeys.add(key);
if (emitChangedKeysTimer) return;
// Defer same-window storage notifications so React render-phase writes do
// not synchronously trigger state updates in unrelated components.
emitChangedKeysTimer = setTimeout(() => {
emitChangedKeysTimer = null;
const keys = Array.from(pendingChangedKeys);
pendingChangedKeys.clear();
for (const changedKey of keys) {
dispatchLocalStorageAdapterChanged(changedKey);
}
}, 0);
}
/**
* Safely write to localStorage, catching QuotaExceededError.
* Returns true if the write succeeded, false if storage quota was exceeded.
*/
function safeSetItem(key: string, value: string): boolean {
try {
if (localStorage.getItem(key) === value) {
return true;
}
localStorage.setItem(key, value);
emitLocalStorageAdapterChanged(key);
return true;
} catch (err) {
if (
err instanceof DOMException &&
(err.name === 'QuotaExceededError' || err.code === 22)
) {
console.warn(
`[localStorageAdapter] QuotaExceededError writing key "${key}" (${value.length} chars). Data was not persisted.`,
);
return false;
}
throw err; // Re-throw unexpected errors
}
}
export const localStorageAdapter = {
read<T>(key: string): T | null {
return safeParse<T>(localStorage.getItem(key));
},
write<T>(key: string, value: T): boolean {
const json = JSON.stringify(value);
return safeSetItem(key, json);
},
readString(key: string): string | null {
return localStorage.getItem(key);
},
writeString(key: string, value: string): boolean {
return safeSetItem(key, value);
},
readBoolean(key: string): boolean | null {
const value = localStorage.getItem(key);
if (value === null) return null;
if (value === "true") return true;
if (value === "false") return false;
return null;
},
writeBoolean(key: string, value: boolean): boolean {
return safeSetItem(key, value ? "true" : "false");
},
readNumber(key: string): number | null {
const value = localStorage.getItem(key);
if (!value) return null;
const num = parseInt(value, 10);
return isNaN(num) ? null : num;
},
writeNumber(key: string, value: number): boolean {
return safeSetItem(key, String(value));
},
remove(key: string) {
if (localStorage.getItem(key) === null) return;
localStorage.removeItem(key);
emitLocalStorageAdapterChanged(key);
},
};

View File

@@ -0,0 +1,263 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SSHKey } from "../../domain/models";
import { ENCRYPTED_CREDENTIAL_PLACEHOLDER } from "../../domain/credentialsTestFixtures";
import { STORAGE_KEY_KEYS } from "../config/storageKeys";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../domain/credentials";
import { localStorageAdapter } from "./localStorageAdapter.ts";
import {
decryptField,
decryptFieldResult,
decryptKeySecrets,
decryptKeys,
encryptKeys,
hydrateStoredKeySecrets,
notifyKeysEncryptedWritePending,
} from "./secureFieldAdapter.ts";
const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n-----END OPENSSH PRIVATE KEY-----";
const storedKey = (overrides: Partial<SSHKey> = {}): SSHKey => ({
id: "key-1",
label: "Imported",
type: "ED25519",
privateKey: ENCRYPTED_CREDENTIAL_PLACEHOLDER,
source: "imported",
category: "key",
created: 1,
...overrides,
});
function installLocalStorage(t: test.TestContext): Map<string, string> {
const store = new Map<string, string>();
const storage: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, value);
},
};
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else delete (globalThis as { localStorage?: Storage }).localStorage;
});
return store;
}
function installBridge(
t: test.TestContext,
netcatty: { credentialsDecrypt?: (value: string) => Promise<string> } | undefined,
): void {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty },
});
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else delete (globalThis as { window?: unknown }).window;
});
}
test("decryptField marks enc:v1 unread without treating it as plaintext when the bridge is missing", async (t) => {
installBridge(t, undefined);
const result = await decryptFieldResult(ENCRYPTED_CREDENTIAL_PLACEHOLDER);
assert.equal(result.unread, true);
assert.equal(result.value, ENCRYPTED_CREDENTIAL_PLACEHOLDER);
assert.equal(await decryptField(ENCRYPTED_CREDENTIAL_PLACEHOLDER), ENCRYPTED_CREDENTIAL_PLACEHOLDER);
assert.equal(sanitizeCredentialValue(result.value), undefined);
assert.equal(await decryptField("plain-secret"), "plain-secret");
assert.equal((await decryptFieldResult("plain-secret")).unread, false);
});
test("decryptField marks enc:v1 unread when decrypt returns the same value", async (t) => {
installBridge(t, {
credentialsDecrypt: async (value: string) => value,
});
const result = await decryptFieldResult(ENCRYPTED_CREDENTIAL_PLACEHOLDER);
assert.equal(result.unread, true);
assert.equal(result.value, ENCRYPTED_CREDENTIAL_PLACEHOLDER);
assert.equal(sanitizeCredentialValue(result.value), undefined);
});
test("decryptField keeps enc:v1 ciphertext when decrypt throws", async (t) => {
installBridge(t, {
credentialsDecrypt: async () => {
throw new Error("safeStorage unavailable");
},
});
const result = await decryptFieldResult(ENCRYPTED_CREDENTIAL_PLACEHOLDER);
assert.equal(result.unread, true);
assert.equal(result.value, ENCRYPTED_CREDENTIAL_PLACEHOLDER);
});
test("decryptField returns plaintext once the credential bridge decrypts", async (t) => {
installBridge(t, {
credentialsDecrypt: async (value: string) => {
assert.equal(value, ENCRYPTED_CREDENTIAL_PLACEHOLDER);
return PRIVATE_KEY;
},
});
const result = await decryptFieldResult(ENCRYPTED_CREDENTIAL_PLACEHOLDER);
assert.equal(result.unread, false);
assert.equal(result.value, PRIVATE_KEY);
assert.equal(await decryptField(ENCRYPTED_CREDENTIAL_PLACEHOLDER), PRIVATE_KEY);
});
test("decryptKeySecrets keeps enc:v1 privateKey ciphertext until decrypt succeeds", async (t) => {
installBridge(t, undefined);
const decrypted = await decryptKeySecrets(storedKey());
assert.equal(decrypted.privateKey, ENCRYPTED_CREDENTIAL_PLACEHOLDER);
assert.equal(isEncryptedCredentialPlaceholder(decrypted.privateKey), true);
});
test("failed decrypt does not persist wiping enc:v1 key material", async (t) => {
installLocalStorage(t);
installBridge(t, undefined);
const decrypted = await decryptKeys([storedKey()]);
assert.equal(decrypted[0]?.privateKey, ENCRYPTED_CREDENTIAL_PLACEHOLDER);
const encrypted = await encryptKeys(decrypted);
assert.equal(encrypted[0]?.privateKey, ENCRYPTED_CREDENTIAL_PLACEHOLDER);
localStorageAdapter.write(STORAGE_KEY_KEYS, encrypted);
const stored = localStorageAdapter.read<SSHKey[]>(STORAGE_KEY_KEYS);
assert.equal(stored?.[0]?.privateKey, ENCRYPTED_CREDENTIAL_PLACEHOLDER);
});
test("hydrateStoredKeySecrets waits until ciphertext decrypts", async (t) => {
installLocalStorage(t);
let attempts = 0;
installBridge(t, {
credentialsDecrypt: async (value: string) => {
attempts += 1;
if (attempts < 3) return value;
return PRIVATE_KEY;
},
});
const hydrated = await hydrateStoredKeySecrets(storedKey(), {
timeoutMs: 500,
retryDelayMs: 10,
});
assert.equal(hydrated.unreadable, false);
assert.equal(hydrated.key.privateKey, PRIVATE_KEY);
assert.ok(attempts >= 3);
});
test("hydrateStoredKeySecrets re-reads storage when in-memory privateKey was stripped", async (t) => {
const store = installLocalStorage(t);
store.set(STORAGE_KEY_KEYS, JSON.stringify([storedKey()]));
installBridge(t, {
credentialsDecrypt: async (value: string) => {
assert.equal(value, ENCRYPTED_CREDENTIAL_PLACEHOLDER);
return PRIVATE_KEY;
},
});
const hydrated = await hydrateStoredKeySecrets(storedKey({ privateKey: "" }), {
timeoutMs: 100,
retryDelayMs: 10,
});
assert.equal(hydrated.unreadable, false);
assert.equal(hydrated.key.privateKey, PRIVATE_KEY);
});
test("hydrateStoredKeySecrets does not revive a stale persisted key while the vault write is pending", async (t) => {
const store = installLocalStorage(t);
store.set(
STORAGE_KEY_KEYS,
JSON.stringify([storedKey({ privateKey: ENCRYPTED_CREDENTIAL_PLACEHOLDER })]),
);
installBridge(t, {
credentialsDecrypt: async (value: string) => {
assert.equal(value, ENCRYPTED_CREDENTIAL_PLACEHOLDER);
return PRIVATE_KEY;
},
});
const keyAfterRecovery = storedKey({ privateKey: "" });
let landWrite: () => void = () => {};
const pendingWrite = new Promise<void>((resolve) => {
landWrite = () => {
// A sync/import recovery replaced the key with an empty private key; the
// async encrypted write publishes that state when it lands.
store.set(STORAGE_KEY_KEYS, JSON.stringify([keyAfterRecovery]));
resolve();
};
});
notifyKeysEncryptedWritePending(pendingWrite);
t.after(() => notifyKeysEncryptedWritePending(null));
let hydrationSettled = false;
const hydration = hydrateStoredKeySecrets(keyAfterRecovery, {
timeoutMs: 2000,
retryDelayMs: 10,
}).then((result) => {
hydrationSettled = true;
return result;
});
await new Promise((resolve) => setTimeout(resolve, 50));
// Without the gate the stale persisted ciphertext would hydrate immediately.
assert.equal(hydrationSettled, false);
landWrite();
const hydrated = await hydration;
assert.equal(hydrated.unreadable, false);
assert.equal(hydrated.key.privateKey, "");
assert.equal(hydrated.key.passphrase, undefined);
});
test("hydrateStoredKeySecrets hydrates from settled storage after the vault write lands", async (t) => {
const store = installLocalStorage(t);
installBridge(t, {
credentialsDecrypt: async () => PRIVATE_KEY,
});
let landWrite: () => void = () => {};
const pendingWrite = new Promise<void>((resolve) => {
landWrite = () => {
store.set(STORAGE_KEY_KEYS, JSON.stringify([storedKey()]));
resolve();
};
});
notifyKeysEncryptedWritePending(pendingWrite);
t.after(() => notifyKeysEncryptedWritePending(null));
const hydration = hydrateStoredKeySecrets(storedKey({ privateKey: "" }), {
timeoutMs: 2000,
retryDelayMs: 10,
});
landWrite();
const hydrated = await hydration;
assert.equal(hydrated.unreadable, false);
assert.equal(hydrated.key.privateKey, PRIVATE_KEY);
});
test("hydrateStoredKeySecrets does not wait when there is no decrypt bridge", async (t) => {
installBridge(t, undefined);
const started = Date.now();
const hydrated = await hydrateStoredKeySecrets(storedKey(), {
timeoutMs: 2000,
retryDelayMs: 50,
});
assert.equal(hydrated.unreadable, true);
assert.equal(hydrated.key.privateKey, "");
assert.ok(Date.now() - started < 200);
});

View File

@@ -0,0 +1,673 @@
/**
* 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));
}

View File

@@ -0,0 +1,21 @@
import { STORAGE_KEY_SNIPPET_VAR_VALUES } from '../config/storageKeys';
import { localStorageAdapter } from './localStorageAdapter';
export type SnippetVariableValuesStore = Record<string, Record<string, string>>;
export function readSnippetVariableValuesStore(): SnippetVariableValuesStore {
return localStorageAdapter.read<SnippetVariableValuesStore>(STORAGE_KEY_SNIPPET_VAR_VALUES) ?? {};
}
export function readSnippetVariableValuesForSnippet(snippetId: string): Record<string, string> {
return readSnippetVariableValuesStore()[snippetId] ?? {};
}
export function saveSnippetVariableValues(
snippetId: string,
values: Record<string, string>,
): void {
const store = readSnippetVariableValuesStore();
store[snippetId] = { ...store[snippetId], ...values };
localStorageAdapter.write(STORAGE_KEY_SNIPPET_VAR_VALUES, store);
}