[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:
960
application/state/defaultKeyPassphrases.test.ts
Normal file
960
application/state/defaultKeyPassphrases.test.ts
Normal file
@@ -0,0 +1,960 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
clearKeyPassphrasesByIds,
|
||||
clearRememberedKeyPassphrases,
|
||||
clearReferenceKeyPassphrases,
|
||||
deleteVaultKey,
|
||||
loadDefaultKeyPassphrase,
|
||||
readDefaultKeyPassphraseForExport,
|
||||
readDefaultKeyPassphrasesForVerification,
|
||||
readRememberedKeyPassphrases,
|
||||
rememberImportedKeyPassphrase,
|
||||
rememberKeyPassphrase,
|
||||
removeDefaultKeyPassphraseAliases,
|
||||
saveDefaultKeyPassphrase,
|
||||
shouldUpdateReferenceKeyPassphrase,
|
||||
} from "../defaultKeyPassphrases";
|
||||
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../../infrastructure/config/storageKeys";
|
||||
import type { SSHKey } from "../../domain/models";
|
||||
|
||||
function installLocalStorage(t: test.TestContext): void {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: undefined },
|
||||
});
|
||||
|
||||
t.after(() => {
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
}
|
||||
|
||||
const referenceKey = (): SSHKey => ({
|
||||
id: "reference-key",
|
||||
label: "id_ed25519",
|
||||
type: "ED25519",
|
||||
category: "key",
|
||||
source: "reference",
|
||||
filePath: "/Users/alice/.ssh/id_ed25519",
|
||||
privateKey: "",
|
||||
created: 1,
|
||||
});
|
||||
|
||||
test("deleting a reference key also forgets its remembered passphrase", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const key = referenceKey();
|
||||
let keys = [key];
|
||||
await saveDefaultKeyPassphrase(key.filePath!, "remembered-passphrase");
|
||||
|
||||
await deleteVaultKey({
|
||||
keyId: key.id,
|
||||
getKeys: () => keys,
|
||||
updateKeys: (updated) => {
|
||||
keys = updated;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(keys, []);
|
||||
assert.equal(await loadDefaultKeyPassphrase(key.filePath!), null);
|
||||
});
|
||||
|
||||
test("deleting one reference keeps the passphrase while another key uses the same file", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const key = referenceKey();
|
||||
const duplicate = { ...key, id: "duplicate-reference" };
|
||||
let keys = [key, duplicate];
|
||||
await saveDefaultKeyPassphrase(key.filePath!, "remembered-passphrase");
|
||||
|
||||
await deleteVaultKey({
|
||||
keyId: key.id,
|
||||
getKeys: () => keys,
|
||||
updateKeys: (updated) => {
|
||||
keys = updated;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(keys, [duplicate]);
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase(key.filePath!),
|
||||
"remembered-passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
test("deleting a key cannot overwrite a concurrent key-list update", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const key = referenceKey();
|
||||
const concurrentReference = { ...key, id: "concurrent-reference" };
|
||||
let keys = [key];
|
||||
await saveDefaultKeyPassphrase(key.filePath!, "remembered-passphrase");
|
||||
let releaseHomeLookup: (() => void) | undefined;
|
||||
const homeLookup = new Promise<void>((resolve) => {
|
||||
releaseHomeLookup = resolve;
|
||||
});
|
||||
let homeLookupCount = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => {
|
||||
homeLookupCount += 1;
|
||||
await homeLookup;
|
||||
return "/Users/alice";
|
||||
} } },
|
||||
});
|
||||
|
||||
const deletion = deleteVaultKey({
|
||||
keyId: key.id,
|
||||
getKeys: () => keys,
|
||||
updateKeys: (updated) => {
|
||||
keys = updated;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(keys, []);
|
||||
keys = [concurrentReference];
|
||||
releaseHomeLookup?.();
|
||||
await deletion;
|
||||
|
||||
assert.deepEqual(keys, [concurrentReference]);
|
||||
assert.equal(homeLookupCount, 1);
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase(key.filePath!),
|
||||
"remembered-passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
test("deleting a Windows reference clears remembered path aliases", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "C:\\Users\\Alice" } },
|
||||
});
|
||||
const key = {
|
||||
...referenceKey(),
|
||||
filePath: "~/.ssh/id_ed25519",
|
||||
};
|
||||
let keys = [key];
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "old-relative",
|
||||
"C:\\Users\\Alice\\.ssh\\id_ed25519": "old-native",
|
||||
"C:/Users/Alice/.ssh/id_ed25519": "old-normalized",
|
||||
"C:/Users/Alice/.ssh/other": "keep",
|
||||
}),
|
||||
);
|
||||
|
||||
await deleteVaultKey({
|
||||
keyId: key.id,
|
||||
getKeys: () => keys,
|
||||
updateKeys: (updated) => {
|
||||
keys = updated;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(keys, []);
|
||||
assert.deepEqual(
|
||||
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
|
||||
{ "C:/Users/Alice/.ssh/other": "keep" },
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
[keyPath]: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
"/Users/alice/.ssh/id_rsa": "still-valid",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await loadDefaultKeyPassphrase(keyPath);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.deepEqual(
|
||||
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
|
||||
{ "/Users/alice/.ssh/id_rsa": "still-valid" },
|
||||
);
|
||||
});
|
||||
|
||||
test("export read reports unavailable encrypted passphrases without deleting them", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: encrypted }),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
await readDefaultKeyPassphraseForExport(keyPath),
|
||||
{ status: "unreadable" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
|
||||
{ [keyPath]: encrypted },
|
||||
);
|
||||
});
|
||||
|
||||
test("export read retries when the passphrase changes during decryption", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||
let releaseDecrypt: (() => void) | undefined;
|
||||
const decryptGate = new Promise<void>((resolve) => {
|
||||
releaseDecrypt = resolve;
|
||||
});
|
||||
let firstDecryptStarted: (() => void) | undefined;
|
||||
const firstDecrypt = new Promise<void>((resolve) => {
|
||||
firstDecryptStarted = resolve;
|
||||
});
|
||||
let decryptCount = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => "/Users/alice",
|
||||
credentialsDecrypt: async (value: string) => {
|
||||
decryptCount += 1;
|
||||
if (decryptCount === 1) {
|
||||
firstDecryptStarted?.();
|
||||
await decryptGate;
|
||||
return "old-secret";
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: encrypted }),
|
||||
);
|
||||
|
||||
const pendingRead = readDefaultKeyPassphraseForExport(keyPath);
|
||||
await firstDecrypt;
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: "new-secret" }),
|
||||
);
|
||||
releaseDecrypt?.();
|
||||
|
||||
assert.deepEqual(await pendingRead, { status: "readable", value: "new-secret" });
|
||||
});
|
||||
|
||||
test("remembered passphrase read includes Keychain reference-key values", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const key = {
|
||||
...referenceKey(),
|
||||
passphrase: "keychain-secret",
|
||||
savePassphrase: true,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
await readRememberedKeyPassphrases(key.filePath!, [key]),
|
||||
{ values: ["keychain-secret"], unreadable: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("remembered passphrase read marks encrypted Keychain placeholders unreadable", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const key = {
|
||||
...referenceKey(),
|
||||
passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
savePassphrase: true,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
await readRememberedKeyPassphrases(key.filePath!, [key]),
|
||||
{ values: [], unreadable: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("passphrase verification reads every alias and preserves unreadable state", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => "/Users/alice",
|
||||
credentialsDecrypt: async (value: string) => value,
|
||||
},
|
||||
},
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "readable-secret",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
await readDefaultKeyPassphrasesForVerification("~/.ssh/id_ed25519"),
|
||||
{ values: ["readable-secret"], unreadable: true, present: true },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await readRememberedKeyPassphrases("~/.ssh/id_ed25519", []),
|
||||
{ values: ["readable-secret"], unreadable: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("passphrase verification preserves conflicting readable alias values", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "relative-secret",
|
||||
"/Users/alice/.ssh/id_ed25519": "absolute-secret",
|
||||
}),
|
||||
);
|
||||
|
||||
const read = await readRememberedKeyPassphrases("~/.ssh/id_ed25519", []);
|
||||
assert.deepEqual(new Set(read.values), new Set(["relative-secret", "absolute-secret"]));
|
||||
assert.equal(read.unreadable, false);
|
||||
});
|
||||
|
||||
test("imported passphrase cannot overwrite a correction queued first", async (t) => {
|
||||
installLocalStorage(t);
|
||||
let releaseEncrypt: (() => void) | undefined;
|
||||
const encryptGate = new Promise<void>((resolve) => {
|
||||
releaseEncrypt = resolve;
|
||||
});
|
||||
let encryptStarted: (() => void) | undefined;
|
||||
const firstEncrypt = new Promise<void>((resolve) => {
|
||||
encryptStarted = resolve;
|
||||
});
|
||||
let encryptCount = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
credentialsEncrypt: async (value: string) => {
|
||||
encryptCount += 1;
|
||||
if (encryptCount === 1) {
|
||||
encryptStarted?.();
|
||||
await encryptGate;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const correction = saveDefaultKeyPassphrase("~/.ssh/id_ed25519", "current-secret");
|
||||
await firstEncrypt;
|
||||
const imported = rememberImportedKeyPassphrase({
|
||||
keyPath: "~/.ssh/id_ed25519",
|
||||
passphrase: "stale-import-secret",
|
||||
keys: [],
|
||||
updateKeys: () => {},
|
||||
});
|
||||
releaseEncrypt?.();
|
||||
|
||||
await correction;
|
||||
assert.equal(await imported, "conflict");
|
||||
assert.equal(await loadDefaultKeyPassphrase("~/.ssh/id_ed25519"), "current-secret");
|
||||
});
|
||||
|
||||
test("imported passphrase cannot overwrite a direct key correction during validation", async (t) => {
|
||||
installLocalStorage(t);
|
||||
let releaseEncrypt: (() => void) | undefined;
|
||||
const encryptGate = new Promise<void>((resolve) => {
|
||||
releaseEncrypt = resolve;
|
||||
});
|
||||
let encryptStarted: (() => void) | undefined;
|
||||
const encryption = new Promise<void>((resolve) => {
|
||||
encryptStarted = resolve;
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => "/Users/alice",
|
||||
credentialsEncrypt: async (value: string) => {
|
||||
encryptStarted?.();
|
||||
await encryptGate;
|
||||
return value;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
let keys: SSHKey[] = [{
|
||||
...referenceKey(),
|
||||
passphrase: "stale-import",
|
||||
savePassphrase: true,
|
||||
}];
|
||||
|
||||
const imported = rememberImportedKeyPassphrase({
|
||||
keyPath: "/Users/alice/.ssh/id_ed25519",
|
||||
passphrase: "stale-import",
|
||||
keys,
|
||||
getKeys: () => keys,
|
||||
setCurrentKeys: (updated) => {
|
||||
keys = updated;
|
||||
},
|
||||
updateKeys: () => undefined,
|
||||
});
|
||||
await encryption;
|
||||
keys = [{ ...keys[0], passphrase: "user-correction" }];
|
||||
releaseEncrypt?.();
|
||||
|
||||
assert.equal(await imported, "conflict");
|
||||
assert.equal(keys[0]?.passphrase, "user-correction");
|
||||
assert.equal(await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"), null);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase cleanup preserves a passphrase saved concurrently", async (t) => {
|
||||
installLocalStorage(t);
|
||||
let releaseFirstHomeLookup: (() => void) | undefined;
|
||||
const firstHomeLookup = new Promise<void>((resolve) => {
|
||||
releaseFirstHomeLookup = resolve;
|
||||
});
|
||||
let homeLookupCount = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => {
|
||||
homeLookupCount += 1;
|
||||
if (homeLookupCount === 1) await firstHomeLookup;
|
||||
return "/Users/alice";
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" }),
|
||||
);
|
||||
|
||||
const pendingLoad = loadDefaultKeyPassphrase("/Users/alice/.ssh/id_old");
|
||||
await saveDefaultKeyPassphrase("/Users/alice/.ssh/id_new", "new-passphrase");
|
||||
releaseFirstHomeLookup?.();
|
||||
|
||||
assert.equal(await pendingLoad, null);
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_new"),
|
||||
"new-passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase retries when the same path is saved concurrently", async (t) => {
|
||||
installLocalStorage(t);
|
||||
let releaseFirstHomeLookup: (() => void) | undefined;
|
||||
const firstHomeLookup = new Promise<void>((resolve) => {
|
||||
releaseFirstHomeLookup = resolve;
|
||||
});
|
||||
let homeLookupCount = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => {
|
||||
homeLookupCount += 1;
|
||||
if (homeLookupCount === 1) await firstHomeLookup;
|
||||
return "/Users/alice";
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: "old-passphrase" }),
|
||||
);
|
||||
|
||||
const pendingLoad = loadDefaultKeyPassphrase(keyPath);
|
||||
await saveDefaultKeyPassphrase(keyPath, "new-passphrase");
|
||||
releaseFirstHomeLookup?.();
|
||||
|
||||
assert.equal(await pendingLoad, "new-passphrase");
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase returns plain stored passphrases", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: "correct horse battery staple" }),
|
||||
);
|
||||
|
||||
assert.equal(await loadDefaultKeyPassphrase(keyPath), "correct horse battery staple");
|
||||
});
|
||||
|
||||
test("saveDefaultKeyPassphrase makes the passphrase available to the connection prompt", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
|
||||
await saveDefaultKeyPassphrase(keyPath, "saved by agent");
|
||||
|
||||
assert.equal(await loadDefaultKeyPassphrase(keyPath), "saved by agent");
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase matches an expanded connection path to a saved home-relative path", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => "/Users/alice",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await saveDefaultKeyPassphrase("~/.ssh/id_ed25519", "saved by agent");
|
||||
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
|
||||
"saved by agent",
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase prefers an exact saved path over a stale alias", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
"/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
|
||||
"valid-exact-passphrase",
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
|
||||
{ "/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase" },
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase falls back to a valid alias and removes an invalid exact value", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "valid-alias-passphrase",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
|
||||
"valid-alias-passphrase",
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
|
||||
{ "~/.ssh/id_ed25519": "valid-alias-passphrase" },
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase consolidates conflicting valid aliases around the exact path", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "old-alias-passphrase",
|
||||
"/Users/alice/.ssh/id_ed25519": "new-exact-passphrase",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"),
|
||||
"new-exact-passphrase",
|
||||
);
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("~/.ssh/id_ed25519"),
|
||||
"new-exact-passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
test("saveDefaultKeyPassphrase replaces stale values stored under path aliases", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "old-relative",
|
||||
"/Users/alice/.ssh/id_ed25519": "old-absolute",
|
||||
}),
|
||||
);
|
||||
|
||||
await saveDefaultKeyPassphrase("~/.ssh/id_ed25519", "replacement");
|
||||
|
||||
const stored = JSON.parse(
|
||||
globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}",
|
||||
) as Record<string, string>;
|
||||
assert.equal(stored["/Users/alice/.ssh/id_ed25519"], undefined);
|
||||
assert.equal(await loadDefaultKeyPassphrase("/Users/alice/.ssh/id_ed25519"), "replacement");
|
||||
});
|
||||
|
||||
test("removeDefaultKeyPassphraseAliases clears relative and expanded paths", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "old-relative",
|
||||
"/Users/alice/.ssh/id_ed25519": "old-absolute",
|
||||
"/Users/alice/.ssh/other": "keep",
|
||||
}),
|
||||
);
|
||||
|
||||
const aliases = await removeDefaultKeyPassphraseAliases(["~/.ssh/id_ed25519"]);
|
||||
|
||||
assert.deepEqual(new Set(aliases), new Set([
|
||||
"~/.ssh/id_ed25519",
|
||||
"/Users/alice/.ssh/id_ed25519",
|
||||
]));
|
||||
assert.deepEqual(
|
||||
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
|
||||
{ "/Users/alice/.ssh/other": "keep" },
|
||||
);
|
||||
const clearedKeys = clearReferenceKeyPassphrases([
|
||||
{ ...referenceKey(), passphrase: "old", savePassphrase: true },
|
||||
], aliases);
|
||||
assert.equal(clearedKeys[0].passphrase, undefined);
|
||||
assert.equal(clearedKeys[0].savePassphrase, false);
|
||||
});
|
||||
|
||||
test("passphrase removal and save mutations run in request order", async (t) => {
|
||||
installLocalStorage(t);
|
||||
let releaseFirstHomeLookup: (() => void) | undefined;
|
||||
const firstHomeLookup = new Promise<void>((resolve) => {
|
||||
releaseFirstHomeLookup = resolve;
|
||||
});
|
||||
let homeLookupCount = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
getHomeDir: async () => {
|
||||
homeLookupCount += 1;
|
||||
if (homeLookupCount === 1) await firstHomeLookup;
|
||||
return "/Users/alice";
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: "old-passphrase" }),
|
||||
);
|
||||
|
||||
const pendingRemoval = removeDefaultKeyPassphraseAliases([keyPath]);
|
||||
const pendingSave = saveDefaultKeyPassphrase(keyPath, "corrected-passphrase");
|
||||
releaseFirstHomeLookup?.();
|
||||
|
||||
assert.deepEqual(await pendingRemoval, [keyPath, "~/.ssh/id_ed25519"]);
|
||||
await pendingSave;
|
||||
assert.equal(await loadDefaultKeyPassphrase(keyPath), "corrected-passphrase");
|
||||
});
|
||||
|
||||
test("credential clearing holds the mutation queue until key state is persisted", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
let keys: SSHKey[] = [{
|
||||
...referenceKey(),
|
||||
passphrase: "old-secret",
|
||||
savePassphrase: true,
|
||||
}];
|
||||
await saveDefaultKeyPassphrase(keyPath, "old-secret");
|
||||
let releaseClear: (() => void) | undefined;
|
||||
const clearPersisted = new Promise<void>((resolve) => {
|
||||
releaseClear = resolve;
|
||||
});
|
||||
let clearUpdateStarted: (() => void) | undefined;
|
||||
const clearStarted = new Promise<void>((resolve) => {
|
||||
clearUpdateStarted = resolve;
|
||||
});
|
||||
|
||||
const clearing = clearRememberedKeyPassphrases({
|
||||
keyPaths: [keyPath],
|
||||
getKeys: () => keys,
|
||||
setCurrentKeys: (updated) => {
|
||||
keys = updated;
|
||||
},
|
||||
updateKeys: async () => {
|
||||
clearUpdateStarted?.();
|
||||
await clearPersisted;
|
||||
},
|
||||
});
|
||||
await clearStarted;
|
||||
let importFinished = false;
|
||||
const importing = rememberImportedKeyPassphrase({
|
||||
keyPath,
|
||||
passphrase: "imported-secret",
|
||||
keys,
|
||||
getKeys: () => keys,
|
||||
setCurrentKeys: (updated) => {
|
||||
keys = updated;
|
||||
},
|
||||
updateKeys: () => undefined,
|
||||
}).then((result) => {
|
||||
importFinished = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(importFinished, false);
|
||||
|
||||
releaseClear?.();
|
||||
await clearing;
|
||||
assert.equal(await importing, "saved");
|
||||
assert.equal(keys[0]?.passphrase, "imported-secret");
|
||||
});
|
||||
|
||||
test("rememberKeyPassphrase updates a reference key stored under an expanded alias", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "/Users/alice" } },
|
||||
});
|
||||
let updatedKeys: SSHKey[] | undefined;
|
||||
|
||||
await rememberKeyPassphrase({
|
||||
keyPath: "~/.ssh/id_ed25519",
|
||||
passphrase: "replacement",
|
||||
keys: [{ ...referenceKey(), passphrase: "old", savePassphrase: true }],
|
||||
updateKeys: (keys) => {
|
||||
updatedKeys = keys;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(updatedKeys?.[0].passphrase, "replacement");
|
||||
assert.equal(updatedKeys?.[0].savePassphrase, true);
|
||||
});
|
||||
|
||||
test("rememberKeyPassphrase reads the latest keys after saving the credential", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
let currentKeys: SSHKey[] = [{
|
||||
id: "key-1",
|
||||
name: "Original",
|
||||
type: "ed25519",
|
||||
source: "reference",
|
||||
filePath: keyPath,
|
||||
createdAt: 1,
|
||||
}];
|
||||
const staleKeys = currentKeys;
|
||||
const updatedNames: string[] = [];
|
||||
currentKeys = [{ ...currentKeys[0], name: "Concurrent edit" }];
|
||||
|
||||
await rememberKeyPassphrase({
|
||||
keyPath,
|
||||
passphrase: "secret",
|
||||
keys: staleKeys,
|
||||
getKeys: () => currentKeys,
|
||||
updateKeys: (updated) => {
|
||||
currentKeys = updated;
|
||||
updatedNames.push(updated[0]?.name ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(updatedNames, ["Concurrent edit"]);
|
||||
assert.equal(currentKeys[0]?.passphrase, "secret");
|
||||
});
|
||||
|
||||
test("path aliases replace and clear Windows reference-key spellings", async (t) => {
|
||||
installLocalStorage(t);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: { getHomeDir: async () => "C:\\Users\\Alice" } },
|
||||
});
|
||||
const windowsReferenceKey: SSHKey = {
|
||||
...referenceKey(),
|
||||
filePath: "c:\\users\\alice\\.ssh\\id_ed25519",
|
||||
passphrase: "old",
|
||||
savePassphrase: true,
|
||||
};
|
||||
let updatedKeys: SSHKey[] | undefined;
|
||||
|
||||
await rememberKeyPassphrase({
|
||||
keyPath: "~/.ssh/id_ed25519",
|
||||
passphrase: "replacement",
|
||||
keys: [windowsReferenceKey],
|
||||
updateKeys: (keys) => {
|
||||
updatedKeys = keys;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(updatedKeys?.[0].passphrase, "replacement");
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("C:\\Users\\Alice\\.ssh\\id_ed25519"),
|
||||
"replacement",
|
||||
);
|
||||
|
||||
const aliases = await removeDefaultKeyPassphraseAliases(["~/.ssh/id_ed25519"]);
|
||||
const clearedKeys = clearReferenceKeyPassphrases(updatedKeys ?? [], aliases);
|
||||
assert.equal(clearedKeys[0].passphrase, undefined);
|
||||
assert.equal(await loadDefaultKeyPassphrase("c:\\users\\alice\\.ssh\\id_ed25519"), null);
|
||||
});
|
||||
|
||||
test("POSIX backslashes remain distinct from path separators", async (t) => {
|
||||
installLocalStorage(t);
|
||||
|
||||
await saveDefaultKeyPassphrase("/home/alice/.ssh/team\\key", "backslash-name");
|
||||
await saveDefaultKeyPassphrase("/home/alice/.ssh/team/key", "nested-path");
|
||||
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/home/alice/.ssh/team\\key"),
|
||||
"backslash-name",
|
||||
);
|
||||
assert.equal(
|
||||
await loadDefaultKeyPassphrase("/home/alice/.ssh/team/key"),
|
||||
"nested-path",
|
||||
);
|
||||
});
|
||||
|
||||
test("clearReferenceKeyPassphrases clears matching reference key paths only", () => {
|
||||
const keys: SSHKey[] = [
|
||||
{
|
||||
...referenceKey(),
|
||||
passphrase: "bad",
|
||||
savePassphrase: true,
|
||||
},
|
||||
{
|
||||
...referenceKey(),
|
||||
id: "other-key",
|
||||
label: "other",
|
||||
filePath: "/Users/alice/.ssh/other",
|
||||
passphrase: "keep",
|
||||
savePassphrase: true,
|
||||
},
|
||||
];
|
||||
|
||||
const updated = clearReferenceKeyPassphrases(keys, ["/Users/alice/.ssh/id_ed25519"]);
|
||||
|
||||
assert.equal(updated[0].passphrase, undefined);
|
||||
assert.equal(updated[0].savePassphrase, false);
|
||||
assert.equal(updated[1].passphrase, "keep");
|
||||
});
|
||||
|
||||
test("clearKeyPassphrasesByIds clears matching saved key passphrases", () => {
|
||||
const keys: SSHKey[] = [
|
||||
{
|
||||
...referenceKey(),
|
||||
id: "inline-key",
|
||||
source: "imported",
|
||||
filePath: undefined,
|
||||
privateKey: "PRIVATE KEY",
|
||||
passphrase: "bad",
|
||||
savePassphrase: true,
|
||||
},
|
||||
{
|
||||
...referenceKey(),
|
||||
id: "other-key",
|
||||
label: "other",
|
||||
passphrase: "keep",
|
||||
savePassphrase: true,
|
||||
},
|
||||
];
|
||||
|
||||
const updated = clearKeyPassphrasesByIds(keys, ["inline-key"]);
|
||||
|
||||
assert.equal(updated[0].passphrase, undefined);
|
||||
assert.equal(updated[0].savePassphrase, false);
|
||||
assert.equal(updated[1].passphrase, "keep");
|
||||
});
|
||||
|
||||
test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passphrases", () => {
|
||||
assert.equal(shouldUpdateReferenceKeyPassphrase(null), false);
|
||||
assert.equal(shouldUpdateReferenceKeyPassphrase(referenceKey()), true);
|
||||
assert.equal(
|
||||
shouldUpdateReferenceKeyPassphrase({
|
||||
...referenceKey(),
|
||||
passphrase: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUpdateReferenceKeyPassphrase({
|
||||
...referenceKey(),
|
||||
passphrase: "saved",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rememberKeyPassphrase updates reference key state before completing", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keys = [referenceKey()];
|
||||
let currentKeys = keys;
|
||||
let releaseUpdate: (() => void) | undefined;
|
||||
let rememberPromise: Promise<void> | undefined;
|
||||
const updateStarted = new Promise<void>((resolve) => {
|
||||
const updateKeys = async (updated: SSHKey[]) => {
|
||||
assert.equal(currentKeys[0].passphrase, "saved");
|
||||
assert.equal(updated[0].passphrase, "saved");
|
||||
resolve();
|
||||
await new Promise<void>((release) => {
|
||||
releaseUpdate = release;
|
||||
});
|
||||
};
|
||||
|
||||
rememberPromise = rememberKeyPassphrase({
|
||||
keyPath: "/Users/alice/.ssh/id_ed25519",
|
||||
passphrase: "saved",
|
||||
keys,
|
||||
updateKeys,
|
||||
setCurrentKeys: (updated) => {
|
||||
currentKeys = updated;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await updateStarted;
|
||||
assert.equal(currentKeys[0].passphrase, "saved");
|
||||
releaseUpdate?.();
|
||||
await rememberPromise;
|
||||
});
|
||||
Reference in New Issue
Block a user