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
961 lines
28 KiB
TypeScript
961 lines
28 KiB
TypeScript
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;
|
|
});
|