mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 01:00:54 +00:00
* feat(ssh): add per-host TOTP with QR scan and MFA auto-fill Store an optional otpauth TOTP secret on the host, encrypt and sync it like a password, and auto-submit it once when a keyboard-interactive prompt matches JumpServer or PAM TOTP text. Closes #3323 Refs #3415 Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): address Codex TOTP review findings Reset authenticator editor state when the host changes, match custom prompts with RE2, offer keyboard-interactive during connection tests when TOTP is configured, and drop the secret when a host becomes a plugin. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): fill TOTP from the active prompt and ship re2js Attach the connection's otpauth URI to the keyboard-interactive dialog so Fill current code uses that attempt, including unsaved hosts. Move re2js into production dependencies so packaged builds can match custom prompts. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): ship the shared TOTP runtime in packaged builds electron-builder allowlists domain CommonJS modules individually, and sshAuthHelper requires domain/totp.shared.cjs at load. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): save a pasted TOTP secret and ignore stale QR imports Host save waits for the authenticator draft to validate before writing the host. QR import completions from the previous host no longer change busy state or errors on the newly selected host. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * test(host): ignore TOTP copy in the proxy command summary check The assertion was matching the word secret in the authenticator UI. It now checks that the ProxyCommand token itself is not rendered. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * feat(ssh): simplify host TOTP setup and type codes into the terminal Move authenticator setup into a dialog so the host form keeps a single setting row, show keyboard-interactive prompts as field labels, and add a terminal context-menu action that types the current code without Enter. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): keep TOTP fill inside the auth dialog Remove the terminal context-menu action. Offer the verification-code button on each non-password prompt, highlight OTP prompts, and never write the code into the password field. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): keep MFA instructions and scan every screen Show keyboard-interactive name and instructions as plain text when they are not already the field label, and scan each unmatched desktop source once instead of repeating the first display. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): keep multiline MFA prompt directions Show lines before the last keyboard-interactive prompt as plain-text guidance, and keep that last line as the field label. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): scan TOTP QR codes at each display's full size Request a thumbnail as wide as the widest display and as tall as the tallest, so a portrait screen is not scaled into a landscape frame. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ssh): keep TOTP fill off menu prompts Judge the fill action from the last prompt line and the shared menu, SMS, and confirmation exclusion list. Use a secondary button on the label row, and simplify the host authenticator row. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
169 lines
6.3 KiB
TypeScript
169 lines
6.3 KiB
TypeScript
import type {
|
|
BuiltInHostProtocol,
|
|
Host,
|
|
HostProtocol,
|
|
PluginConfigurationValue,
|
|
PluginConnectionConfig,
|
|
} from './models';
|
|
import { isEncryptedCredentialPlaceholder } from './credentials';
|
|
|
|
const CONTRIBUTION_ID = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+\.[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-])*$/u;
|
|
const MAX_CONTRIBUTION_ID_LENGTH = 192;
|
|
const PLUGIN_PROTOCOL_PREFIX = 'plugin:';
|
|
const MAX_CONFIGURATION_BYTES = 128 * 1024;
|
|
const MAX_CONFIGURATION_DEPTH = 32;
|
|
const MAX_CONFIGURATION_ENTRIES = 4096;
|
|
const BUILT_IN_HOST_PROTOCOLS = new Set<BuiltInHostProtocol>([
|
|
'ssh',
|
|
'telnet',
|
|
'mosh',
|
|
'et',
|
|
'local',
|
|
'serial',
|
|
]);
|
|
|
|
export const pluginProtocolForProvider = (providerId: string): HostProtocol => `plugin:${providerId}`;
|
|
|
|
export const isPluginHostProtocol = (protocol?: string): protocol is `plugin:${string}` => (
|
|
typeof protocol === 'string'
|
|
&& protocol.startsWith(PLUGIN_PROTOCOL_PREFIX)
|
|
&& protocol.length <= PLUGIN_PROTOCOL_PREFIX.length + MAX_CONTRIBUTION_ID_LENGTH
|
|
&& CONTRIBUTION_ID.test(protocol.slice(PLUGIN_PROTOCOL_PREFIX.length))
|
|
);
|
|
|
|
export const isBuiltInHostProtocol = (protocol?: string): protocol is BuiltInHostProtocol => (
|
|
typeof protocol === 'string' && BUILT_IN_HOST_PROTOCOLS.has(protocol as BuiltInHostProtocol)
|
|
);
|
|
|
|
export const isPluginCredentialCatalogEntryAvailable = (
|
|
id: string,
|
|
value: string | undefined,
|
|
catalogIds: ReadonlySet<string>,
|
|
): value is string => (
|
|
catalogIds.has(id)
|
|
&& typeof value === 'string'
|
|
&& value.length > 0
|
|
&& !isEncryptedCredentialPlaceholder(value)
|
|
&& new TextEncoder().encode(value).byteLength <= 64 * 1024
|
|
);
|
|
|
|
export const isSafePluginAuthenticationUrl = (value: string): boolean => {
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.username || url.password) return false;
|
|
if (url.protocol === 'https:') return true;
|
|
if (url.protocol !== 'http:') return false;
|
|
const hostname = url.hostname.toLowerCase();
|
|
return hostname === 'localhost'
|
|
|| hostname === '[::1]'
|
|
|| /^127(?:\.\d{1,3}){3}$/u.test(hostname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
function validateConfiguration(value: unknown, depth: number, budget: { entries: number }): value is PluginConfigurationValue {
|
|
if (depth > MAX_CONFIGURATION_DEPTH || ++budget.entries > MAX_CONFIGURATION_ENTRIES) return false;
|
|
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
|
|
if (typeof value === 'number') return Number.isFinite(value);
|
|
if (Array.isArray(value)) return value.every((item) => validateConfiguration(item, depth + 1, budget));
|
|
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return false;
|
|
return Object.entries(value).every(([key, item]) => (
|
|
key.length > 0
|
|
&& key.length <= 256
|
|
&& key !== '__proto__'
|
|
&& key !== 'prototype'
|
|
&& key !== 'constructor'
|
|
&& validateConfiguration(item, depth + 1, budget)
|
|
));
|
|
}
|
|
|
|
export function sanitizePluginConnection(
|
|
value: unknown,
|
|
protocol?: string,
|
|
): PluginConnectionConfig | undefined {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
const candidate = value as Partial<PluginConnectionConfig>;
|
|
if (typeof candidate.providerId !== 'string' || !CONTRIBUTION_ID.test(candidate.providerId)
|
|
|| candidate.providerId.length > MAX_CONTRIBUTION_ID_LENGTH
|
|
|| protocol !== pluginProtocolForProvider(candidate.providerId)
|
|
|| !validateConfiguration(candidate.configuration, 0, { entries: 0 })) {
|
|
return undefined;
|
|
}
|
|
const serialized = JSON.stringify(candidate.configuration);
|
|
if (new TextEncoder().encode(serialized).byteLength > MAX_CONFIGURATION_BYTES) return undefined;
|
|
const authenticationProviderId = typeof candidate.authenticationProviderId === 'string'
|
|
&& CONTRIBUTION_ID.test(candidate.authenticationProviderId)
|
|
&& candidate.authenticationProviderId.length <= 192
|
|
? candidate.authenticationProviderId
|
|
: undefined;
|
|
const credentialId = typeof candidate.credentialId === 'string'
|
|
&& candidate.credentialId.length >= 16
|
|
&& candidate.credentialId.length <= 256
|
|
&& !candidate.credentialId.includes('\0')
|
|
? candidate.credentialId
|
|
: undefined;
|
|
return {
|
|
providerId: candidate.providerId,
|
|
configuration: structuredClone(candidate.configuration),
|
|
...(authenticationProviderId ? { authenticationProviderId } : {}),
|
|
...(credentialId ? { credentialId } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Plugin Providers own their connection and authentication configuration.
|
|
* Remove hidden first-party protocol state so switching an existing host to a
|
|
* plugin cannot retain credentials or transport behavior that the plugin UI
|
|
* cannot display or manage.
|
|
*/
|
|
export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
|
|
if (!isPluginHostProtocol(host.protocol)) return host;
|
|
const {
|
|
port: _port,
|
|
identityId: _identityId,
|
|
identityFileId: _identityFileId,
|
|
identityFilePaths: _identityFilePaths,
|
|
password: _password,
|
|
totp: _totp,
|
|
savePassword: _savePassword,
|
|
authMethod: _authMethod,
|
|
authPolicyVersion: _authPolicyVersion,
|
|
requiresMfa: _requiresMfa,
|
|
useSshAgent: _useSshAgent,
|
|
identityAgent: _identityAgent,
|
|
identitiesOnly: _identitiesOnly,
|
|
addKeysToAgent: _addKeysToAgent,
|
|
useKeychain: _useKeychain,
|
|
agentForwarding: _agentForwarding,
|
|
x11Forwarding: _x11Forwarding,
|
|
proxy: _proxy,
|
|
proxyProfileId: _proxyProfileId,
|
|
proxyConfig: _proxyConfig,
|
|
hostChain: _hostChain,
|
|
moshEnabled: _moshEnabled,
|
|
moshServerPath: _moshServerPath,
|
|
etEnabled: _etEnabled,
|
|
etPort: _etPort,
|
|
protocols: _protocols,
|
|
telnetPort: _telnetPort,
|
|
telnetEnabled: _telnetEnabled,
|
|
telnetIdentityId: _telnetIdentityId,
|
|
telnetUsername: _telnetUsername,
|
|
telnetPassword: _telnetPassword,
|
|
serialConfig: _serialConfig,
|
|
sftpSudo: _sftpSudo,
|
|
legacyAlgorithms: _legacyAlgorithms,
|
|
skipEcdsaHostKey: _skipEcdsaHostKey,
|
|
algorithms: _algorithms,
|
|
keepaliveInterval: _keepaliveInterval,
|
|
keepaliveCountMax: _keepaliveCountMax,
|
|
keepaliveOverride: _keepaliveOverride,
|
|
sshTcpConnectTimeoutSeconds: _sshTcpConnectTimeoutSeconds,
|
|
sshAuthReadyTimeoutSeconds: _sshAuthReadyTimeoutSeconds,
|
|
...pluginHost
|
|
} = host;
|
|
delete (pluginHost as { singleChannelSsh?: unknown }).singleChannelSsh;
|
|
return pluginHost;
|
|
}
|