mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 17:30:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2096e93420 | ||
|
|
56f0b55b0e | ||
|
|
2c84f9cb0f | ||
|
|
ea62a0c31e | ||
|
|
d27dbbcd1d |
No files matched your search
@@ -7,8 +7,9 @@ import {
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { resolveHostAuth } from '../domain/sshAuth.ts';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||
import type { Host } from '../types';
|
||||
import type { Host, Identity } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
id: 'host-1',
|
||||
@@ -194,6 +195,219 @@ test('keyboard-interactive submit can save login password for the session host',
|
||||
assert.equal(bridgeResponses.length, 1);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit detaches keychain identity when remembering a password change', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-rotate',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
// resolveHostAuth prefers identity.password, so the stale keychain secret
|
||||
// shadows whatever is already stored on the host.
|
||||
assert.equal(
|
||||
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
|
||||
'stale-keychain-password',
|
||||
);
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-rotate',
|
||||
['rotated-password'],
|
||||
'rotated-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].password, 'rotated-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(hosts[0].username, baseHost.username);
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
assert.equal(
|
||||
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
|
||||
'rotated-password',
|
||||
);
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit snapshots a different keychain username when remembering a password', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'deploy',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
username: 'root',
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-rotate-user',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-rotate-user',
|
||||
['new-password'],
|
||||
'new-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].password, 'new-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(hosts[0].username, 'deploy');
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.username, 'deploy');
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
|
||||
assert.equal(resolved.password, 'new-password');
|
||||
assert.equal(resolved.username, 'deploy');
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit fills a blank host username from the keychain identity', async () => {
|
||||
const sharedIdentity: Identity = {
|
||||
id: 'id-1',
|
||||
label: 'shared',
|
||||
username: 'alice',
|
||||
authMethod: 'password',
|
||||
password: 'stale-keychain-password',
|
||||
created: 0,
|
||||
};
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
username: '',
|
||||
identityId: 'id-1',
|
||||
password: 'old-host-password',
|
||||
savePassword: false,
|
||||
}];
|
||||
let queue = [{
|
||||
requestId: 'ki-blank-user',
|
||||
sessionId: 'session-1',
|
||||
hostname: baseHost.hostname,
|
||||
allowSavePassword: true,
|
||||
}];
|
||||
let identityUpdates = 0;
|
||||
|
||||
await handleKeyboardInteractiveSubmitImpl(
|
||||
() => ({
|
||||
hosts,
|
||||
identities: [sharedIdentity],
|
||||
keyboardInteractiveQueue: queue,
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
respondKeyboardInteractive: () => ({ success: true }),
|
||||
}),
|
||||
},
|
||||
sessions: [{
|
||||
id: 'session-1',
|
||||
hostId: baseHost.id,
|
||||
hostname: baseHost.hostname,
|
||||
}],
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
|
||||
queue = updater(queue);
|
||||
},
|
||||
t: (key: string) => key,
|
||||
toast: { error: () => {} },
|
||||
updateHosts: (nextHosts: Host[]) => {
|
||||
hosts = nextHosts;
|
||||
},
|
||||
updateIdentities: () => {
|
||||
identityUpdates += 1;
|
||||
},
|
||||
}),
|
||||
'ki-blank-user',
|
||||
['new-password'],
|
||||
'new-password',
|
||||
);
|
||||
|
||||
assert.equal(hosts[0].identityId, '');
|
||||
assert.equal(hosts[0].username, 'alice');
|
||||
assert.equal(hosts[0].password, 'new-password');
|
||||
assert.equal(hosts[0].savePassword, true);
|
||||
assert.equal(identityUpdates, 0);
|
||||
assert.equal(sharedIdentity.username, 'alice');
|
||||
assert.equal(sharedIdentity.password, 'stale-keychain-password');
|
||||
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
|
||||
assert.equal(resolved.password, 'new-password');
|
||||
assert.equal(resolved.username, 'alice');
|
||||
assert.deepEqual(queue, []);
|
||||
});
|
||||
|
||||
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
|
||||
let hosts: Host[] = [{
|
||||
...baseHost,
|
||||
|
||||
@@ -393,6 +393,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
||||
const {
|
||||
hosts,
|
||||
hostsRef,
|
||||
identities,
|
||||
keyboardInteractiveQueue,
|
||||
sessions,
|
||||
setKeyboardInteractiveQueue,
|
||||
@@ -424,12 +425,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
|
||||
// Save password to host if requested - never for second-factor / EDR prompts
|
||||
// (allowSavePassword === false) so a secondary secret cannot overwrite the
|
||||
// host login password (#2150 / Codex review on #2151).
|
||||
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
|
||||
// identity.password and identity.username, so snapshot the identity username
|
||||
// onto the host before clearing identityId (#3982). Same as
|
||||
// applyVaultHostUpdate's password detach. Do not mutate the identity.
|
||||
if (savePassword && host && request?.allowSavePassword !== false) {
|
||||
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
|
||||
...h,
|
||||
password: savePassword,
|
||||
savePassword: true,
|
||||
} : h));
|
||||
updateHosts(latestHosts.map((h: Host) => {
|
||||
if (h.id !== host.id) return h;
|
||||
const identity = h.identityId
|
||||
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
|
||||
: undefined;
|
||||
const detachedUsername = identity?.username?.trim();
|
||||
return {
|
||||
...h,
|
||||
password: savePassword,
|
||||
savePassword: true,
|
||||
...(h.identityId ? {
|
||||
identityId: '',
|
||||
...(detachedUsername ? { username: detachedUsername } : {}),
|
||||
} : {}),
|
||||
};
|
||||
}));
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
|
||||
@@ -716,7 +716,7 @@ export function AppSideEffects() {
|
||||
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
|
||||
|
||||
// Handle keyboard-interactive cancel
|
||||
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import type { Host, Identity } from "../types";
|
||||
import { handleKeyboardInteractiveSubmitImpl } from "../application/app/AppHandlers";
|
||||
import { createTerminalSessionStarters } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createPromptLineBreakState } from "./terminal/runtime/promptLineBreak";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
test("KI remember input preserves the reconnect account and leaves shared hosts unchanged", async (t) => {
|
||||
const dom = installDomEnvironment();
|
||||
const globals = {
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
HTMLInputElement: dom.window.HTMLInputElement,
|
||||
localStorage: dom.window.localStorage,
|
||||
};
|
||||
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
const { KeyboardInteractiveModal } = await import("./KeyboardInteractiveModal");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider");
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
try {
|
||||
for (const scenario of [
|
||||
{ name: "differing usernames", hostUsername: "old-account", identityUsername: "fixtureki", remember: true },
|
||||
{ name: "blank host username", hostUsername: "", identityUsername: "fixtureki", remember: true },
|
||||
{ name: "blank identity username", hostUsername: "fixtureki", identityUsername: " ", remember: true },
|
||||
{ name: "no identity", hostUsername: "fixtureki", identityUsername: undefined, remember: true },
|
||||
{ name: "remember unchecked", hostUsername: "old-account", identityUsername: "fixtureki", remember: false },
|
||||
{ name: "secondary password", hostUsername: "old-account", identityUsername: "fixtureki", remember: true, allowSavePassword: false },
|
||||
]) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const identity: Identity = {
|
||||
id: "shared", label: "Fixture", username: scenario.identityUsername ?? "",
|
||||
authMethod: "password", password: "fixture-old", created: 0,
|
||||
};
|
||||
const identities = scenario.identityUsername === undefined ? [] : [identity];
|
||||
const originalIdentity = { ...identity };
|
||||
const host: Host = {
|
||||
id: "changed", label: "Fixture", hostname: "127.0.0.1", username: scenario.hostUsername,
|
||||
tags: [], os: "linux", protocol: "ssh", authMethod: "password",
|
||||
password: "fixture-host-old", savePassword: false,
|
||||
...(identities.length ? { identityId: identity.id } : {}),
|
||||
};
|
||||
const otherHost = { ...host, id: "unchanged", label: "Other fixture" };
|
||||
let hosts = [host, otherHost];
|
||||
let updates = 0;
|
||||
const request = {
|
||||
requestId: `ki-${scenario.name}`, sessionId: "fixture-session", hostname: host.hostname,
|
||||
name: "Password expiry", instructions: "", prompts: [{ prompt: "New password:", echo: false }],
|
||||
allowSavePassword: scenario.allowSavePassword ?? true,
|
||||
};
|
||||
let queue = [request];
|
||||
const responses: unknown[][] = [];
|
||||
await renderer.render(
|
||||
<I18nProvider locale="en">
|
||||
<KeyboardInteractiveModal request={request} onCancel={() => false}
|
||||
onSubmit={(requestId, values, savePassword) => handleKeyboardInteractiveSubmitImpl(() => ({
|
||||
hosts, identities, keyboardInteractiveQueue: queue,
|
||||
sessions: [{ id: "fixture-session", hostId: host.id, hostname: host.hostname }],
|
||||
netcattyBridge: { get: () => ({ respondKeyboardInteractive: (...args: unknown[]) => {
|
||||
responses.push(args);
|
||||
return { success: true };
|
||||
} }) },
|
||||
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => { queue = updater(queue); },
|
||||
updateHosts: (next: Host[]) => { updates += 1; hosts = next; },
|
||||
t: (key: string) => key, toast: { error: (message: string) => assert.fail(message) },
|
||||
}), requestId, values, savePassword)} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const input = dom.document.querySelector<HTMLInputElement>("#ki-prompt-0");
|
||||
assert.ok(input);
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-new");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
const checkbox = dom.document.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
if (scenario.allowSavePassword === false) assert.equal(checkbox, null);
|
||||
else {
|
||||
assert.ok(checkbox);
|
||||
assert.equal(checkbox.checked, false);
|
||||
if (scenario.remember) await dispatchDomEvent(checkbox, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
const submit = [...dom.document.querySelectorAll("button")].find((button) => button.textContent === "Submit");
|
||||
assert.ok(submit);
|
||||
await dispatchDomEvent(submit, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.deepEqual(responses, [[request.requestId, ["fixture-new"], false]]);
|
||||
assert.deepEqual(queue, []);
|
||||
const saved = scenario.remember && scenario.allowSavePassword !== false;
|
||||
assert.equal(updates, saved ? 1 : 0);
|
||||
assert.equal(hosts[0].identityId, saved && identities.length ? "" : host.identityId);
|
||||
assert.equal(hosts[0].password, saved ? "fixture-new" : host.password);
|
||||
assert.equal(hosts[0].savePassword, saved);
|
||||
assert.equal(hosts[1], otherHost);
|
||||
assert.deepEqual(identity, originalIdentity);
|
||||
|
||||
// Exercise the reconnect launcher that builds the actual SSH bridge request.
|
||||
for (const [index, candidate] of hosts.entries()) {
|
||||
let started: { username: string; password?: string } | undefined;
|
||||
const ctx = {
|
||||
host: candidate, identities, keys: [], knownHosts: [], resolvedChainHosts: [],
|
||||
sessionId: `reconnect-${index}`, terminalSettings: {},
|
||||
sessionRef: { current: null }, hasConnectedRef: { current: true },
|
||||
hasRunStartupCommandRef: { current: true }, disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null }, fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null }, pendingAuthRef: { current: null },
|
||||
promptLineBreakStateRef: { current: createPromptLineBreakState() }, sudoAutofillRef: { current: null },
|
||||
updateStatus: noop, setStatus: noop, setError: noop, setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop, setAuthPassword: noop, setProgressLogs: noop,
|
||||
setProgressValue: noop, setChainProgress: noop,
|
||||
terminalBackend: {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: { username: string; password?: string }) => { started = options; return `reconnect-${index}`; },
|
||||
onSessionData: () => noop, onSessionExit: () => noop, onChainProgress: () => noop,
|
||||
writeToSession: noop, resizeSession: noop,
|
||||
},
|
||||
};
|
||||
await createTerminalSessionStarters(ctx as never).startSSH({
|
||||
cols: 80, rows: 24, write: noop, writeln: noop, scrollToBottom: noop,
|
||||
} as never);
|
||||
assert.ok(started);
|
||||
assert.equal(started.username, scenario.identityUsername?.trim() || scenario.hostUsername);
|
||||
assert.equal(started.password, saved && index === 0 ? "fixture-new" : identities.length ? "fixture-old" : "fixture-host-old");
|
||||
}
|
||||
// A fresh request resets the checkbox and submitting state for the next case.
|
||||
await renderer.render(<I18nProvider locale="en"><KeyboardInteractiveModal request={null} onSubmit={() => false} onCancel={() => false} /></I18nProvider>);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user