[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:
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);
|
||||
});
|
||||
Reference in New Issue
Block a user