mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13d349bd4d | ||
|
|
1a745a2a22 | ||
|
|
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!Dockerfile
|
||||
!ca-certificates.crt
|
||||
@@ -0,0 +1,4 @@
|
||||
/ca-certificates.crt
|
||||
/docker-config/
|
||||
/private-fixture.json
|
||||
/__pycache__/
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM debian:bookworm-slim
|
||||
COPY --chmod=644 ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
RUN chmod 755 /etc/ssl /etc/ssl/certs && printf '%s\n' 'Acquire::https::CaInfo "/etc/ssl/certs/ca-certificates.crt";' > /etc/apt/apt.conf.d/99fixture-ca && sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && apt-get -o Acquire::Retries=0 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 update && apt-get install -y --no-install-recommends openssh-server passwd && rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd --create-home --shell /bin/bash fixtureki && mkdir -p /run/sshd && ssh-keygen -A
|
||||
RUN printf '%s\n' 'Port 22' 'ListenAddress 0.0.0.0' 'UsePAM yes' 'PasswordAuthentication yes' 'KbdInteractiveAuthentication yes' 'PubkeyAuthentication no' 'PermitRootLogin no' 'PrintMotd no' 'LogLevel VERBOSE' > /etc/ssh/sshd_config
|
||||
CMD ["/usr/sbin/sshd", "-D", "-e"]
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the disposable fixture with this environment's existing CA trust."""
|
||||
import os, socket, subprocess
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
root=Path(__file__).resolve().parent
|
||||
certs=[]
|
||||
for value in ['/etc/ssl/certs/ca-certificates.crt',os.environ.get('NODE_EXTRA_CA_CERTS','')]:
|
||||
path=Path(value)
|
||||
if value and path.is_file(): certs.append(path.read_bytes())
|
||||
(root/'ca-certificates.crt').write_bytes(b'\n'.join(certs))
|
||||
(root/'ca-certificates.crt').chmod(0o644)
|
||||
args=['docker','build']
|
||||
seen=set()
|
||||
for key in ['HTTP_PROXY','HTTPS_PROXY','http_proxy','https_proxy']:
|
||||
if os.environ.get(key):
|
||||
args += ['--build-arg',key]
|
||||
host=urlsplit(os.environ[key]).hostname
|
||||
if host and host not in seen:
|
||||
seen.add(host)
|
||||
args += ['--add-host',f'{host}:{socket.gethostbyname(host)}']
|
||||
args += ['-t','netcatty-3982-pam-fixture',str(root)]
|
||||
subprocess.run(args,env={**os.environ,'DOCKER_CONFIG':str(root/'docker-config')},check=True)
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Manage only this kit's disposable, loopback-bound SSH containers."""
|
||||
import json, os, secrets, subprocess, sys
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parent
|
||||
CONFIG=ROOT/'private-fixture.json'
|
||||
def run(*args, input=None):
|
||||
return subprocess.check_output(['docker',*args],input=input,text=True).strip()
|
||||
def reset(config):
|
||||
for name, host in config['hosts'].items():
|
||||
run('exec','-i',host['container'],'chpasswd',input=f"{config['username']}:{config['currentPassword']}\n")
|
||||
run('exec',host['container'],'chage','-d','0' if name=='target' else '1',config['username'])
|
||||
if sys.argv[1]=='start':
|
||||
if CONFIG.exists(): raise SystemExit('Existing fixture config: use reset or stop first.')
|
||||
config={'username':'fixtureki','currentPassword':secrets.token_urlsafe(18)+'aA1!','newPassword':secrets.token_urlsafe(18)+'bB2!','hosts':{}}
|
||||
try:
|
||||
for name in ['target','control']:
|
||||
container='netcatty-3982-'+name+'-'+secrets.token_hex(4)
|
||||
run('run','--rm','-d','--name',container,'-p','127.0.0.1::22','netcatty-3982-pam-fixture')
|
||||
port=int(run('port',container,'22/tcp').rsplit(':',1)[1])
|
||||
config['hosts'][name]={'container':container,'hostname':'127.0.0.1','port':port}
|
||||
reset(config)
|
||||
except BaseException:
|
||||
for host in config['hosts'].values(): subprocess.run(['docker','rm','-f',host['container']],stdout=subprocess.DEVNULL)
|
||||
raise
|
||||
fd=os.open(CONFIG,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
|
||||
with os.fdopen(fd,'w') as f: json.dump(config,f,indent=2)
|
||||
print('Disposable fixture ready. Private connection details:',CONFIG)
|
||||
elif sys.argv[1]=='reset':
|
||||
reset(json.loads(CONFIG.read_text()))
|
||||
print('Reset target expiry and control account; retained the synthetic fixture secrets.')
|
||||
elif sys.argv[1]=='stop':
|
||||
config=json.loads(CONFIG.read_text())
|
||||
for host in config['hosts'].values(): run('rm','-f',host['container'])
|
||||
CONFIG.unlink()
|
||||
print('Removed this fixture containers and private connection details.')
|
||||
else: raise SystemExit('Use start, reset, or stop')
|
||||
@@ -0,0 +1,68 @@
|
||||
# Verify remembered KI passwords and reconnects
|
||||
|
||||
Run this recipe in the parent's native GUI session on PR #3983 head `2096e93420f547be6491b7fb5df90f20f75a9545`.
|
||||
Use a fresh Netcatty profile and the two disposable local servers from this kit.
|
||||
The OpenSSH/PAM fixture requires Docker.
|
||||
Keep private-fixture.json out of screenshots, recordings, uploads, and reports.
|
||||
|
||||
1. Run `python3 build.py` in this directory.
|
||||
2. Run `python3 manage.py start`.
|
||||
3. Read private-fixture.json locally for the synthetic account and connection details. Do not copy those values into the report.
|
||||
4. Build the app at the exact PR head with `npm ci` and `npm run build`.
|
||||
5. From the app checkout, launch Electron directly with a new disposable profile:
|
||||
|
||||
```bash
|
||||
node <<'NODE'
|
||||
const { spawn } = require('node:child_process');
|
||||
const { mkdtempSync } = require('node:fs');
|
||||
const { applyElectronLaunchEnv } = require('./electron/launchEnv.cjs');
|
||||
const { getTempFilePath } = require('./electron/bridges/tempDirBridge.cjs');
|
||||
const profile = mkdtempSync(getTempFilePath('ki-expiry-profile-'));
|
||||
console.log('Disposable app profile:', profile);
|
||||
const child = spawn(require('electron'), ['.', `--user-data-dir=${profile}`], {
|
||||
stdio: 'inherit',
|
||||
env: applyElectronLaunchEnv(process.env),
|
||||
});
|
||||
child.on('error', (error) => { console.error(error.message); process.exit(1); });
|
||||
child.on('exit', (code) => process.exit(code ?? 1));
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => { if (!child.killed) child.kill(signal); });
|
||||
}
|
||||
NODE
|
||||
```
|
||||
|
||||
`electron/launch.cjs` does not forward command-line arguments. Do not pass the profile flag to that script. Keep the sandbox enabled. Coordinate the GUI session through the parent.
|
||||
6. In **Keychain**, create one password identity from the kit's current account.
|
||||
7. In **Hosts**, add the kit's target and control servers. Link both hosts to that identity.
|
||||
8. Enable **Prioritize keyboard-interactive authentication** on the target host.
|
||||
9. Set the target host's stored username to a different synthetic account while retaining the identity. The identity must supply the actual login account.
|
||||
10. Connect to the control host. Verify that the shell opens. Close its session.
|
||||
11. Connect to the target host. Accept only the disposable server's host key.
|
||||
12. Enter the current password when the expiry dialog requests it.
|
||||
13. Enter the new password from private-fixture.json. Select **Remember** and submit.
|
||||
14. Confirm the new password when requested. Verify that the shell opens.
|
||||
15. Close the target session. Reconnect by opening the saved target host. Verify that login succeeds with the saved new password.
|
||||
16. Open the target host's editor. Verify that the identity reference is cleared, the actual identity username is retained, and the password remains masked.
|
||||
17. Open **Keychain**. Verify that the shared identity remains unchanged without revealing its password.
|
||||
18. Reconnect to the control host. Verify that it still opens with the shared identity.
|
||||
19. Record the exact app head and the expected results. Capture screenshots at the expiry dialog, Remember selection, first shell, reconnected shell, detached target identity, and connected control host.
|
||||
20. Repeat after `python3 manage.py reset` with **Remember** unchecked. Verify that the target keeps its identity reference. Do not treat a subsequent prompt or failed reconnect with the old password as a fix regression.
|
||||
21. Repeat with a blank stored host username. Verify that the identity supplies and preserves the actual account.
|
||||
22. Repeat with no identity reference. Verify that Remember saves the new host password and reconnect succeeds.
|
||||
|
||||
## Verify the separate wrong-current path
|
||||
|
||||
1. Run `python3 manage.py reset`.
|
||||
2. Restore the target host's shared identity and its old fixture credential in the app.
|
||||
3. Connect to the target host.
|
||||
4. Enter an intentionally incorrect current password in the KI dialog.
|
||||
5. Capture the transition to the terminal password-change prompt in the same connection.
|
||||
6. Record whether the server rejects KI, accepts password authentication, and then opens the shell. Do not infer protocol events from the dialog alone.
|
||||
7. Run `node probe.cjs /absolute/path/to/Netcatty wrong` after another reset to capture the redacted protocol event sequence.
|
||||
8. Keep this outcome separate from remembered KI credential persistence. The terminal password prompt does not offer KI Remember.
|
||||
|
||||
## Remove the fixture
|
||||
|
||||
1. Close the native app sessions.
|
||||
2. Run `python3 manage.py stop` to remove both containers and the private connection details.
|
||||
3. Remove the disposable app profile.
|
||||
@@ -0,0 +1,62 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const appRequire = createRequire(path.resolve(process.argv[2], 'package.json'));
|
||||
const { Client } = appRequire('ssh2');
|
||||
const { buildAuthHandler, createKeyboardInteractiveHandler } = appRequire('./electron/bridges/sshAuthHelper.cjs');
|
||||
const keyboard = appRequire('./electron/bridges/keyboardInteractiveHandler.cjs');
|
||||
const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'private-fixture.json')));
|
||||
const mode = process.argv[3] || 'correct';
|
||||
const host = config.hosts.target;
|
||||
const events = [];
|
||||
const originalLog = console.log;
|
||||
console.log = () => {}; // Main-process debug output includes server instructions; report only bounded event labels.
|
||||
(async () => {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const client = new Client();
|
||||
let settled = false;
|
||||
let sawNew = false;
|
||||
let sawCurrent = false;
|
||||
const timer = setTimeout(() => done(new Error('fixture probe timed out')), 20000);
|
||||
function done(error) {
|
||||
if (settled) return;
|
||||
settled=true;
|
||||
clearTimeout(timer);
|
||||
keyboard.cancelRequestsForSession('fixture-probe');
|
||||
client.end();
|
||||
error ? reject(error) : resolve({events,sawNew,sawCurrent});
|
||||
}
|
||||
const sender = { id: 3982, isDestroyed: () => false, send(channel, data) {
|
||||
if(channel !== 'netcatty:keyboard-interactive') return;
|
||||
const prompts = data.prompts.map(p=>p.prompt.toLowerCase());
|
||||
const answers = prompts.map(prompt=>{
|
||||
if(prompt.includes('current')) { sawCurrent=true; events.push('ki-current'); return mode==='wrong'?'intentionally-wrong-fixture-response':config.currentPassword; }
|
||||
if(prompt.includes('new') || prompt.includes('retype')) { sawNew=true; events.push(prompt.includes('retype')?'ki-confirm':'ki-new'); return config.newPassword; }
|
||||
events.push('ki-password'); return config.currentPassword;
|
||||
});
|
||||
queueMicrotask(()=>keyboard.handleResponse({sender:{id:3982}},{requestId:data.requestId,responses:answers,cancelled:false}));
|
||||
}};
|
||||
const auth = buildAuthHandler({authMethod:'password',username:config.username,password:config.currentPassword,requiresMfa:true,onAuthAttempt:m=>events.push('auth-'+m)});
|
||||
client.on('keyboard-interactive', createKeyboardInteractiveHandler({sender,sessionId:'fixture-probe',hostname:'127.0.0.1',password:config.currentPassword,scope:'terminal'}));
|
||||
client.on('error',done);
|
||||
client.on('ready',()=>{
|
||||
events.push('ssh-ready');
|
||||
client.shell({term:'xterm',cols:80,rows:24},(error,stream)=>{
|
||||
if(error) return done(error);
|
||||
events.push('shell-open');
|
||||
let text='';
|
||||
stream.on('data',chunk=>{
|
||||
text+=chunk.toString();
|
||||
if(mode==='wrong' && /Current password:/i.test(text)) {events.push('shell-current-password');done();}
|
||||
if(mode==='correct' && /fixtureki@/.test(text)) {events.push('shell-prompt');done();}
|
||||
});
|
||||
});
|
||||
});
|
||||
client.connect({host:'127.0.0.1',port:host.port,username:config.username,password:config.currentPassword,authHandler:auth.authHandler,tryKeyboard:true,readyTimeout:15000,hostVerifier:()=>true});
|
||||
});
|
||||
assert.equal(result.sawCurrent,true);
|
||||
if(mode==='correct') assert.equal(result.sawNew,true);
|
||||
else { assert.equal(result.sawNew,false); assert.ok(events.includes('shell-current-password')); }
|
||||
originalLog(JSON.stringify({mode,...result},null,2));
|
||||
})().catch(error=>{originalLog(error.message);process.exitCode=1;});
|
||||
Reference in new issue
Block a user