[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
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:
143
infrastructure/persistence/localStorageAdapter.test.ts
Normal file
143
infrastructure/persistence/localStorageAdapter.test.ts
Normal 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);
|
||||
});
|
||||
111
infrastructure/persistence/localStorageAdapter.ts
Normal file
111
infrastructure/persistence/localStorageAdapter.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
263
infrastructure/persistence/secureFieldAdapter.test.ts
Normal file
263
infrastructure/persistence/secureFieldAdapter.test.ts
Normal 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);
|
||||
});
|
||||
673
infrastructure/persistence/secureFieldAdapter.ts
Normal file
673
infrastructure/persistence/secureFieldAdapter.ts
Normal 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));
|
||||
}
|
||||
21
infrastructure/persistence/snippetVariableValuesStorage.ts
Normal file
21
infrastructure/persistence/snippetVariableValuesStorage.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user