Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 0e856ad9bb 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>
2026-10-20 10:27:44 +00:00
Cursor Agentand陈大猫 08aff13469 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>
2026-10-20 09:26:11 +00:00
Cursor Agentand陈大猫 21a174b573 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>
2026-10-20 09:18:16 +00:00
Cursor Agentand陈大猫 147ae96c06 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>
2026-10-20 09:09:19 +00:00
Cursor Agentand陈大猫 90e3e95f88 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>
2026-10-20 09:00:50 +00:00
Cursor Agentand陈大猫 426e30cd35 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>
2026-10-20 08:55:19 +00:00
Cursor Agentand陈大猫 1769005f6d 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>
2026-10-20 08:27:48 +00:00
Cursor Agentand陈大猫 75ced1e093 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>
2026-10-20 07:39:44 +00:00
Cursor Agentand陈大猫 aa15e36880 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>
2026-10-20 07:27:52 +00:00
Cursor Agentand陈大猫 101cb8eeb9 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>
2026-10-20 07:23:39 +00:00
Cursor Agentand陈大猫 33776fb6cc 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>
2026-10-20 07:17:27 +00:00
Cursor Agentand陈大猫 d12363ecf8 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>
2026-10-20 07:03:54 +00:00
58 changed files with 2347 additions and 111 deletions

No files matched your search

+10
View File
@@ -376,6 +376,14 @@ function AppViewInner({ domains }: AppViewProps) {
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
const pendingKeyboardRequest = keyboardInteractiveQueue[0] || null;
const pendingTotpUri = pendingKeyboardRequest?.totpUri;
const handleFillTotp = useCallback(async () => {
if (!pendingTotpUri) return null;
const result = await netcattyBridge.get()?.previewTotp?.({ uri: pendingTotpUri });
return result?.ok && result.code ? result.code : null;
}, [pendingTotpUri]);
const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState();
@@ -1022,6 +1030,8 @@ function AppViewInner({ domains }: AppViewProps) {
request={keyboardInteractiveQueue[0] || null}
onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel}
canFillTotp={Boolean(pendingTotpUri)}
onFillTotp={handleFillTotp}
/>
{/* Indicator when more 2FA requests are pending */}
{keyboardInteractiveQueue.length > 1 && (
+3
View File
@@ -554,6 +554,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
hostname: request.hostname,
savedPassword: request.savedPassword,
allowSavePassword: request.allowSavePassword !== false,
...(typeof request.totpUri === "string" && request.totpUri && !request.totpUri.startsWith("enc:v1:")
? { totpUri: request.totpUri }
: {}),
}]);
});
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
+2
View File
@@ -912,11 +912,13 @@ export const enTerminalMessages: Messages = {
'keyboard.interactive.desc': 'The server requires additional authentication.',
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
'keyboard.interactive.response': 'Response',
'keyboard.interactive.verificationCode': 'Verification code',
'keyboard.interactive.enterCode': 'Enter verification code',
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.savePassword': 'Save password',
'keyboard.interactive.fillTotp': 'Fill verification code',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',
+29
View File
@@ -876,6 +876,35 @@ export const enVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': 'Use the selected SSH certificate, with the saved password as a fallback when present.',
'hostDetails.auth.mfaFirst': 'Prefer interactive auth',
'hostDetails.auth.mfaFirst.desc': 'For servers that require password and MFA through keyboard-interactive.',
'hostDetails.totp.title': 'Authenticator (TOTP)',
'hostDetails.totp.enable': 'Save a TOTP secret for this host',
'hostDetails.totp.setup': 'Set up…',
'hostDetails.totp.edit': 'Edit',
'hostDetails.totp.modalTitle': 'Set up authenticator',
'hostDetails.totp.replacePlaceholder': 'Paste a new secret to replace the current one',
'hostDetails.totp.warning': 'Anyone who can open this vault gets this code and the password. Use it only on a personal device.',
'hostDetails.totp.secret': 'Secret or otpauth URI',
'hostDetails.totp.secretPlaceholder': 'Paste otpauth://totp/... or a Base32 secret',
'hostDetails.totp.importQr': 'Import QR image',
'hostDetails.totp.scanScreen': 'Scan QR code from screen',
'hostDetails.totp.scanning': 'Scanning screens…',
'hostDetails.totp.importedAccount': 'Imported {account}',
'hostDetails.totp.preview': 'Current code',
'hostDetails.totp.countdown': '{seconds}s remaining',
'hostDetails.totp.autoSubmit': 'Auto-submit on known TOTP prompts',
'hostDetails.totp.autoSubmitDesc': 'Submits once per connection, and only when the prompt exactly matches JumpServer, Google Authenticator PAM, or your pattern.',
'hostDetails.totp.promptPattern': 'Custom prompt pattern',
'hostDetails.totp.promptPatternPlaceholder': 'Optional RE2 regular expression',
'hostDetails.totp.clear': 'Remove authenticator',
'hostDetails.totp.removeTitle': 'Remove authenticator?',
'hostDetails.totp.removeMessage': 'This host will no longer store this TOTP secret.',
'hostDetails.totp.error.hotp': 'HOTP is not supported. Use a TOTP secret.',
'hostDetails.totp.error.migration': 'otpauth-migration QR codes are not supported. Use the service binding QR code for a single account.',
'hostDetails.totp.error.invalid': 'Could not read a TOTP secret from that value.',
'hostDetails.totp.error.notFound': 'No TOTP QR code was found.',
'hostDetails.totp.error.permission': 'Screen recording permission is required. Allow Netcatty to record the screen in System Settings, then try again.',
'hostDetails.totp.error.unavailable': 'Screen capture is not available.',
'hostDetails.totp.error.pattern': 'The custom prompt pattern is not a valid RE2 regular expression.',
'hostDetails.auth.credentialRequired': 'Select a key or certificate before saving this login method.',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
+2
View File
@@ -874,11 +874,13 @@ export const esTerminalMessages: Messages = {
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
'keyboard.interactive.response': 'Respuesta',
'keyboard.interactive.verificationCode': 'Código de verificación',
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
'keyboard.interactive.submit': 'Enviar',
'keyboard.interactive.verifying': 'Verificando...',
'keyboard.interactive.savePassword': 'Guardar contraseña',
'keyboard.interactive.fillTotp': 'Rellenar código de verificación',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'Frase de contraseña de la clave SSH',
+29
View File
@@ -866,6 +866,35 @@ export const esVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
'hostDetails.totp.title': 'Autenticador (TOTP)',
'hostDetails.totp.enable': 'Guardar un secreto TOTP para este host',
'hostDetails.totp.setup': 'Configurar…',
'hostDetails.totp.edit': 'Editar',
'hostDetails.totp.modalTitle': 'Configurar autenticador',
'hostDetails.totp.replacePlaceholder': 'Pega un secreto nuevo para reemplazar el actual',
'hostDetails.totp.warning': 'Quien abra esta caja fuerte obtiene este código y la contraseña. Úsalo solo en un dispositivo personal.',
'hostDetails.totp.secret': 'Secreto o URI otpauth',
'hostDetails.totp.secretPlaceholder': 'Pega otpauth://totp/... o un secreto Base32',
'hostDetails.totp.importQr': 'Importar imagen QR',
'hostDetails.totp.scanScreen': 'Escanear código QR de la pantalla',
'hostDetails.totp.scanning': 'Escaneando pantallas…',
'hostDetails.totp.importedAccount': 'Importado: {account}',
'hostDetails.totp.preview': 'Código actual',
'hostDetails.totp.countdown': 'Quedan {seconds} s',
'hostDetails.totp.autoSubmit': 'Enviar automáticamente en avisos TOTP conocidos',
'hostDetails.totp.autoSubmitDesc': 'Se envía una sola vez por conexión y solo si el aviso coincide exactamente con JumpServer, Google Authenticator PAM o tu patrón.',
'hostDetails.totp.promptPattern': 'Patrón de aviso personalizado',
'hostDetails.totp.promptPatternPlaceholder': 'Expresión regular RE2 opcional',
'hostDetails.totp.clear': 'Quitar autenticador',
'hostDetails.totp.removeTitle': '¿Quitar el autenticador?',
'hostDetails.totp.removeMessage': 'Este host ya no guardará este secreto TOTP.',
'hostDetails.totp.error.hotp': 'HOTP no es compatible. Usa un secreto TOTP.',
'hostDetails.totp.error.migration': 'Los códigos otpauth-migration no son compatibles. Usa el código QR de vinculación de una sola cuenta.',
'hostDetails.totp.error.invalid': 'No se pudo leer un secreto TOTP de ese valor.',
'hostDetails.totp.error.notFound': 'No se encontró un código QR TOTP.',
'hostDetails.totp.error.permission': 'Se necesita permiso de grabación de pantalla. Permite que Netcatty grabe la pantalla en Ajustes del sistema e inténtalo de nuevo.',
'hostDetails.totp.error.unavailable': 'La captura de pantalla no está disponible.',
'hostDetails.totp.error.pattern': 'El patrón personalizado no es una expresión regular RE2 válida.',
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
'hostDetails.identity.suggestions': 'Identidades',
'hostDetails.identity.missing': 'Identidad no encontrada',
+2
View File
@@ -919,11 +919,13 @@ export const ruTerminalMessages: Messages = {
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
'keyboard.interactive.response': 'Ответ',
'keyboard.interactive.verificationCode': 'Код подтверждения',
'keyboard.interactive.enterCode': 'Введите код подтверждения',
'keyboard.interactive.enterResponse': 'Введите ответ',
'keyboard.interactive.submit': 'Отправить',
'keyboard.interactive.verifying': 'Проверка...',
'keyboard.interactive.savePassword': 'Сохранить пароль',
'keyboard.interactive.fillTotp': 'Подставить код подтверждения',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'Парольная фраза SSH-ключа',
+29
View File
@@ -914,6 +914,35 @@ export const ruVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': 'Использовать выбранный SSH-сертификат и сохранённый пароль как запасной вариант.',
'hostDetails.auth.mfaFirst': 'Сначала интерактивная аутентификация',
'hostDetails.auth.mfaFirst.desc': 'Для серверов, которым нужны пароль и MFA через keyboard-interactive.',
'hostDetails.totp.title': 'Аутентификатор (TOTP)',
'hostDetails.totp.enable': 'Сохранить секрет TOTP для этого хоста',
'hostDetails.totp.setup': 'Настроить…',
'hostDetails.totp.edit': 'Изменить',
'hostDetails.totp.modalTitle': 'Настройка аутентификатора',
'hostDetails.totp.replacePlaceholder': 'Вставьте новый секрет, чтобы заменить текущий',
'hostDetails.totp.warning': 'Тот, кто откроет это хранилище, получит этот код и пароль. Используйте только на личном устройстве.',
'hostDetails.totp.secret': 'Секрет или URI otpauth',
'hostDetails.totp.secretPlaceholder': 'Вставьте otpauth://totp/... или секрет Base32',
'hostDetails.totp.importQr': 'Импорт изображения QR',
'hostDetails.totp.scanScreen': 'Сканировать QR-код с экрана',
'hostDetails.totp.scanning': 'Сканирование экранов…',
'hostDetails.totp.importedAccount': 'Импортировано: {account}',
'hostDetails.totp.preview': 'Текущий код',
'hostDetails.totp.countdown': 'Осталось {seconds} с',
'hostDetails.totp.autoSubmit': 'Автоотправка на известных запросах TOTP',
'hostDetails.totp.autoSubmitDesc': 'Отправляется один раз за подключение и только если запрос точно совпадает с JumpServer, Google Authenticator PAM или вашим шаблоном.',
'hostDetails.totp.promptPattern': 'Свой шаблон запроса',
'hostDetails.totp.promptPatternPlaceholder': 'Необязательное регулярное выражение RE2',
'hostDetails.totp.clear': 'Удалить аутентификатор',
'hostDetails.totp.removeTitle': 'Удалить аутентификатор?',
'hostDetails.totp.removeMessage': 'Этот хост больше не будет хранить этот секрет TOTP.',
'hostDetails.totp.error.hotp': 'HOTP не поддерживается. Используйте секрет TOTP.',
'hostDetails.totp.error.migration': 'Коды otpauth-migration не поддерживаются. Используйте QR-код привязки одной учетной записи.',
'hostDetails.totp.error.invalid': 'Не удалось прочитать секрет TOTP.',
'hostDetails.totp.error.notFound': 'QR-код TOTP не найден.',
'hostDetails.totp.error.permission': 'Нужно разрешение на запись экрана. Разрешите Netcatty запись экрана в системных настройках и повторите попытку.',
'hostDetails.totp.error.unavailable': 'Захват экрана недоступен.',
'hostDetails.totp.error.pattern': 'Пользовательский шаблон не является допустимым регулярным выражением RE2.',
'hostDetails.auth.credentialRequired': 'Перед сохранением этого способа входа выберите ключ или сертификат.',
'hostDetails.identity.suggestions': 'Идентификаторы',
'hostDetails.identity.missing': 'Идентификатор не найден',
@@ -996,11 +996,13 @@ export const zhCNTerminalMessages: Messages = {
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
'keyboard.interactive.response': '响应',
'keyboard.interactive.verificationCode': '验证码',
'keyboard.interactive.enterCode': '输入验证码',
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.savePassword': '保存密码',
'keyboard.interactive.fillTotp': '填入验证码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',
+29
View File
@@ -290,6 +290,35 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': '使用选中的 SSH 证书;如果同时保存了密码,可在失败后尝试密码。',
'hostDetails.auth.mfaFirst': '优先交互式认证',
'hostDetails.auth.mfaFirst.desc': '适用于必须通过 keyboard-interactive 输入密码和 MFA 的服务器。',
'hostDetails.totp.title': '验证器 (TOTP)',
'hostDetails.totp.enable': '为这台主机保存 TOTP 密钥',
'hostDetails.totp.setup': '设置…',
'hostDetails.totp.edit': '编辑',
'hostDetails.totp.modalTitle': '设置验证器',
'hostDetails.totp.replacePlaceholder': '粘贴新密钥以替换当前密钥',
'hostDetails.totp.warning': '能打开这个保险库的人会同时得到验证码和密码。请只在个人设备上使用。',
'hostDetails.totp.secret': '密钥或 otpauth URI',
'hostDetails.totp.secretPlaceholder': '粘贴 otpauth://totp/... 或 Base32 密钥',
'hostDetails.totp.importQr': '导入二维码图片',
'hostDetails.totp.scanScreen': '从屏幕扫描二维码',
'hostDetails.totp.scanning': '正在扫描屏幕…',
'hostDetails.totp.importedAccount': '已导入 {account}',
'hostDetails.totp.preview': '当前验证码',
'hostDetails.totp.countdown': '剩余 {seconds} 秒',
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自动提交',
'hostDetails.totp.autoSubmitDesc': '每次连接只提交一次,且提示必须精确匹配 JumpServer、Google Authenticator PAM,或你填写的正则。',
'hostDetails.totp.promptPattern': '自定义提示正则',
'hostDetails.totp.promptPatternPlaceholder': '可选的 RE2 正则表达式',
'hostDetails.totp.clear': '移除验证器',
'hostDetails.totp.removeTitle': '移除验证器?',
'hostDetails.totp.removeMessage': '这台主机将不再保存这个 TOTP 密钥。',
'hostDetails.totp.error.hotp': '不支持 HOTP。请使用 TOTP 密钥。',
'hostDetails.totp.error.migration': '不支持 otpauth-migration 二维码。请使用服务提供的单个账号绑定二维码。',
'hostDetails.totp.error.invalid': '无法从该内容读取 TOTP 密钥。',
'hostDetails.totp.error.notFound': '没有找到 TOTP 二维码。',
'hostDetails.totp.error.permission': '需要屏幕录制权限。请在系统设置中允许 Netcatty 录制屏幕后再试。',
'hostDetails.totp.error.unavailable': '无法使用屏幕捕获。',
'hostDetails.totp.error.pattern': '自定义提示正则不是有效的 RE2 正则表达式。',
'hostDetails.auth.credentialRequired': '保存这种登录方式前,请先选择密钥或证书。',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
+3 -1
View File
@@ -996,11 +996,13 @@ export const zhTWTerminalMessages: Messages = {
'keyboard.interactive.desc': '伺服器需要額外的身分驗證。',
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。',
'keyboard.interactive.response': '回應',
'keyboard.interactive.verificationCode': '驗證碼',
'keyboard.interactive.enterCode': '輸入驗證碼',
'keyboard.interactive.enterResponse': '輸入回應',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.submit': '送出',
'keyboard.interactive.verifying': '驗證中...',
'keyboard.interactive.savePassword': '儲存密碼',
'keyboard.interactive.fillTotp': '填入驗證碼',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 金鑰密碼',
+29
View File
@@ -290,6 +290,35 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': '使用選取的 SSH 憑證;如果同時儲存了密碼,可在失敗後嘗試密碼。',
'hostDetails.auth.mfaFirst': '優先互動式驗證',
'hostDetails.auth.mfaFirst.desc': '適用於必須透過 keyboard-interactive 輸入密碼和 MFA 的伺服器。',
'hostDetails.totp.title': '驗證器 (TOTP)',
'hostDetails.totp.enable': '為這台主機儲存 TOTP 金鑰',
'hostDetails.totp.setup': '設定…',
'hostDetails.totp.edit': '編輯',
'hostDetails.totp.modalTitle': '設定驗證器',
'hostDetails.totp.replacePlaceholder': '貼上新金鑰以取代目前的金鑰',
'hostDetails.totp.warning': '能開啟這個保險庫的人會同時取得驗證碼和密碼。請只在個人裝置上使用。',
'hostDetails.totp.secret': '金鑰或 otpauth URI',
'hostDetails.totp.secretPlaceholder': '貼上 otpauth://totp/... 或 Base32 金鑰',
'hostDetails.totp.importQr': '匯入 QR 碼圖片',
'hostDetails.totp.scanScreen': '從螢幕掃描 QR 碼',
'hostDetails.totp.scanning': '正在掃描螢幕…',
'hostDetails.totp.importedAccount': '已匯入 {account}',
'hostDetails.totp.preview': '目前驗證碼',
'hostDetails.totp.countdown': '剩餘 {seconds} 秒',
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自動送出',
'hostDetails.totp.autoSubmitDesc': '每次連線只送出一次,且提示必須精確符合 JumpServer、Google Authenticator PAM,或你填寫的規則運算式。',
'hostDetails.totp.promptPattern': '自訂提示規則',
'hostDetails.totp.promptPatternPlaceholder': '可選的 RE2 規則運算式',
'hostDetails.totp.clear': '移除驗證器',
'hostDetails.totp.removeTitle': '移除驗證器?',
'hostDetails.totp.removeMessage': '這台主機將不再保存這個 TOTP 金鑰。',
'hostDetails.totp.error.hotp': '不支援 HOTP。請使用 TOTP 金鑰。',
'hostDetails.totp.error.migration': '不支援 otpauth-migration QR 碼。請使用服務提供的單一帳號綁定 QR 碼。',
'hostDetails.totp.error.invalid': '無法從該內容讀取 TOTP 金鑰。',
'hostDetails.totp.error.notFound': '沒有找到 TOTP QR 碼。',
'hostDetails.totp.error.permission': '需要螢幕錄製權限。請在系統設定中允許 Netcatty 錄製螢幕後再試。',
'hostDetails.totp.error.unavailable': '無法使用螢幕擷取。',
'hostDetails.totp.error.pattern': '自訂提示規則不是有效的 RE2 規則運算式。',
'hostDetails.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
'hostDetails.identity.suggestions': '身分',
'hostDetails.identity.missing': '身分不存在',
@@ -3,6 +3,7 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
import { connectionTotpFields } from "../../../domain/totpConfig";
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
import {
findIncompleteProxyIdentityId,
@@ -121,6 +122,7 @@ export const buildSftpHostCredentials = ({
authMethod: jumpAuth.authMethod,
requiresMfa: !!jumpHost.requiresMfa,
password: jumpPassword,
...connectionTotpFields(jumpHost),
privateKey: jumpKeyAuth.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpKeyAuth.passphrase,
@@ -182,6 +184,7 @@ export const buildSftpHostCredentials = ({
requiresMfa: !!host.requiresMfa,
port: host.port || 22,
password,
...connectionTotpFields(host),
privateKey: keyAuth.privateKey,
certificate: key?.certificate,
passphrase: keyAuth.passphrase,
@@ -2,6 +2,7 @@ import React from "react";
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
import type { Host } from "../types";
import { applyHostAuthMethodSelection } from "../domain/sshAuth";
import { HostTotpFields } from "./HostTotpFields";
import { HostIconPicker } from "./HostIconPicker";
import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox";
@@ -101,6 +102,7 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
distroOptions,
effectiveFormDistro,
getDistroOptionLabel,
totpFlushRef,
}) => {
const selectAuthMethod = (authMethod: "auto" | "password" | "key" | "certificate") => {
setForm((previous: Host) => applyEffectiveHostAuthMethodSelection(
@@ -739,6 +741,13 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</div>
</div>
)}
<HostTotpFields
t={t}
form={form}
setForm={setForm}
effectiveProtocol={effectiveProtocol}
flushRef={totpFlushRef}
/>
</div>
</HostDetailsSection>
@@ -92,7 +92,7 @@ test("HostDetailsPanel labels command proxy summaries consistently", () => {
assert.match(markup, /ProxyCommand/);
assert.doesNotMatch(markup, /COMMAND/);
assert.doesNotMatch(markup, /cloudflared access ssh/);
assert.doesNotMatch(markup, /secret/);
assert.doesNotMatch(markup, /--token secret/);
});
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
+17 -1
View File
@@ -44,6 +44,7 @@ import {
} from "./ui/aside-panel";
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
import type { HostTotpFlushResult } from "./HostTotpFields";
import {
LINUX_DISTRO_OPTION_IDS,
parseOptionalPortInput,
@@ -163,6 +164,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
};
const { checkSshAgent } = useApplicationBackend();
const baselineSnippetsRef = useRef(snippets);
const totpFlushRef = useRef<(() => Promise<HostTotpFlushResult>) | null>(null);
const savingHostRef = useRef(false);
const baselineHydratedRef = useRef(snippets.length > 0);
const openingQueueIdsRef = useRef<string[]>([]);
const [form, setForm] = useState<Host>(
@@ -527,7 +530,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol))
: hasHostname;
const handleSubmit = () => {
const handleSubmit = async () => {
if (savingHostRef.current) return;
savingHostRef.current = true;
try {
const flushed = await totpFlushRef.current?.();
if (flushed && !flushed.ok) return;
const hostname = form.hostname.trim()
|| (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : "");
if (!hostname || !canSaveHost) return;
@@ -608,6 +616,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
authPolicyVersion: 1,
managedSourceId: finalManagedSourceId,
};
if (flushed?.ok) {
if (flushed.totp) cleaned.totp = flushed.totp;
else delete cleaned.totp;
}
if (pluginProtocolActive) {
const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol);
if (!pluginConnection) return;
@@ -735,6 +747,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
}
}
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
} finally {
savingHostRef.current = false;
}
};
const handleCreateGroup = () => {
@@ -1128,6 +1143,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
t={t}
form={form}
setForm={setForm}
totpFlushRef={totpFlushRef}
update={update}
groupDefaults={effectiveGroupDefaults}
effectiveAuthMethod={effectiveAuthMethod}
+104
View File
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import type { Host } from "../domain/models";
test("host totp setup stays on the shared setting row and opens a dialog", async () => {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
url: "http://localhost",
pretendToBeVisual: true,
});
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
install("window", dom.window);
install("document", dom.window.document);
install("navigator", dom.window.navigator);
install("HTMLElement", dom.window.HTMLElement);
install("HTMLInputElement", dom.window.HTMLInputElement);
install("HTMLButtonElement", dom.window.HTMLButtonElement);
install("Element", dom.window.Element);
install("Node", dom.window.Node);
install("MutationObserver", dom.window.MutationObserver);
install("Event", dom.window.Event);
install("CustomEvent", dom.window.CustomEvent);
install("MouseEvent", dom.window.MouseEvent);
install("KeyboardEvent", dom.window.KeyboardEvent);
install("FocusEvent", dom.window.FocusEvent);
install("PointerEvent", dom.window.PointerEvent);
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
install("ResizeObserver", ResizeObserverStub);
install("IS_REACT_ACT_ENVIRONMENT", true);
Object.assign(dom.window, { netcatty: {} });
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { HostTotpFields } = await import("./HostTotpFields.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const form = {
id: "host-1",
label: "Jump",
hostname: "127.0.0.1",
port: 22,
username: "root",
protocol: "ssh",
} as Host;
let latest = form;
const root = createRoot(dom.window.document.getElementById("root")!);
const t = (key: string) => key;
try {
await act(async () => {
root.render(
<TooltipProvider>
<HostTotpFields
t={t}
form={latest}
setForm={(updater) => {
latest = typeof updater === "function" ? updater(latest) : updater;
}}
effectiveProtocol="ssh"
/>
</TooltipProvider>,
);
});
const markup = dom.window.document.body.innerHTML;
assert.match(markup, /hostDetails\.totp\.setup/);
assert.doesNotMatch(markup, /rounded-md border border-border\/60 p-3/);
assert.equal(dom.window.document.querySelector("[role='dialog']"), null);
assert.equal(dom.window.document.querySelector("[role='switch']"), null);
const button = [...dom.window.document.querySelectorAll("button")]
.find((node) => node.textContent?.includes("hostDetails.totp.setup"));
assert.ok(button);
await act(async () => { button.click(); });
assert.ok(dom.window.document.body.textContent?.includes("hostDetails.totp.modalTitle"));
assert.ok(dom.window.document.body.innerHTML.includes("hostDetails.totp.secretPlaceholder"));
assert.equal(latest.totp, undefined);
const fields = readFileSync(new URL("./HostTotpFields.tsx", import.meta.url), "utf8");
assert.match(fields, /label=\{t\("hostDetails\.totp\.title"\)\}/);
assert.match(fields, /setConfirmClear\(true\)/);
assert.match(fields, /variant="secondary"/);
const sections = readFileSync(new URL("./HostDetailsConnectionSections.tsx", import.meta.url), "utf8");
const passwordAt = sections.indexOf("hostDetails.password.placeholder");
const totpAt = sections.indexOf("<HostTotpFields");
assert.ok(passwordAt > 0 && totpAt > passwordAt);
} finally {
await act(async () => { root.unmount(); });
await new Promise((resolve) => setTimeout(resolve, 50));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
+568
View File
@@ -0,0 +1,568 @@
import React, { useEffect, useRef, useState } from "react";
import jsQR from "jsqr";
import { RE2JS } from "re2js";
import type { Host, HostTotpConfig } from "../domain/models";
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { HostDetailsSettingRow } from "./host-details";
import { SystemPanelConfirmDialog } from "./systemManager/SystemPanelConfirmDialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
type Preview = {
uri: string;
code: string;
secondsRemaining: number;
issuer?: string;
label?: string;
};
export type HostTotpFlushResult =
| { ok: true; totp: HostTotpConfig | undefined }
| { ok: false };
type HostTotpFieldsProps = {
t: (key: string, params?: Record<string, string | number>) => string;
form: Host;
setForm: (updater: Host | ((host: Host) => Host)) => void;
effectiveProtocol?: string;
flushRef?: React.MutableRefObject<(() => Promise<HostTotpFlushResult>) | null>;
};
const HIDDEN_PROTOCOLS = new Set(["telnet", "serial"]);
function errorKey(error: string | undefined): string {
switch (error) {
case "hotp":
return "hostDetails.totp.error.hotp";
case "migration":
return "hostDetails.totp.error.migration";
case "not-found":
return "hostDetails.totp.error.notFound";
case "screen-permission-denied":
return "hostDetails.totp.error.permission";
case "unavailable":
return "hostDetails.totp.error.unavailable";
case "pattern":
return "hostDetails.totp.error.pattern";
default:
return "hostDetails.totp.error.invalid";
}
}
async function decodeQrFile(file: File): Promise<string> {
const bitmap = await createImageBitmap(file);
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("unavailable");
context.drawImage(bitmap, 0, 0);
const image = context.getImageData(0, 0, canvas.width, canvas.height);
return jsQR(image.data, image.width, image.height)?.data || "";
} finally {
bitmap.close?.();
}
}
function validPattern(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.length > 200) return null;
try {
RE2JS.compile(trimmed);
return trimmed;
} catch {
return null;
}
}
function importedAccount(preview: Preview): string {
const issuer = preview.issuer?.trim() || "";
const label = preview.label?.trim() || "";
if (issuer && label && !label.toLowerCase().startsWith(issuer.toLowerCase())) {
return `${issuer} (${label})`;
}
return label || issuer;
}
function useLiveTotp(source: string | undefined, hostId: string): Preview | null {
const [preview, setPreview] = useState<Preview | null>(null);
const hostRef = useRef(hostId);
hostRef.current = hostId;
useEffect(() => {
if (!source) {
setPreview(null);
return undefined;
}
const expectedHostId = hostId;
let cancelled = false;
const bridge = netcattyBridge.get();
if (!bridge?.previewTotp) return undefined;
const endsAtRef = { current: 0 };
let refreshed = false;
const apply = (next: Preview) => {
if (cancelled || hostRef.current !== expectedHostId) return;
endsAtRef.current = Date.now() + next.secondsRemaining * 1000;
refreshed = false;
setPreview(next);
};
void bridge.previewTotp({ text: source }).then((result) => {
if (!result?.ok || !result.uri || !result.code) return;
apply({
uri: result.uri,
code: result.code,
secondsRemaining: result.secondsRemaining || result.period || 30,
issuer: result.issuer,
label: result.label,
});
}).catch(() => undefined);
const timer = setInterval(() => {
if (cancelled || !endsAtRef.current) return;
const left = Math.max(0, Math.ceil((endsAtRef.current - Date.now()) / 1000));
setPreview((current) => (
current && current.secondsRemaining !== left
? { ...current, secondsRemaining: left }
: current
));
if (left !== 0 || refreshed) return;
refreshed = true;
void bridge.previewTotp({ text: source }).then((result) => {
if (!result?.ok || !result.uri || !result.code) return;
apply({
uri: result.uri,
code: result.code,
secondsRemaining: result.secondsRemaining || result.period || 30,
issuer: result.issuer,
label: result.label,
});
}).catch(() => undefined);
}, 250);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [hostId, source]);
return source ? preview : null;
}
export const HostTotpFields: React.FC<HostTotpFieldsProps> = ({
t,
form,
setForm,
effectiveProtocol,
flushRef,
}) => {
const configured = Boolean(form.totp?.uri);
const [setupOpen, setSetupOpen] = useState(false);
const [draft, setDraft] = useState("");
const [pattern, setPattern] = useState(form.totp?.promptPattern || "");
const [autoSubmit, setAutoSubmit] = useState(form.totp?.autoSubmit !== false);
const [modalUri, setModalUri] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [scanning, setScanning] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const hostIdRef = useRef(form.id);
const formRef = useRef(form);
const setupOpenRef = useRef(setupOpen);
const draftRef = useRef(draft);
const patternRef = useRef(pattern);
const autoSubmitRef = useRef(autoSubmit);
const modalUriRef = useRef(modalUri);
const requestRef = useRef(0);
formRef.current = form;
setupOpenRef.current = setupOpen;
draftRef.current = draft;
patternRef.current = pattern;
autoSubmitRef.current = autoSubmit;
modalUriRef.current = modalUri;
const [trackedHostId, setTrackedHostId] = useState(form.id);
if (form.id !== trackedHostId) {
setTrackedHostId(form.id);
setSetupOpen(false);
setDraft("");
setPattern(form.totp?.promptPattern || "");
setAutoSubmit(form.totp?.autoSubmit !== false);
setModalUri(undefined);
setError(null);
setBusy(false);
setScanning(false);
setConfirmClear(false);
draftRef.current = "";
patternRef.current = form.totp?.promptPattern || "";
autoSubmitRef.current = form.totp?.autoSubmit !== false;
modalUriRef.current = undefined;
setupOpenRef.current = false;
}
hostIdRef.current = form.id;
const rowPreview = useLiveTotp(form.totp?.uri, form.id);
const modalPreview = useLiveTotp(setupOpen ? (modalUri || form.totp?.uri) : undefined, form.id);
const writeTotp = (next: HostTotpConfig | undefined) => {
const expectedHostId = hostIdRef.current;
setForm((current) => {
if (current.id !== expectedHostId) return current;
const updated = { ...current };
if (next?.uri) updated.totp = next;
else delete updated.totp;
return updated;
});
};
const previewText = async (text: string): Promise<string | null> => {
const expectedHostId = hostIdRef.current;
const requestId = ++requestRef.current;
const bridge = netcattyBridge.get();
if (!bridge?.previewTotp) {
setError("unavailable");
return null;
}
setBusy(true);
try {
const result = await bridge.previewTotp({ text });
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return null;
if (!result?.ok || !result.uri || !result.code) {
setError(result?.error || "invalid");
return null;
}
setError(null);
setModalUri(result.uri);
modalUriRef.current = result.uri;
setDraft("");
draftRef.current = "";
return result.uri;
} catch {
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
return null;
} finally {
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setBusy(false);
}
};
const commitSetup = async (options?: { requireSecret?: boolean }): Promise<HostTotpFlushResult> => {
const expectedHostId = hostIdRef.current;
const patternValue = validPattern(patternRef.current);
if (patternValue == null) {
setError("pattern");
return { ok: false };
}
let uri = modalUriRef.current || formRef.current.totp?.uri;
const text = draftRef.current.trim();
if (text) {
uri = await previewText(text) || undefined;
if (hostIdRef.current !== expectedHostId) return { ok: false };
if (!uri) return { ok: false };
}
if (!uri) {
if (options?.requireSecret) {
setError("invalid");
return { ok: false };
}
return { ok: true, totp: formRef.current.totp };
}
const next: HostTotpConfig = {
uri,
autoSubmit: autoSubmitRef.current,
...(patternValue ? { promptPattern: patternValue } : {}),
};
writeTotp(next);
setSetupOpen(false);
setupOpenRef.current = false;
setDraft("");
draftRef.current = "";
setModalUri(undefined);
modalUriRef.current = undefined;
setError(null);
return { ok: true, totp: next };
};
const commitRef = useRef(commitSetup);
commitRef.current = commitSetup;
useEffect(() => {
if (!flushRef) return undefined;
const flush = async (): Promise<HostTotpFlushResult> => {
const expectedHostId = hostIdRef.current;
if (hostIdRef.current !== expectedHostId) return { ok: false };
if (!setupOpenRef.current) return { ok: true, totp: formRef.current.totp };
return commitRef.current();
};
flushRef.current = flush;
return () => {
if (flushRef.current === flush) flushRef.current = null;
};
}, [flushRef]);
const openSetup = () => {
setPattern(form.totp?.promptPattern || "");
patternRef.current = form.totp?.promptPattern || "";
setAutoSubmit(form.totp?.autoSubmit !== false);
autoSubmitRef.current = form.totp?.autoSubmit !== false;
setDraft("");
draftRef.current = "";
setModalUri(undefined);
modalUriRef.current = undefined;
setError(null);
setSetupOpen(true);
setupOpenRef.current = true;
};
const cancelSetup = () => {
requestRef.current += 1;
setSetupOpen(false);
setupOpenRef.current = false;
setDraft("");
draftRef.current = "";
setModalUri(undefined);
modalUriRef.current = undefined;
setError(null);
setBusy(false);
setScanning(false);
};
const clearTotp = () => {
cancelSetup();
writeTotp(undefined);
};
if (effectiveProtocol && HIDDEN_PROTOCOLS.has(effectiveProtocol)) return null;
const scanScreen = async () => {
const expectedHostId = form.id;
const requestId = ++requestRef.current;
const bridge = netcattyBridge.get();
if (!bridge?.scanTotpQrFromScreen) {
setError("unavailable");
return;
}
setBusy(true);
setScanning(true);
try {
const result = await bridge.scanTotpQrFromScreen();
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
if (!result?.ok || !result.uri || !result.code) {
setError(result?.error || "not-found");
return;
}
setError(null);
setModalUri(result.uri);
modalUriRef.current = result.uri;
setDraft("");
draftRef.current = "";
} catch {
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
} finally {
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) {
setBusy(false);
setScanning(false);
}
}
};
return (
<>
<HostDetailsSettingRow
label={t("hostDetails.totp.title")}
hint={t("hostDetails.totp.warning")}
>
<div className="flex items-center gap-2">
{rowPreview && (
<span
className="font-mono text-xs font-normal tabular-nums tracking-widest text-muted-foreground"
title={t("hostDetails.totp.countdown", { seconds: rowPreview.secondsRemaining })}
>
{rowPreview.code}
</span>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={openSetup}
>
{configured ? t("hostDetails.totp.edit") : t("hostDetails.totp.setup")}
</Button>
</div>
</HostDetailsSettingRow>
<Dialog open={setupOpen} onOpenChange={(open) => { if (!open) cancelSetup(); }}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{t("hostDetails.totp.modalTitle")}</DialogTitle>
<DialogDescription>{t("hostDetails.totp.warning")}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="host-totp-secret">{t("hostDetails.totp.secret")}</Label>
<Input
id="host-totp-secret"
value={draft}
onChange={(event) => {
setDraft(event.target.value);
draftRef.current = event.target.value;
}}
onBlur={() => {
if (draftRef.current.trim()) void previewText(draftRef.current);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void commitSetup({ requireSecret: true });
}
}}
placeholder={configured ? t("hostDetails.totp.replacePlaceholder") : t("hostDetails.totp.secretPlaceholder")}
aria-label={t("hostDetails.totp.secret")}
className="h-8 font-mono text-xs"
disabled={busy}
spellCheck={false}
autoFocus
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
disabled={busy}
onClick={() => fileRef.current?.click()}
>
{t("hostDetails.totp.importQr")}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
disabled={busy}
onClick={() => { void scanScreen(); }}
>
{scanning ? t("hostDetails.totp.scanning") : t("hostDetails.totp.scanScreen")}
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const expectedHostId = form.id;
const requestId = ++requestRef.current;
setBusy(true);
void decodeQrFile(file)
.then((text) => {
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
if (!text) {
setError("not-found");
setBusy(false);
return;
}
return previewText(text);
})
.catch(() => {
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
setError("invalid");
setBusy(false);
});
}}
/>
</div>
{modalPreview && (
<div className="space-y-1">
{importedAccount(modalPreview) ? (
<p className="text-sm text-foreground">
{t("hostDetails.totp.importedAccount", { account: importedAccount(modalPreview) })}
</p>
) : null}
<p className="text-sm text-muted-foreground">
{t("hostDetails.totp.preview")}
{" "}
<span className="font-mono tracking-widest text-foreground">{modalPreview.code}</span>
<span className="ml-2">{t("hostDetails.totp.countdown", { seconds: modalPreview.secondsRemaining })}</span>
</p>
</div>
)}
{error && <p className="text-xs text-destructive">{t(errorKey(error))}</p>}
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{t("hostDetails.totp.autoSubmit")}</p>
<p className="text-xs text-muted-foreground">{t("hostDetails.totp.autoSubmitDesc")}</p>
</div>
<Switch
checked={autoSubmit}
onCheckedChange={(checked) => {
setAutoSubmit(checked);
autoSubmitRef.current = checked;
}}
aria-label={t("hostDetails.totp.autoSubmit")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="host-totp-pattern">{t("hostDetails.totp.promptPattern")}</Label>
<Input
id="host-totp-pattern"
value={pattern}
onChange={(event) => {
setPattern(event.target.value);
patternRef.current = event.target.value;
setError((current) => (current === "pattern" ? null : current));
}}
placeholder={t("hostDetails.totp.promptPatternPlaceholder")}
aria-label={t("hostDetails.totp.promptPattern")}
className="h-8 font-mono text-xs"
spellCheck={false}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:justify-between">
{configured ? (
<Button type="button" variant="ghost" onClick={() => setConfirmClear(true)}>
{t("hostDetails.totp.clear")}
</Button>
) : <span />}
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={cancelSetup}>
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => { void commitSetup({ requireSecret: true }); }} disabled={busy}>
{t("common.save")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{confirmClear ? (
<SystemPanelConfirmDialog
open
destructive
title={t("hostDetails.totp.removeTitle")}
message={t("hostDetails.totp.removeMessage")}
confirmLabel={t("hostDetails.totp.clear")}
onOpenChange={(open) => { if (!open) setConfirmClear(false); }}
onConfirm={() => {
setConfirmClear(false);
clearTotp();
}}
/>
) : null}
</>
);
};
+81 -35
View File
@@ -4,48 +4,94 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { formatKeyboardInteractiveServerPrompt } from "./KeyboardInteractiveModal.tsx";
const require = createRequire(import.meta.url);
import {
keyboardInteractiveFieldLabel,
keyboardInteractiveGuidance,
totpFillPlacement,
visibleTotpFillPlacements,
} from "./KeyboardInteractiveModal.tsx";
const modalSource = readFileSync(new URL("./KeyboardInteractiveModal.tsx", import.meta.url), "utf8");
test("formatKeyboardInteractiveServerPrompt preserves server instructions and prompt labels", () => {
const text = formatKeyboardInteractiveServerPrompt({
hostname: "192.168.9.138",
name: "Keyboard-interactive authentication prompts from server",
instructions: "为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
prompts: [
{
prompt: "Secondary Authentication Password:",
echo: false,
},
],
});
assert.equal(
text,
[
"Keyboard-interactive authentication prompts from server:",
"| 为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
"| Secondary Authentication Password:",
].join("\n"),
);
test("keyboardInteractiveFieldLabel uses the cleaned last prompt line", () => {
assert.equal(keyboardInteractiveFieldLabel("Secondary Authentication Password:", "Verification code"), "Secondary Authentication Password");
assert.equal(keyboardInteractiveFieldLabel("JumpServer:\n| SMS code:", "Verification code"), "SMS code");
assert.equal(keyboardInteractiveFieldLabel("[OTP Code]: ", "Verification code"), "OTP Code");
assert.equal(keyboardInteractiveFieldLabel(" ", "Verification code"), "Verification code");
});
test("formatKeyboardInteractiveServerPrompt omits hostname-only fallback prompts", () => {
const text = formatKeyboardInteractiveServerPrompt({
hostname: "192.168.9.138",
name: "192.168.9.138",
instructions: "",
prompts: [
{
prompt: "Password:",
echo: false,
},
],
});
test("keyboard-interactive dialog shows a field label and an inline TOTP action", async () => {
assert.doesNotMatch(modalSource, /<pre/);
assert.match(modalSource, /keyboardInteractiveFieldLabel\(/);
assert.match(modalSource, /keyboardInteractiveGuidance\(/);
assert.match(modalSource, /data-totp-fill=\{fillPlacement\}/);
assert.match(modalSource, /variant="secondary"/);
assert.doesNotMatch(modalSource, /variant=\{fillPlacement/);
assert.match(modalSource, /totpPrompt\.shared\.mjs/);
const shared = require("../domain/totpPrompt.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
const runtime = require("../domain/totp.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
const esm = await import("../domain/totpPrompt.shared.mjs");
assert.equal(shared.NON_TOTP_PROMPT, runtime.NON_TOTP_PROMPT);
assert.equal(esm.NON_TOTP_PROMPT.source, shared.NON_TOTP_PROMPT.source);
assert.doesNotMatch(modalSource, /ring-primary/);
assert.doesNotMatch(modalSource, /className="w-full"/);
assert.doesNotMatch(modalSource, /onTypeTotp/);
});
assert.equal(text, "");
test("keyboardInteractiveGuidance keeps directions that are not already the field label", () => {
assert.equal(keyboardInteractiveGuidance({
name: "bastion",
hostname: "bastion",
instructions: "",
prompts: [{ prompt: "Verification code:", echo: false }],
}), "");
assert.equal(keyboardInteractiveGuidance({
name: "Duo Security",
hostname: "vpn.example",
instructions: "1. Duo Push\n2. SMS\nPasscode or option (1-3):",
prompts: [{ prompt: "Passcode or option (1-3):", echo: true }],
}), "Duo Security\n1. Duo Push\n2. SMS");
assert.equal(keyboardInteractiveGuidance({
name: "host",
hostname: "other",
instructions: "| Verification code:",
prompts: [{ prompt: "Verification code:", echo: false }],
}), "host");
assert.equal(keyboardInteractiveGuidance({
name: "bastion",
hostname: "bastion",
instructions: "",
prompts: [{ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }],
}), "1. OTP\n2. SMS");
assert.equal(keyboardInteractiveGuidance({
name: "bastion",
hostname: "bastion",
instructions: "1. OTP",
prompts: [{ prompt: "1. OTP\nOption>", echo: true }],
}), "1. OTP");
});
test("TOTP fill stays off password prompts and prefers verification prompts", () => {
assert.equal(totpFillPlacement({ prompt: "Password:", echo: false }), "hidden");
assert.equal(totpFillPlacement({ prompt: "Verification code:", echo: false }), "preferred");
assert.equal(totpFillPlacement({ prompt: "[OTP Code]:", echo: false }), "preferred");
assert.equal(totpFillPlacement({ prompt: "One-time password:", echo: false }), "preferred");
assert.equal(totpFillPlacement({ prompt: "SMS code:", echo: false }), "hidden");
assert.equal(totpFillPlacement({ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }), "hidden");
assert.equal(totpFillPlacement({ prompt: "[Y/n]", echo: true }), "hidden");
assert.equal(totpFillPlacement({ prompt: "1. OTP\nVerification code:", echo: false }), "preferred");
assert.equal(totpFillPlacement({ prompt: "Response:", echo: true }), "available");
assert.deepEqual(visibleTotpFillPlacements([
{ prompt: "Password:", echo: false },
{ prompt: "Verification code:", echo: false },
]), ["hidden", "preferred"]);
assert.deepEqual(visibleTotpFillPlacements([
{ prompt: "Password:", echo: false },
]), ["hidden"]);
});
test("keyboard-interactive modal cannot be dismissed by outside click or Escape", () => {
+115 -39
View File
@@ -5,6 +5,7 @@
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { NON_TOTP_PROMPT } from "../domain/totpPrompt.shared.mjs";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
@@ -34,35 +35,54 @@ export interface KeyboardInteractiveRequest {
savedPassword?: string | null;
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
allowSavePassword?: boolean;
/** otpauth URI from the connection attempt that opened this dialog. */
totpUri?: string;
}
type KeyboardInteractiveServerPromptInput = Pick<
KeyboardInteractiveRequest,
"name" | "instructions" | "prompts" | "hostname"
>;
/** Last meaningful line of a server prompt, without the raw banner. */
export function keyboardInteractiveFieldLabel(prompt: string | undefined, fallback: string): string {
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const last = lines.at(-1) || "";
const cleaned = last
.replace(/^[\s||>•*-]+/, "")
.replace(/[::]\s*$/, "")
.trim()
.replace(/^\[(.+)\]$/, "$1")
.trim();
return cleaned || fallback;
}
/** Formats the server-supplied keyboard-interactive prompt block for display. */
export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string {
const lines: string[] = [];
const name = request.name?.trim();
const hostname = request.hostname?.trim();
const instructions = request.instructions?.trim();
const hasServerText = !!instructions || !!(name && name !== hostname);
if (!hasServerText) return "";
if (name && name !== hostname) {
lines.push(name.endsWith(":") ? name : `${name}:`);
}
if (instructions) {
for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) {
lines.push(`| ${line}`);
}
}
function guidanceKeys(text: string): string[] {
const trimmed = text.trim().toLowerCase();
const label = keyboardInteractiveFieldLabel(text, "").toLowerCase();
return [trimmed, label].filter(Boolean);
}
/** Server directions that are not already the field label. Plain text, not a prompt dump. */
export function keyboardInteractiveGuidance(request: Pick<KeyboardInteractiveRequest, "name" | "instructions" | "prompts" | "hostname">): string {
const labelKeys = new Set<string>();
const promptLines: string[] = [];
for (const prompt of request.prompts || []) {
const promptText = prompt.prompt?.trim();
if (promptText) {
lines.push(`| ${promptText}`);
}
const parts = String(prompt?.prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const last = parts.at(-1) || "";
for (const key of guidanceKeys(last)) labelKeys.add(key);
promptLines.push(...parts.slice(0, -1));
}
const seen = new Set<string>();
const lines: string[] = [];
const push = (text: string) => {
const line = text.trim().replace(/^\|\s*/, "");
if (!line) return;
const keys = guidanceKeys(line);
if (keys.some((key) => labelKeys.has(key) || seen.has(key))) return;
for (const key of keys) seen.add(key);
lines.push(line);
};
const hostname = (request.hostname || "").trim().toLowerCase();
const name = (request.name || "").trim();
if (name && name.toLowerCase() !== hostname) push(name);
for (const part of String(request.instructions || "").split(/\r?\n/)) push(part);
for (const part of promptLines) push(part);
return lines.join("\n");
}
@@ -107,12 +127,43 @@ interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>;
onCancel: (requestId: string) => boolean | Promise<boolean>;
canFillTotp?: boolean;
onFillTotp?: () => Promise<string | null> | string | null;
}
const OTP_PROMPT = /otp|one[-\s]?time|totp|verification|passcode|authenticator|\bmfa\b|\b2fa\b|two[-\s]?factor|验证码|驗證碼|动态口令|動態口令|二次认证|二次認證|二次验证|二次驗證/i;
export type TotpFillPlacement = "preferred" | "available" | "hidden";
function lastPromptLine(prompt: string | undefined): string {
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return lines.at(-1) || "";
}
/** Where a TOTP fill action may appear. Password prompts never receive the code. */
export function totpFillPlacement(prompt: KeyboardInteractivePrompt): TotpFillPlacement {
const rawLast = lastPromptLine(prompt.prompt);
const label = keyboardInteractiveFieldLabel(prompt.prompt, "");
if (!label || NON_TOTP_PROMPT.test(rawLast) || NON_TOTP_PROMPT.test(label)) return "hidden";
if (OTP_PROMPT.test(label)) return "preferred";
if (isAPasswordPrompt({ prompt: label, echo: prompt.echo })) return "hidden";
if (/\b(password|passwd|passphrase)\b/i.test(label)) return "hidden";
return "available";
}
export function visibleTotpFillPlacements(prompts: KeyboardInteractivePrompt[]): TotpFillPlacement[] {
const placements = prompts.map(totpFillPlacement);
const hasPreferred = placements.some((placement) => placement === "preferred");
if (!hasPreferred) return placements;
return placements.map((placement) => (placement === "preferred" ? placement : "hidden"));
}
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
request,
onSubmit,
onCancel,
canFillTotp = false,
onFillTotp,
}) => {
const { t } = useI18n();
const [responses, setResponses] = useState<string[]>([]);
@@ -185,6 +236,15 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
}
}, [request, onCancel, isSubmitting]);
const handleFillTotp = useCallback(async (index: number) => {
if (!request || isSubmitting || !onFillTotp) return;
const prompt = request.prompts[index];
if (!prompt || totpFillPlacement(prompt) === "hidden") return;
const code = await onFillTotp();
if (!code) return;
handleResponseChange(index, code);
}, [request, isSubmitting, onFillTotp, handleResponseChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting) {
@@ -197,12 +257,14 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
if (!request) return null;
const serverPromptText = formatKeyboardInteractiveServerPrompt(request);
const title = t("keyboard.interactive.title");
const description =
request.hostname
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
: t("keyboard.interactive.desc");
const fillPlacements = canFillTotp ? visibleTotpFillPlacements(request.prompts) : [];
const preferredFillIndex = fillPlacements.findIndex((placement) => placement === "preferred");
const guidance = keyboardInteractiveGuidance(request);
return (
<Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}>
@@ -227,26 +289,40 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</DialogHeader>
<div className="space-y-4 py-2">
{serverPromptText && (
<pre className="max-h-36 overflow-auto rounded-md border border-border/60 bg-muted/45 px-3 py-2 text-xs leading-relaxed text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]">
{serverPromptText}
</pre>
)}
{guidance ? (
<p className="max-h-36 overflow-auto whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground [overflow-wrap:anywhere]">
{guidance}
</p>
) : null}
{request.prompts.map((prompt, index) => {
const isPassword = !prompt.echo;
const showPassword = showPasswords[index];
const showPromptLabel = !(serverPromptText && request.prompts.length === 1);
// Clean up prompt text (remove trailing colon and whitespace)
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
const promptLabel = keyboardInteractiveFieldLabel(
prompt.prompt,
t("keyboard.interactive.verificationCode"),
);
const fillPlacement = fillPlacements[index] ?? "hidden";
return (
<div key={index} className="space-y-2">
{showPromptLabel && (
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`ki-prompt-${index}`}>
{promptLabel || t("keyboard.interactive.response")}
{promptLabel}
</Label>
)}
{fillPlacement !== "hidden" && (
<Button
type="button"
variant="secondary"
size="sm"
className="h-7 shrink-0 px-2 font-normal focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
data-totp-fill={fillPlacement}
onClick={() => { void handleFillTotp(index); }}
disabled={isSubmitting}
>
{t("keyboard.interactive.fillTotp")}
</Button>
)}
</div>
<div className="relative">
<Input
id={`ki-prompt-${index}`}
@@ -256,7 +332,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
onKeyDown={handleKeyDown}
placeholder=""
className={isPassword ? "pr-10" : undefined}
autoFocus={index === 0}
autoFocus={preferredFillIndex >= 0 ? index === preferredFillIndex : index === 0}
disabled={isSubmitting}
/>
{isPassword && (
@@ -288,7 +364,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</div>
);
})}
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Button
+2
View File
@@ -4,6 +4,7 @@ import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig"
import { sanitizeCredentialValue } from "../domain/credentials";
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
import { connectionTotpFields } from "../domain/totpConfig";
import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
@@ -258,6 +259,7 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
enableKeyboardInteractive: true,
requiresMfa: !!effectiveExportHost.requiresMfa,
...connectionTotpFields(effectiveExportHost),
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
});
@@ -37,6 +37,7 @@ import {
sanitizeCredentialValue,
} from "../../../domain/credentials";
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { connectionTotpFields } from "../../../domain/totpConfig";
import { requiresFidoSshAgentAuth } from "../../../domain/fidoSsh";
import {
hostRestrictsExtraSshChannels,
@@ -478,6 +479,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
authMethod: jumpAuth.authMethod,
requiresMfa: !!jumpHost.requiresMfa,
password: jumpPassword,
...connectionTotpFields(jumpHost),
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
}),
@@ -705,6 +707,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
requiresMfa: !!ctx.host.requiresMfa,
port: ctx.host.port || 22,
password: attempt.password,
...connectionTotpFields(ctx.host),
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
? undefined
: sftpReuseOptions,
+23
View File
@@ -109,6 +109,29 @@ test("stripSyncPayloadEncryptedCredentials clears device-bound placeholders for
assert.equal(findSyncPayloadEncryptedCredentialPaths(stripped).length, 0);
});
test("TOTP secrets follow the same encrypted sync recovery path as passwords", () => {
const withTotp = samplePayload({
hosts: [{
...samplePayload().hosts[0]!,
totp: { uri: ENC, autoSubmit: true },
}],
});
assert.deepEqual(findSyncPayloadEncryptedCredentialPaths(withTotp), [
"hosts[0].password",
"hosts[0].totp.uri",
"keys[0].privateKey",
]);
const stripped = stripSyncPayloadEncryptedCredentials(withTotp);
assert.equal(stripped.hosts[0]?.totp, undefined);
const healed = healPoisonedSecretsForMerge(withTotp, samplePayload({
hosts: [{
...samplePayload().hosts[0]!,
totp: { uri: "otpauth://totp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" },
}],
}), null);
assert.match(healed.hosts[0]?.totp?.uri || "", /^otpauth:\/\/totp\//);
});
test("healPoisonedSecretsForMerge keeps usable preferred passwords over poisoned enc:v1", () => {
const poisoned = samplePayload();
const preferred = samplePayload({
+15
View File
@@ -151,6 +151,9 @@ export const findSyncPayloadEncryptedCredentialPaths = (
if (isEncryptedCredentialPlaceholder(host.proxyConfig?.password)) {
issues.push(`hosts[${index}].proxyConfig.password`);
}
if (isEncryptedCredentialPlaceholder(host.totp?.uri)) {
issues.push(`hosts[${index}].totp.uri`);
}
});
payload.keys.forEach((key, index) => {
@@ -208,6 +211,9 @@ export const stripSyncPayloadEncryptedCredentials = (
const { password: _removed, ...proxyRest } = next.proxyConfig;
next.proxyConfig = proxyRest;
}
if (next.totp && isEncryptedCredentialPlaceholder(next.totp.uri)) {
delete next.totp;
}
return next;
});
@@ -330,6 +336,15 @@ export const healPoisonedSecretsForMerge = (
next.proxyConfig = proxyRest;
}
}
if (next.totp && isEncryptedCredentialPlaceholder(next.totp.uri)) {
const healed = healPoisonedCredential(
preferredHost,
preferredHost?.totp?.uri,
fallbackHost?.totp?.uri,
);
if (healed !== undefined) next.totp = { ...next.totp, uri: healed };
else delete next.totp;
}
return next;
});
+3
View File
@@ -8,6 +8,7 @@ import {
resolveProxyConfigAuth,
} from "./proxyProfiles";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "./credentials";
import { connectionTotpFields } from "./totpConfig";
import { resolveEffectiveTerminalHost, resolveTerminalChainHosts } from "./terminalHostResolution";
/** Test-mode connection timeouts — a probe should fail fast, not wait out the
@@ -158,6 +159,7 @@ const buildTestJumpHost = (
authMethod: jumpAuth.authMethod,
requiresMfa: !!chainHost.requiresMfa,
password: sanitizeCredentialValue(jumpAuth.password),
...connectionTotpFields(chainHost),
privateKey: jumpKey?.source === "reference" ? undefined : sanitizeCredentialValue(jumpKey?.privateKey),
certificate: jumpKey?.certificate,
passphrase: sanitizeCredentialValue(jumpAuth.passphrase || jumpKey?.passphrase),
@@ -308,6 +310,7 @@ export const buildHostConnectionTestPlan = (
requiresMfa: !!effectiveHost.requiresMfa,
port: effectiveHost.port || 22,
password: effectivePassword,
...connectionTotpFields(effectiveHost),
privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey),
certificate: key?.certificate,
publicKey: key?.publicKey,
+12
View File
@@ -157,6 +157,15 @@ export interface SftpBookmark {
export type HostAuthMethod = 'auto' | 'password' | 'key' | 'certificate';
/** Per-host TOTP authenticator. `uri` is an otpauth://totp URI and holds the secret. */
export interface HostTotpConfig {
uri: string;
/** Defaults to true. False keeps the dialog fill button and skips auto-submit. */
autoSubmit?: boolean;
/** Extra RE2 regular expression matched against the KI prompt. */
promptPattern?: string;
}
export type HostOperatingSystem = 'linux' | 'windows' | 'macos' | 'freebsd' | 'unknown';
export type HostOsSelection = 'auto' | HostOperatingSystem;
@@ -194,6 +203,9 @@ export interface Host {
authPolicyVersion?: 1;
// Prefer keyboard-interactive before the password method for MFA/PAM hosts.
requiresMfa?: boolean;
// Optional per-host TOTP authenticator. The secret lives in `uri` and is
// encrypted and synced with the same path as `password`.
totp?: HostTotpConfig;
// Use the local SSH agent for login. This is separate from agentForwarding,
// which exposes the local agent to the remote host after login.
useSshAgent?: boolean;
+26
View File
@@ -6,7 +6,9 @@ import {
isSafePluginAuthenticationUrl,
pluginProtocolForProvider,
sanitizePluginConnection,
stripBuiltInConnectionFieldsForPluginHost,
} from './pluginConnection.ts';
import type { Host } from './models';
test('plugin connection profiles preserve opaque configuration when their provider is absent', () => {
const providerId = 'com.example.transport.connection';
@@ -59,6 +61,30 @@ test('plugin credentials are selectable only after secure catalog publication',
);
});
test('plugin hosts drop a built-in TOTP secret the plugin UI cannot manage', () => {
const totp = { uri: 'otpauth://totp/Example:root?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' };
const base = {
id: 'host-1',
label: 'Host',
hostname: 'host.test',
username: 'root',
tags: [],
os: 'linux',
password: 'secret',
requiresMfa: true,
totp,
} as Host;
const pluginHost = stripBuiltInConnectionFieldsForPluginHost({
...base,
protocol: pluginProtocolForProvider('com.example.transport.connection'),
});
assert.equal(pluginHost.totp, undefined);
assert.equal(pluginHost.password, undefined);
assert.equal(pluginHost.requiresMfa, undefined);
assert.equal(pluginHost.hostname, 'host.test');
assert.equal(stripBuiltInConnectionFieldsForPluginHost({ ...base, protocol: 'ssh' }).totp, totp);
});
test('plugin authentication URLs require HTTPS except for loopback HTTP callbacks', () => {
assert.equal(isSafePluginAuthenticationUrl('https://login.example.com/authorize'), true);
assert.equal(isSafePluginAuthenticationUrl('http://localhost:44123/callback'), true);
+1
View File
@@ -125,6 +125,7 @@ export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
identityFileId: _identityFileId,
identityFilePaths: _identityFilePaths,
password: _password,
totp: _totp,
savePassword: _savePassword,
authMethod: _authMethod,
authPolicyVersion: _authPolicyVersion,
+290
View File
@@ -0,0 +1,290 @@
"use strict";
/* global Buffer, URL, URLSearchParams */
const crypto = require("node:crypto");
const { NON_TOTP_PROMPT } = require("./totpPrompt.shared.cjs");
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const ALGORITHMS = new Set(["SHA1", "SHA256", "SHA512"]);
const HMAC_NAMES = { SHA1: "sha1", SHA256: "sha256", SHA512: "sha512" };
const BUILTIN_TOTP_PROMPTS = [
/^\[OTP Code\]:\s*$/,
/^\[MFA auth\]:\s*$/,
/^Verification code:\s*$/,
];
// SMS, email, RADIUS, menus, and confirmations must never be auto-answered,
// even when a host regex would otherwise match. The pattern lives in
// totpPrompt.shared.cjs so the auth dialog can use the same list.
const MAX_PROMPT_PATTERN_LENGTH = 200;
const MAX_PROMPT_SUBJECT_LENGTH = 1024;
let compileBoundedPattern = null;
function boundedPatternMatches(pattern, text) {
if (!compileBoundedPattern) {
const { RE2JS } = require("re2js");
compileBoundedPattern = (source) => RE2JS.compile(source);
}
return compileBoundedPattern(pattern).matcher(text).find();
}
const MIN_SECRET_BYTES = 10;
const MAX_SECRET_BYTES = 128;
function decodeBase32(input) {
const cleaned = String(input || "").replace(/=+$/g, "").replace(/\s+/g, "").toUpperCase();
if (!cleaned || /[^A-Z2-7]/.test(cleaned)) return null;
let bits = 0;
let value = 0;
const out = [];
for (const char of cleaned) {
value = (value << 5) | BASE32_ALPHABET.indexOf(char);
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((value >>> bits) & 0xff);
}
}
return Buffer.from(out);
}
function encodeBase32(buffer) {
let bits = 0;
let value = 0;
let output = "";
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
bits -= 5;
output += BASE32_ALPHABET[(value >>> bits) & 31];
}
}
if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
return output;
}
function generateTotp({ secret, algorithm = "SHA1", digits = 6, period = 30, timestamp = Date.now() }) {
const algo = HMAC_NAMES[String(algorithm || "SHA1").toUpperCase()];
if (!algo) throw new Error("invalid-algorithm");
if (!Number.isInteger(digits) || digits < 6 || digits > 8) throw new Error("invalid-digits");
if (!Number.isInteger(period) || period < 1) throw new Error("invalid-period");
if (!Buffer.isBuffer(secret) || secret.length === 0) throw new Error("invalid-secret");
const counter = BigInt(Math.floor(Number(timestamp) / 1000 / period));
const message = Buffer.alloc(8);
message.writeBigUInt64BE(counter);
const hash = crypto.createHmac(algo, secret).update(message).digest();
const offset = hash[hash.length - 1] & 0x0f;
const binary = ((hash[offset] & 0x7f) << 24)
| ((hash[offset + 1] & 0xff) << 16)
| ((hash[offset + 2] & 0xff) << 8)
| (hash[offset + 3] & 0xff);
return String(binary % (10 ** digits)).padStart(digits, "0");
}
function buildOtpauthUri({ secret, algorithm, digits, period, issuer, label }) {
const account = label || issuer || "TOTP";
const pathLabel = issuer && label && !label.startsWith(`${issuer}:`)
? `${issuer}:${label}`
: account;
const params = new URLSearchParams();
params.set("secret", encodeBase32(secret));
params.set("algorithm", algorithm);
params.set("digits", String(digits));
params.set("period", String(period));
if (issuer) params.set("issuer", issuer);
return `otpauth://totp/${encodeURIComponent(pathLabel)}?${params.toString()}`;
}
function parseRawSecret(text) {
const secret = decodeBase32(text);
if (!secret || secret.length < MIN_SECRET_BYTES || secret.length > MAX_SECRET_BYTES) {
return { ok: false, error: "invalid-secret" };
}
return finalizeParsed({
secret,
algorithm: "SHA1",
digits: 6,
period: 30,
issuer: "",
label: "",
});
}
function finalizeParsed(fields) {
return {
ok: true,
...fields,
uri: buildOtpauthUri(fields),
};
}
function parseOtpauthUri(text) {
let url;
try {
url = new URL(text);
} catch {
return { ok: false, error: "invalid-uri" };
}
const type = (url.hostname || "").toLowerCase();
if (!type) return { ok: false, error: "invalid-uri" };
if (type === "hotp") return { ok: false, error: "hotp" };
if (type !== "totp") return { ok: false, error: "unsupported-type" };
const secretParam = url.searchParams.get("secret");
if (!secretParam || !secretParam.trim()) return { ok: false, error: "missing-secret" };
const secret = decodeBase32(secretParam);
if (!secret || secret.length < MIN_SECRET_BYTES || secret.length > MAX_SECRET_BYTES) {
return { ok: false, error: "invalid-secret" };
}
const algorithm = String(url.searchParams.get("algorithm") || "SHA1").replace(/-/g, "").toUpperCase();
if (!ALGORITHMS.has(algorithm)) return { ok: false, error: "invalid-algorithm" };
const digitsText = url.searchParams.get("digits");
const digits = digitsText == null || digitsText.trim() === "" ? 6 : Number(digitsText);
if (!Number.isInteger(digits) || digits < 6 || digits > 8) return { ok: false, error: "invalid-digits" };
const periodText = url.searchParams.get("period");
const period = periodText == null || periodText.trim() === "" ? 30 : Number(periodText);
if (!Number.isInteger(period) || period < 1 || period > 300) return { ok: false, error: "invalid-period" };
const rawLabel = decodeURIComponent(url.pathname.replace(/^\//, "")).trim();
let issuer = (url.searchParams.get("issuer") || "").trim();
let label = rawLabel;
if (!issuer && rawLabel.includes(":")) {
const splitAt = rawLabel.indexOf(":");
issuer = rawLabel.slice(0, splitAt).trim();
label = rawLabel.slice(splitAt + 1).trim();
} else if (issuer && rawLabel.toLowerCase().startsWith(`${issuer.toLowerCase()}:`)) {
label = rawLabel.slice(issuer.length + 1).trim();
}
return finalizeParsed({ secret, algorithm, digits, period, issuer, label });
}
function parseTotpInput(input) {
const text = String(input ?? "").trim();
if (!text) return { ok: false, error: "empty" };
if (/^otpauth-migration:/i.test(text)) return { ok: false, error: "migration" };
if (/^otpauth:/i.test(text)) return parseOtpauthUri(text);
return parseRawSecret(text);
}
function currentTotp(parsed, timestamp = Date.now()) {
const periodMs = parsed.period * 1000;
const into = Number(timestamp) % periodMs;
const remainingMs = into === 0 ? periodMs : periodMs - into;
return {
code: generateTotp({ ...parsed, timestamp }),
secondsRemaining: Math.ceil(remainingMs / 1000),
period: parsed.period,
};
}
function planTotpSubmission(parsed, timestamp = Date.now()) {
const periodMs = parsed.period * 1000;
const into = Number(timestamp) % periodMs;
const remainingMs = into === 0 ? periodMs : periodMs - into;
if (remainingMs <= 2000) {
return {
code: generateTotp({ ...parsed, timestamp: Number(timestamp) + remainingMs }),
delayMs: remainingMs,
};
}
return {
code: generateTotp({ ...parsed, timestamp }),
delayMs: 0,
};
}
function previewTotpText(input, timestamp = Date.now()) {
const parsed = parseTotpInput(input);
if (!parsed.ok) return { ok: false, error: parsed.error };
const current = currentTotp(parsed, timestamp);
return {
ok: true,
uri: parsed.uri,
code: current.code,
secondsRemaining: current.secondsRemaining,
period: parsed.period,
algorithm: parsed.algorithm,
digits: parsed.digits,
issuer: parsed.issuer,
label: parsed.label,
};
}
function isExcludedTotpPrompt(prompts, name, instructions) {
const promptText = typeof prompts?.[0]?.prompt === "string" ? prompts[0].prompt : "";
const context = [name, instructions, promptText]
.filter((part) => typeof part === "string" && part.trim())
.join("\n");
return NON_TOTP_PROMPT.test(promptText) || NON_TOTP_PROMPT.test(context);
}
function isAutoSubmittableTotpPrompt({ prompts, name, instructions, promptPattern } = {}) {
if (!Array.isArray(prompts) || prompts.length !== 1) return false;
if (isExcludedTotpPrompt(prompts, name, instructions)) return false;
const promptText = typeof prompts[0]?.prompt === "string" ? prompts[0].prompt : "";
const pattern = typeof promptPattern === "string" ? promptPattern.trim() : "";
if (
pattern
&& pattern.length <= MAX_PROMPT_PATTERN_LENGTH
&& promptText.length <= MAX_PROMPT_SUBJECT_LENGTH
) {
try {
if (boundedPatternMatches(pattern, promptText)) return true;
} catch {
// An invalid or non-RE2 pattern still leaves the built-in prompts available.
}
}
return BUILTIN_TOTP_PROMPTS.some((pattern) => pattern.test(promptText));
}
function planTotpAutoSubmit({ uri, prompts, name, instructions, promptPattern, autoSubmit, timestamp }) {
if (autoSubmit === false) return { action: "skip" };
if (typeof uri !== "string" || !uri || uri.startsWith("enc:v1:")) return { action: "skip" };
if (!isAutoSubmittableTotpPrompt({ prompts, name, instructions, promptPattern })) {
return { action: "skip" };
}
const parsed = parseTotpInput(uri);
if (!parsed.ok) return { action: "fail" };
return { action: "submit", ...planTotpSubmission(parsed, timestamp) };
}
function selectTotpFromQrPayloads(payloads) {
let sawHotp = false;
let sawMigration = false;
for (const payload of payloads) {
const parsed = parseTotpInput(payload);
if (parsed.ok) return { ok: true, uri: parsed.uri, parsed };
if (parsed.error === "hotp") sawHotp = true;
else if (parsed.error === "migration") sawMigration = true;
}
if (sawHotp) return { ok: false, error: "hotp" };
if (sawMigration) return { ok: false, error: "migration" };
return { ok: false, error: "not-found" };
}
function bgraToRgba(bitmap, width, height) {
const rgba = new Uint8ClampedArray(width * height * 4);
const source = bitmap instanceof Uint8Array ? bitmap : new Uint8Array(bitmap);
for (let index = 0; index < width * height; index += 1) {
const offset = index * 4;
rgba[offset] = source[offset + 2];
rgba[offset + 1] = source[offset + 1];
rgba[offset + 2] = source[offset];
rgba[offset + 3] = source[offset + 3];
}
return rgba;
}
module.exports = {
NON_TOTP_PROMPT,
decodeBase32,
encodeBase32,
generateTotp,
parseTotpInput,
currentTotp,
planTotpSubmission,
previewTotpText,
isAutoSubmittableTotpPrompt,
planTotpAutoSubmit,
selectTotpFromQrPayloads,
bgraToRgba,
};
+231
View File
@@ -0,0 +1,231 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const {
generateTotp,
parseTotpInput,
isAutoSubmittableTotpPrompt,
planTotpSubmission,
selectTotpFromQrPayloads,
encodeBase32,
} = require("./totp.shared.cjs") as {
generateTotp: (input: {
secret: Buffer;
algorithm: string;
digits: number;
period: number;
timestamp: number;
}) => string;
parseTotpInput: (input: string) => {
ok: boolean;
error?: string;
uri?: string;
algorithm?: string;
digits?: number;
period?: number;
issuer?: string;
label?: string;
secret?: Buffer;
};
isAutoSubmittableTotpPrompt: (input: {
prompts?: Array<{ prompt?: string }>;
name?: string;
instructions?: string;
promptPattern?: string;
}) => boolean;
planTotpSubmission: (parsed: { secret: Buffer; algorithm: string; digits: number; period: number }, timestamp: number) => {
code: string;
delayMs: number;
};
selectTotpFromQrPayloads: (payloads: string[]) => { ok: boolean; error?: string; uri?: string };
encodeBase32: (buffer: Buffer) => string;
};
const SHA1_SECRET = Buffer.from("12345678901234567890", "ascii");
const SHA256_SECRET = Buffer.from("12345678901234567890123456789012", "ascii");
const SHA512_SECRET = Buffer.from(
"1234567890123456789012345678901234567890123456789012345678901234",
"ascii",
);
const RFC_VECTORS: Array<[number, string, string, string]> = [
[59, "94287082", "46119246", "90693936"],
[1111111109, "07081804", "68084774", "25091201"],
[1111111111, "14050471", "67062674", "99943326"],
[1234567890, "89005924", "91819424", "93441116"],
[2000000000, "69279037", "90698825", "38618901"],
[20000000000, "65353130", "77737706", "47863826"],
];
for (const [time, sha1, sha256, sha512] of RFC_VECTORS) {
test(`RFC 6238 TOTP at ${time}`, () => {
const timestamp = time * 1000;
assert.equal(generateTotp({ secret: SHA1_SECRET, algorithm: "SHA1", digits: 8, period: 30, timestamp }), sha1);
assert.equal(generateTotp({ secret: SHA256_SECRET, algorithm: "SHA256", digits: 8, period: 30, timestamp }), sha256);
assert.equal(generateTotp({ secret: SHA512_SECRET, algorithm: "SHA512", digits: 8, period: 30, timestamp }), sha512);
});
}
test("parseTotpInput keeps algorithm, digits, period, issuer, and label", () => {
const parsed = parseTotpInput(
"otpauth://totp/Example:alice%40example.com?secret=gezdgnbvg y3tqojqgezdgnbvgy3tqojq&issuer=Example&algorithm=SHA256&digits=7&period=45",
);
assert.equal(parsed.ok, true);
assert.equal(parsed.algorithm, "SHA256");
assert.equal(parsed.digits, 7);
assert.equal(parsed.period, 45);
assert.equal(parsed.issuer, "Example");
assert.equal(parsed.label, "alice@example.com");
assert.equal(parsed.secret?.equals(SHA1_SECRET), true);
assert.match(parsed.uri || "", /^otpauth:\/\/totp\//);
assert.match(parsed.uri || "", /algorithm=SHA256/);
assert.match(parsed.uri || "", /digits=7/);
assert.match(parsed.uri || "", /period=45/);
assert.match(parsed.uri || "", /issuer=Example/);
});
test("parseTotpInput accepts a raw Base32 secret with SHA1/6/30 defaults", () => {
const parsed = parseTotpInput(` ${encodeBase32(SHA1_SECRET).toLowerCase()} `);
assert.equal(parsed.ok, true);
assert.equal(parsed.algorithm, "SHA1");
assert.equal(parsed.digits, 6);
assert.equal(parsed.period, 30);
assert.equal(parsed.secret?.equals(SHA1_SECRET), true);
});
test("parseTotpInput accepts SHA-1 spelling and uppercase type", () => {
const parsed = parseTotpInput(
`OTPAUTH://TOTP/JumpServer?secret=${encodeBase32(SHA1_SECRET)}&algorithm=SHA-1`,
);
assert.equal(parsed.ok, true);
assert.equal(parsed.algorithm, "SHA1");
assert.equal(parsed.issuer, "");
assert.equal(parsed.label, "JumpServer");
});
test("parseTotpInput splits issuer out of the label when the query omits it", () => {
const parsed = parseTotpInput(
`otpauth://totp/JumpServer:root?secret=${encodeBase32(SHA1_SECRET)}`,
);
assert.equal(parsed.ok, true);
assert.equal(parsed.issuer, "JumpServer");
assert.equal(parsed.label, "root");
});
test("parseTotpInput rejects HOTP, migration QR codes, and bad parameters", () => {
assert.equal(parseTotpInput("otpauth://hotp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=1").error, "hotp");
assert.equal(parseTotpInput("otpauth-migration://offline?data=abc").error, "migration");
assert.equal(parseTotpInput("").error, "empty");
assert.equal(parseTotpInput("otpauth://totp/Example:alice").error, "missing-secret");
assert.equal(parseTotpInput("otpauth://push/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ").error, "unsupported-type");
assert.equal(parseTotpInput("not a secret").error, "invalid-secret");
assert.equal(parseTotpInput("AAAA").error, "invalid-secret");
assert.equal(parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&algorithm=SHA384`).error, "invalid-algorithm");
assert.equal(parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&digits=5`).error, "invalid-digits");
assert.equal(parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&digits=9`).error, "invalid-digits");
assert.equal(parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&period=0`).error, "invalid-period");
assert.equal(parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&period=301`).error, "invalid-period");
assert.equal(parseTotpInput("otpauth:").error, "invalid-uri");
});
test("auto-submit waits out the last two seconds of the current window", () => {
const parsed = parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&digits=8&period=30`);
assert.equal(parsed.ok, true);
const soon = planTotpSubmission(parsed as { secret: Buffer; algorithm: string; digits: number; period: number }, 59_000);
assert.equal(soon.delayMs, 1000);
assert.equal(soon.code, generateTotp({
secret: SHA1_SECRET,
algorithm: "SHA1",
digits: 8,
period: 30,
timestamp: 60_000,
}));
const early = planTotpSubmission(parsed as { secret: Buffer; algorithm: string; digits: number; period: number }, 15_000);
assert.equal(early.delayMs, 0);
});
const prompt = (text: string) => ({ prompts: [{ prompt: text }] });
test("prompt matcher accepts JumpServer and PAM TOTP prompts", () => {
assert.equal(isAutoSubmittableTotpPrompt({
...prompt("[OTP Code]: "),
instructions: "Please Enter MFA Code.",
}), true);
assert.equal(isAutoSubmittableTotpPrompt(prompt("[OTP Code]:")), true);
assert.equal(isAutoSubmittableTotpPrompt({
...prompt("[MFA auth]: "),
instructions: "Please enter 6 digits.",
}), true);
assert.equal(isAutoSubmittableTotpPrompt(prompt("Verification code: ")), true);
assert.equal(isAutoSubmittableTotpPrompt(prompt("Verification code:")), true);
});
test("prompt matcher rejects SMS, email, option menus, and confirmations", () => {
assert.equal(isAutoSubmittableTotpPrompt(prompt("[SMS Code]: ")), false);
assert.equal(isAutoSubmittableTotpPrompt(prompt("[EMAIL Code]: ")), false);
assert.equal(isAutoSubmittableTotpPrompt(prompt("[OTP_RADIUS Code]: ")), false);
assert.equal(isAutoSubmittableTotpPrompt({
prompts: [{ prompt: "\n1. OTP\n2. SMS\nOption> " }],
instructions: "Please Select MFA Option",
}), false);
assert.equal(isAutoSubmittableTotpPrompt(prompt("Do you want to continue [Y/n]? : ")), false);
assert.equal(isAutoSubmittableTotpPrompt(prompt("Password:")), false);
assert.equal(isAutoSubmittableTotpPrompt({
prompts: [{ prompt: "Password:" }, { prompt: "[OTP Code]: " }],
}), false);
});
test("a host regex can add a prompt without overriding the exclusion list", () => {
assert.equal(isAutoSubmittableTotpPrompt({
...prompt("My OTP: "),
promptPattern: "^My OTP:\\s*$",
}), true);
assert.equal(isAutoSubmittableTotpPrompt({
...prompt("[OTP Code]: "),
promptPattern: "^My OTP:\\s*$",
}), true);
assert.equal(isAutoSubmittableTotpPrompt({
...prompt("[SMS Code]: "),
promptPattern: ".*",
}), false);
assert.equal(isAutoSubmittableTotpPrompt({
...prompt("Option> "),
promptPattern: ".*",
}), false);
assert.equal(isAutoSubmittableTotpPrompt({
...prompt("[OTP Code]: "),
promptPattern: "(",
}), true);
});
test("custom prompt patterns stay linear and ignore syntax RE2 rejects", () => {
const started = Date.now();
assert.equal(isAutoSubmittableTotpPrompt({
prompts: [{ prompt: `${"a".repeat(32)}!` }],
promptPattern: "(a+)+$",
}), false);
assert.ok(Date.now() - started < 1000);
assert.equal(isAutoSubmittableTotpPrompt({
prompts: [{ prompt: "x".repeat(2048) }],
promptPattern: "x+",
}), false);
assert.equal(isAutoSubmittableTotpPrompt({
...prompt("My OTP: "),
promptPattern: "^(a)\\1$",
}), false);
});
test("QR payload selection keeps the first totp URI and rejects HOTP or migration", () => {
const totpUri = `otpauth://totp/Example:alice?secret=${encodeBase32(SHA1_SECRET)}`;
const selected = selectTotpFromQrPayloads([
"otpauth://hotp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=1",
totpUri,
]);
assert.equal(selected.ok, true);
assert.match(selected.uri || "", /^otpauth:\/\/totp\//);
assert.equal(selectTotpFromQrPayloads(["otpauth://hotp/A?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=1"]).error, "hotp");
assert.equal(selectTotpFromQrPayloads(["otpauth-migration://offline?data=abc"]).error, "migration");
assert.equal(selectTotpFromQrPayloads(["hello"]).error, "not-found");
});
+24
View File
@@ -0,0 +1,24 @@
import type { HostTotpConfig } from "./models";
import { isEncryptedCredentialPlaceholder } from "./credentials";
/** Connection payload for a host TOTP secret. Device-bound ciphertext is omitted. */
export function connectionTotpFromHost(
host: { totp?: HostTotpConfig } | null | undefined,
): HostTotpConfig | undefined {
const totp = host?.totp;
const uri = totp?.uri?.trim();
if (!uri || isEncryptedCredentialPlaceholder(uri)) return undefined;
const promptPattern = totp?.promptPattern?.trim();
return {
uri,
autoSubmit: totp?.autoSubmit !== false,
...(promptPattern ? { promptPattern } : {}),
};
}
export function connectionTotpFields(
host: { totp?: HostTotpConfig } | null | undefined,
): { totp?: HostTotpConfig } {
const totp = connectionTotpFromHost(host);
return totp ? { totp } : {};
}
+7
View File
@@ -0,0 +1,7 @@
"use strict";
// SMS, email, RADIUS, menus, and confirmations must never be treated as a
// TOTP code prompt, even when earlier lines mention OTP.
// Assigned on `exports` so the renderer can import the name through Vite.
const NON_TOTP_PROMPT = /SMS|EMAIL|RADIUS|PASSKEY|MFA_CUSTOM|短信|邮件|郵件|Option>|\[Y\/n\]/i;
exports.NON_TOTP_PROMPT = NON_TOTP_PROMPT;
+1
View File
@@ -0,0 +1 @@
export const NON_TOTP_PROMPT: RegExp;
+4
View File
@@ -0,0 +1,4 @@
// SMS, email, RADIUS, menus, and confirmations must never be treated as a
// TOTP code prompt, even when earlier lines mention OTP.
// Keep this pattern identical to domain/totpPrompt.shared.cjs.
export const NON_TOTP_PROMPT = /SMS|EMAIL|RADIUS|PASSKEY|MFA_CUSTOM|短信|邮件|郵件|Option>|\[Y\/n\]/i;
+6 -1
View File
@@ -73,6 +73,10 @@ module.exports = {
'domain/singleChannelSshBanner.shared.cjs',
'domain/singleChannelSshBanner.shared.mjs',
'domain/sshServerVendor.mjs',
// sshAuthHelper.cjs requires this at load. TypeScript under domain/
// is excluded below, so the shared TOTP runtime must be listed.
'domain/totp.shared.cjs',
'domain/totpPrompt.shared.cjs',
'lib/**/*.cjs',
'lib/**/*.json',
'skills/**/*',
@@ -112,7 +116,8 @@ module.exports = {
'!node_modules/@streamdown/**/*',
'!node_modules/@tanstack/react-virtual/**/*',
'!node_modules/pinyin-pro/**/*',
'!node_modules/re2js/**/*',
// re2js stays in the package: the main-process TOTP prompt matcher
// requires it so custom patterns cannot freeze Electron.
'!node_modules/@eslint-community/regexpp/**/*',
'!node_modules/clsx/**/*',
'!node_modules/tailwind-merge/**/*',
@@ -1142,6 +1142,7 @@ async function startPortForward(event, payload) {
authPolicyVersion,
requiresMfa,
password,
totp,
privateKey,
publicKey,
certificate,
@@ -1660,6 +1661,7 @@ async function startPortForward(event, payload) {
hostId,
hostname,
password,
totp,
logPrefix: "[PortForward]",
scope: "external",
getAuthBanner: () => authBanner,
@@ -444,6 +444,7 @@ function createOpenConnectionApi(ctx) {
hostId: jump.hostId,
hostname: hopLabel,
password: jump.password,
totp: jump.totp,
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
scope: "external",
getAuthBanner: () => authBanner,
@@ -1137,6 +1138,7 @@ function createOpenConnectionApi(ctx) {
hostId: options.hostId,
hostname: options.hostname,
password: options.password,
totp: options.totp,
logPrefix: "[SFTP]",
scope: "external",
getAuthBanner: () => authBanner,
+46
View File
@@ -12,6 +12,7 @@ const { exec } = childProcess;
const { utils: sshUtils } = require("ssh2");
const systemSshAgent = require("./systemSshAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { planTotpAutoSubmit } = require("../../domain/totp.shared.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const {
normalizePrivateKeyForSsh2,
@@ -2382,12 +2383,18 @@ function createKeyboardInteractiveHandler(options) {
onPromptShown,
onUserResponded,
getAuthBanner,
totp,
now = () => Date.now(),
schedule = (fn, ms) => setTimeout(fn, ms),
} = options;
// ssh2 may re-invoke the keyboard-interactive event on auth failure with a
// fresh challenge. If our first auto-fill attempt was wrong, falling back
// to the modal on the retry lets the user correct it — and prevents a
// tight loop where we keep submitting the same wrong password.
let autoFilledOnce = false;
// TOTP is independent of the password fast-path: a PAM password prompt and
// a later verification-code prompt share this handler. Submit at most once.
let totpAutoSubmitted = false;
return (name, instructions, instructionsLang, prompts, finish) => {
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
@@ -2446,6 +2453,41 @@ function createKeyboardInteractiveHandler(options) {
return;
}
if (!totpAutoSubmitted && totp?.uri) {
let totpPlan = { action: "skip" };
try {
totpPlan = planTotpAutoSubmit({
uri: totp.uri,
prompts,
name,
instructions,
promptPattern: totp.promptPattern,
autoSubmit: totp.autoSubmit,
timestamp: now(),
});
} catch {
console.warn(`${logPrefix} TOTP auto-submit check failed`);
totpPlan = { action: "fail" };
}
if (totpPlan.action === "fail") {
totpAutoSubmitted = true;
} else if (totpPlan.action === "submit") {
totpAutoSubmitted = true;
const submitCode = () => {
console.log(`${logPrefix} Auto-submitting TOTP for a matching keyboard-interactive prompt`);
try { onAutoFill?.("totp"); } catch (err) { console.warn(`${logPrefix} onAutoFill callback threw`, err); }
try { finish([totpPlan.code]); } catch { console.warn(`${logPrefix} TOTP finish threw`); }
};
if (totpPlan.delayMs > 0) {
const timer = schedule(submitCode, totpPlan.delayMs);
timer?.unref?.();
} else {
submitCode();
}
return;
}
}
// Forward prompts to user via IPC
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
@@ -2493,6 +2535,9 @@ function createKeyboardInteractiveHandler(options) {
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
try { onPromptShown?.(); } catch (err) { console.warn(`${logPrefix} onPromptShown callback threw`, err); }
const totpUri = typeof totp?.uri === "string" && totp.uri && !totp.uri.startsWith("enc:v1:")
? totp.uri
: "";
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId,
@@ -2504,6 +2549,7 @@ function createKeyboardInteractiveHandler(options) {
savedPassword: savedPasswordForModal,
allowSavePassword,
scope,
...(totpUri ? { totpUri } : {}),
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
});
};
@@ -0,0 +1,144 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createKeyboardInteractiveHandler } = require("./sshAuthHelper.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { encodeBase32, generateTotp } = require("../../domain/totp.shared.cjs");
const SECRET = Buffer.from("12345678901234567890", "ascii");
const TOTP = {
uri: `otpauth://totp/JumpServer:root?secret=${encodeBase32(SECRET)}&digits=8&period=30`,
autoSubmit: true,
};
const createSender = () => {
const sent = [];
return {
sent,
sender: {
id: 7,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
},
};
};
const drainPendingRequests = (sent, senderId = 7) => {
for (const event of sent) {
if (event.channel !== "netcatty:keyboard-interactive") continue;
const requestId = event.payload?.requestId;
if (requestId) {
keyboardInteractiveHandler.handleResponse({ sender: { id: senderId } }, { requestId, cancelled: true });
}
}
};
const invoke = (handler, prompt, instructions = "") => {
let finished = null;
handler("root", instructions, "", [{ prompt, echo: true }], (responses) => {
finished = responses;
});
return finished;
};
test("TOTP auto-submit runs once per connection attempt, then falls back to the dialog", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "jumpserver.example",
totp: TOTP,
now: () => 15_000,
});
const expected = generateTotp({
secret: SECRET,
algorithm: "SHA1",
digits: 8,
period: 30,
timestamp: 15_000,
});
assert.deepEqual(invoke(handler, "[OTP Code]: ", "Please Enter MFA Code."), [expected]);
assert.equal(sent.length, 0);
const retry = invoke(handler, "[OTP Code]: ", "Please Enter MFA Code.");
assert.equal(retry, null);
assert.equal(sent.length, 1);
assert.equal(sent[0].channel, "netcatty:keyboard-interactive");
assert.equal(sent[0].payload.totpUri, TOTP.uri);
drainPendingRequests(sent);
});
test("keyboard-interactive dialog omits an encrypted TOTP placeholder", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-enc",
hostname: "jumpserver.example",
totp: { uri: "enc:v1:placeholder" },
now: () => 15_000,
});
assert.equal(invoke(handler, "[OTP Code]: "), null);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.totpUri, undefined);
drainPendingRequests(sent);
});
test("a non-matching prompt does not consume the one TOTP auto-submit", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-2",
hostname: "jumpserver.example",
totp: TOTP,
now: () => 15_000,
});
assert.equal(invoke(handler, "\n1. OTP\n2. SMS\nOption> ", "Please Select MFA Option"), null);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.totpUri, TOTP.uri);
drainPendingRequests(sent);
const code = invoke(handler, "[OTP Code]: ", "Please Enter MFA Code.");
assert.equal(code?.[0]?.length, 8);
assert.equal(sent.length, 1);
});
test("SMS, email, and confirmation prompts are never auto-answered", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-3",
hostname: "jumpserver.example",
totp: TOTP,
now: () => 15_000,
});
for (const prompt of ["[SMS Code]: ", "[EMAIL Code]: ", "Do you want to continue [Y/n]? : "]) {
assert.equal(invoke(handler, prompt), null);
}
assert.equal(sent.length, 3);
drainPendingRequests(sent);
});
test("legacy JumpServer and PAM prompts auto-submit, and a later mismatch opens the dialog", () => {
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-4",
hostname: "pam.example",
totp: TOTP,
now: () => 15_000,
});
assert.equal(invoke(handler, "[MFA auth]: ")?.[0]?.length, 8);
const fresh = createKeyboardInteractiveHandler({
sender,
sessionId: "session-5",
hostname: "pam.example",
totp: { ...TOTP },
now: () => 15_000,
});
assert.equal(invoke(fresh, "Verification code: ")?.[0]?.length, 8);
assert.equal(invoke(fresh, "Password:"), null);
assert.equal(sent.length, 1);
drainPendingRequests(sent);
});
+4 -2
View File
@@ -896,14 +896,16 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
hostId: jump.hostId,
hostname: hopLabel,
password: jump.password,
totp: jump.totp,
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
scope: keyboardInteractiveScope,
allowSavePassword: options._monitorMode !== true,
bootEpoch: options.bootEpoch,
getAuthBanner: () => authBanner,
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(hopAuthPhase),
onAutoFill: () => sendProgress(
i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'using saved password',
onAutoFill: (kind) => sendProgress(
i + 1, totalHops + 1, hopLabel, 'auth-attempt',
kind === 'totp' ? 'using authenticator code' : 'using saved password',
),
onPromptShown: () => sendProgress(
i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'waiting for user input...',
@@ -201,6 +201,7 @@ function createExecCommandApi(ctx) {
hostId: payload.hostId,
hostname: payload.hostname,
password: payload.password,
totp: payload.totp,
logPrefix: "[SSH Exec]",
scope: "external",
getAuthBanner: () => authBanner,
+12 -7
View File
@@ -2817,6 +2817,7 @@ function createStartSessionApi(ctx) {
hostId: options.hostId,
hostname: options.hostname,
password: options.password,
totp: options.totp,
logPrefix,
// A test has no terminal session registered in the renderer's
// session list, so the terminal-scoped keyboard-interactive queue
@@ -2827,8 +2828,9 @@ function createStartSessionApi(ctx) {
bootEpoch: options.bootEpoch,
getAuthBanner: () => authBanner,
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
onAutoFill: () => sendProgress(
totalHops, totalHops, options.hostname, 'auth-attempt', 'using saved password',
onAutoFill: (kind) => sendProgress(
totalHops, totalHops, options.hostname, 'auth-attempt',
kind === 'totp' ? 'using authenticator code' : 'using saved password',
),
onPromptShown: () => sendProgress(
totalHops, totalHops, options.hostname, 'auth-attempt', 'waiting for user input...',
@@ -2881,10 +2883,13 @@ function createStartSessionApi(ctx) {
// Password credentials still allow keyboard-interactive after
// "password" so PAM/2FA hosts (which reject the raw "password"
// method) are covered without re-prompting the user. Hosts marked
// `requiresMfa` always get keyboard-interactive, including key /
// certificate-only credentials where the server accepts the key as
// the first factor and then asks for an OTP (matching the terminal
// auth handler, which always appends keyboard-interactive).
// `requiresMfa` and a configured TOTP secret always get
// keyboard-interactive, including key / certificate-only credentials
// where the server accepts the key as the first factor and then
// asks for an OTP (matching the terminal auth handler, which always
// appends keyboard-interactive).
const totpUri = typeof options.totp?.uri === "string" ? options.totp.uri : "";
const hasTotp = totpUri.length > 0 && !totpUri.startsWith("enc:v1:");
const order = [];
if (connectOpts.privateKey) {
order.push("publickey");
@@ -2894,7 +2899,7 @@ function createStartSessionApi(ctx) {
if (connectOpts.password && !options.requiresMfa) {
order.push("password");
}
if (connectOpts.password || options.requiresMfa || order.length === 0) {
if (connectOpts.password || options.requiresMfa || hasTotp || order.length === 0) {
order.push("keyboard-interactive");
}
connectOpts.authHandler = order;
@@ -313,6 +313,26 @@ test("test mode keeps key-only non-MFA hosts on the single key credential", asyn
assert.deepEqual(calls.connectOptions.authHandler, ["publickey"]);
});
test("test mode includes keyboard-interactive for key-only hosts with TOTP", async () => {
const { api, calls, sender } = keyOnlyHarness();
await api.startSSHSession({ sender }, keyOnlyOptions({
totp: { uri: "otpauth://totp/Example:root?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example" },
}));
assert.deepEqual(calls.connectOptions.authHandler, ["publickey", "keyboard-interactive"]);
});
test("test mode ignores an encrypted TOTP placeholder", async () => {
const { api, calls, sender } = keyOnlyHarness();
await api.startSSHSession({ sender }, keyOnlyOptions({
totp: { uri: "enc:v1:placeholder" },
}));
assert.deepEqual(calls.connectOptions.authHandler, ["publickey"]);
});
test("test mode skips the raw password method for password MFA hosts", async () => {
const { api, calls, sender } = createHarness({
onConnect: (conn) => {
+167
View File
@@ -0,0 +1,167 @@
"use strict";
const jsQR = require("jsqr");
const {
previewTotpText,
selectTotpFromQrPayloads,
bgraToRgba,
currentTotp,
} = require("../../domain/totp.shared.cjs");
const MAX_INPUT_LENGTH = 4096;
function screenCaptureStatus(electron) {
try {
return electron?.systemPreferences?.getMediaAccessStatus?.("screen") || "unknown";
} catch {
return "unknown";
}
}
function isScreenPermissionError(error) {
const message = String(error?.message || error || "");
return /denied|permission|not authorized|not permitted/i.test(message);
}
function thumbnailSizeForDisplay(display) {
const scale = Number(display?.scaleFactor) || 1;
const width = Math.max(1, Math.round((display?.size?.width || display?.bounds?.width || 1920) * scale));
const height = Math.max(1, Math.round((display?.size?.height || display?.bounds?.height || 1080) * scale));
return { width, height };
}
function largestThumbnailSize(displays) {
if (!displays.length) return { width: 1920, height: 1080 };
let width = 1;
let height = 1;
for (const display of displays) {
const size = thumbnailSizeForDisplay(display);
width = Math.max(width, size.width);
height = Math.max(height, size.height);
}
return { width, height };
}
function pushThumbnail(images, image) {
if (!image?.getSize || typeof image.toBitmap !== "function") return;
const size = image.getSize();
if (!size?.width || !size?.height) return;
images.push({ width: size.width, height: size.height, bitmap: image.toBitmap() });
}
function sourceDisplayId(source) {
if (source?.display_id == null) return "";
return String(source.display_id);
}
async function captureScreenBitmaps(electron) {
const desktopCapturer = electron?.desktopCapturer;
const screen = electron?.screen;
if (!desktopCapturer?.getSources || !screen?.getAllDisplays) {
throw new Error("unavailable");
}
const displays = screen.getAllDisplays() || [];
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: largestThumbnailSize(displays),
fetchWindowIcons: false,
});
const images = [];
const used = new Set();
const take = (source) => {
if (!source || used.has(source)) return;
used.add(source);
pushThumbnail(images, source.thumbnail);
};
const matchedDisplayIds = new Set();
for (const display of displays) {
const displayId = String(display?.id);
const match = sources.find((source) => sourceDisplayId(source) !== "" && sourceDisplayId(source) === displayId);
if (!match) continue;
matchedDisplayIds.add(displayId);
take(match);
}
for (const source of sources) {
const displayId = sourceDisplayId(source);
if (displayId && matchedDisplayIds.has(displayId)) continue;
take(source);
}
return images;
}
function decodeBitmaps(images, decode) {
const payloads = [];
for (const image of images) {
const rgba = bgraToRgba(image.bitmap, image.width, image.height);
const result = decode(rgba, image.width, image.height);
if (result?.data) payloads.push(result.data);
}
return payloads;
}
function publicPreviewFromParsed(parsed, timestamp) {
const current = currentTotp(parsed, timestamp);
return {
ok: true,
uri: parsed.uri,
code: current.code,
secondsRemaining: current.secondsRemaining,
period: parsed.period,
algorithm: parsed.algorithm,
digits: parsed.digits,
issuer: parsed.issuer,
label: parsed.label,
};
}
function previewTotpInput(payload, now = Date.now()) {
const text = typeof payload?.text === "string" ? payload.text : payload?.uri;
if (typeof text !== "string" || text.length > MAX_INPUT_LENGTH) {
return { ok: false, error: "invalid" };
}
return previewTotpText(text, now);
}
async function scanScreensForTotp(deps = {}) {
const electron = deps.electron;
const status = screenCaptureStatus(electron);
if (status === "denied" || status === "restricted") {
return { ok: false, error: "screen-permission-denied" };
}
let images;
try {
images = await (deps.capture || captureScreenBitmaps)(electron);
} catch (error) {
if (isScreenPermissionError(error)) return { ok: false, error: "screen-permission-denied" };
return { ok: false, error: "unavailable" };
}
if (!images?.length) {
const platform = deps.platform || process.platform;
if (platform === "darwin" && status !== "granted") {
return { ok: false, error: "screen-permission-denied" };
}
return { ok: false, error: "not-found" };
}
const decode = deps.decode || ((data, width, height) => jsQR(data, width, height));
let payloads;
try {
payloads = decodeBitmaps(images, decode);
} catch {
return { ok: false, error: "not-found" };
}
const selected = selectTotpFromQrPayloads(payloads);
if (!selected.ok) return { ok: false, error: selected.error };
return publicPreviewFromParsed(selected.parsed, deps.now?.() ?? Date.now());
}
function registerTotpHandlers(ipcMain, electronModule) {
ipcMain.handle("netcatty:totp:preview", (_event, payload) => previewTotpInput(payload));
ipcMain.handle("netcatty:totp:scan-screen", () => scanScreensForTotp({ electron: electronModule }));
}
module.exports = {
registerTotpHandlers,
scanScreensForTotp,
previewTotpInput,
captureScreenBitmaps,
};
+140
View File
@@ -0,0 +1,140 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { scanScreensForTotp, previewTotpInput, captureScreenBitmaps } = require("./totpBridge.cjs");
const { encodeBase32 } = require("../../domain/totp.shared.cjs");
const SECRET = Buffer.from("12345678901234567890", "ascii");
const TOTP_URI = `otpauth://totp/Example:alice?secret=${encodeBase32(SECRET)}&algorithm=SHA1&digits=8&period=30`;
test("previewTotpInput returns the current code without echoing a separate secret field", () => {
const preview = previewTotpInput({ text: TOTP_URI }, 59_000);
assert.equal(preview.ok, true);
assert.equal(preview.code, "94287082");
assert.equal(preview.algorithm, "SHA1");
assert.equal(preview.digits, 8);
assert.equal("secret" in preview, false);
assert.equal(previewTotpInput({ text: "otpauth://hotp/A?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=1" }).error, "hotp");
assert.equal(previewTotpInput({ text: "otpauth-migration://offline?data=abc" }).error, "migration");
});
test("screen scan returns a permission error when macOS screen recording is denied", async () => {
const result = await scanScreensForTotp({
electron: { systemPreferences: { getMediaAccessStatus: () => "denied" } },
capture: async () => {
throw new Error("capture should not run");
},
});
assert.deepEqual(result, { ok: false, error: "screen-permission-denied" });
});
test("screen scan takes the first otpauth totp URI and ignores a leading HOTP code", async () => {
const result = await scanScreensForTotp({
electron: { systemPreferences: { getMediaAccessStatus: () => "granted" } },
capture: async () => [{ width: 1, height: 1, bitmap: Buffer.from([0, 0, 0, 255]) }],
decode: () => ({ data: TOTP_URI }),
now: () => 59_000,
});
assert.equal(result.ok, true);
assert.equal(result.code, "94287082");
assert.match(result.uri, /^otpauth:\/\/totp\//);
assert.equal("secret" in result, false);
});
test("an empty macOS capture without screen-recording permission is a clear denial", async () => {
const result = await scanScreensForTotp({
platform: "darwin",
electron: { systemPreferences: { getMediaAccessStatus: () => "not-determined" } },
capture: async () => [],
});
assert.equal(result.error, "screen-permission-denied");
});
function thumbnail(label) {
return {
getSize: () => ({ width: 2, height: 2 }),
toBitmap: () => Buffer.from(label),
};
}
test("screen capture scans each unmatched source once", async () => {
let calls = 0;
const images = await captureScreenBitmaps({
screen: {
getAllDisplays: () => [
{ id: 1, size: { width: 800, height: 600 }, scaleFactor: 1 },
{ id: 2, size: { width: 1920, height: 1080 }, scaleFactor: 1 },
],
},
desktopCapturer: {
getSources: async (options) => {
calls += 1;
assert.deepEqual(options.thumbnailSize, { width: 1920, height: 1080 });
return [
{ id: "left", display_id: "", thumbnail: thumbnail("left") },
{ id: "right", display_id: "", thumbnail: thumbnail("right") },
];
},
},
});
assert.equal(calls, 1);
assert.deepEqual(images.map((image) => image.bitmap.toString()), ["left", "right"]);
});
test("screen capture uses the largest width and height across orientations", async () => {
let thumbnailSize;
await captureScreenBitmaps({
screen: {
getAllDisplays: () => [
{ id: 1, size: { width: 3840, height: 2160 }, scaleFactor: 1 },
{ id: 2, size: { width: 2160, height: 3840 }, scaleFactor: 1 },
],
},
desktopCapturer: {
getSources: async (options) => {
thumbnailSize = options.thumbnailSize;
return [
{ id: "landscape", display_id: "1", thumbnail: thumbnail("landscape") },
{ id: "portrait", display_id: "2", thumbnail: thumbnail("portrait") },
];
},
},
});
assert.deepEqual(thumbnailSize, { width: 3840, height: 3840 });
});
test("screen capture keeps a matched display and still scans an unmatched source", async () => {
const images = await captureScreenBitmaps({
screen: {
getAllDisplays: () => [
{ id: 10, size: { width: 100, height: 100 }, scaleFactor: 2 },
{ id: 20, size: { width: 100, height: 100 }, scaleFactor: 1 },
],
},
desktopCapturer: {
getSources: async (options) => {
assert.deepEqual(options.thumbnailSize, { width: 200, height: 200 });
return [
{ id: "known", display_id: "10", thumbnail: thumbnail("known") },
{ id: "other", display_id: "", thumbnail: thumbnail("other") },
];
},
},
});
assert.deepEqual(images.map((image) => image.bitmap.toString()), ["known", "other"]);
});
test("screen scan reports HOTP and a friendly miss", async () => {
const hotp = await scanScreensForTotp({
electron: { systemPreferences: { getMediaAccessStatus: () => "granted" } },
capture: async () => [{ width: 1, height: 1, bitmap: Buffer.from([0, 0, 0, 255]) }],
decode: () => ({ data: "otpauth://hotp/A?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=1" }),
});
assert.equal(hotp.error, "hotp");
const missed = await scanScreensForTotp({
electron: { systemPreferences: { getMediaAccessStatus: () => "granted" } },
capture: async () => [{ width: 1, height: 1, bitmap: Buffer.from([0, 0, 0, 255]) }],
decode: () => null,
});
assert.equal(missed.error, "not-found");
});
+2
View File
@@ -525,6 +525,8 @@ function createBridgeRegistrar(context) {
compressUploadBridge.registerHandlers(ipcMain, { terminalWorkerManager });
globalShortcutBridge.registerHandlers(ipcMain);
credentialBridge.registerHandlers(ipcMain, electronModule);
const { registerTotpHandlers } = require("../bridges/totpBridge.cjs");
registerTotpHandlers(ipcMain, electronModule);
autoUpdateBridge.init(deps);
autoUpdateBridge.registerHandlers(ipcMain);
aiBridge.registerHandlers(ipcMain);
+1 -1
View File
@@ -6,7 +6,7 @@ const path = require("node:path");
const { PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_MAX_FILES } = require("./constants.cjs");
const { assertPluginStorageSegment } = require("./paths.cjs");
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie)/iu;
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie|totp|otpauth)/iu;
function redactLogValue(value, key = "", depth = 0) {
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
+2
View File
@@ -1865,6 +1865,8 @@ function createPreloadApi(ctx) {
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
credentialsEncrypt: (plaintext) => ipcRenderer.invoke("netcatty:credentials:encrypt", plaintext),
credentialsDecrypt: (value) => ipcRenderer.invoke("netcatty:credentials:decrypt", value),
previewTotp: (payload) => ipcRenderer.invoke("netcatty:totp:preview", payload),
scanTotpQrFromScreen: () => ipcRenderer.invoke("netcatty:totp:scan-screen"),
// Auto-update
checkForUpdate: () => ipcRenderer.invoke("netcatty:update:check"),
Vendored
+9
View File
@@ -38,6 +38,12 @@ declare global {
isDefault?: boolean;
}
interface NetcattyTotpConfig {
uri: string;
autoSubmit?: boolean;
promptPattern?: string;
}
// Jump host configuration for SSH tunneling
interface NetcattyJumpHost {
hostname: string;
@@ -47,6 +53,7 @@ declare global {
authMethod?: import("./domain/models").HostAuthMethod;
requiresMfa?: boolean;
password?: string;
totp?: NetcattyTotpConfig;
privateKey?: string;
certificate?: string;
passphrase?: string;
@@ -103,6 +110,7 @@ declare global {
requiresMfa?: boolean;
port?: number;
password?: string;
totp?: NetcattyTotpConfig;
privateKey?: string;
// Optional OpenSSH user certificate
certificate?: string;
@@ -221,6 +229,7 @@ declare global {
authMethod?: import("./domain/models").HostAuthMethod;
requiresMfa?: boolean;
password?: string;
totp?: NetcattyTotpConfig;
privateKey?: string;
certificate?: string;
keyId?: string;
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { fitLargeToolResultForModel } from './harness/toolResultFitting';
import type { GroupConfig, Host, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, VaultNote } from '../../domain/models';
import { handleVaultAgentOp, runSerializedVaultAgentRequest, type VaultAgentApiDeps } from './vaultAgentBridgeClient';
import { handleVaultAgentOp, runSerializedVaultAgentRequest, sanitizeHostForAgent, type VaultAgentApiDeps } from './vaultAgentBridgeClient';
type DepsSeed = {
hosts?: Host[];
@@ -404,6 +404,20 @@ describe('handleVaultAgentOp vault hosts', () => {
assert.equal('password' in (hosts?.[0] ?? {}), false);
});
it('sanitizeHostForAgent omits TOTP secrets', () => {
const sanitized = sanitizeHostForAgent({
id: 'host-1',
label: 'prod',
hostname: '10.0.0.1',
username: 'root',
port: 22,
tags: [],
os: 'linux',
totp: { uri: 'otpauth://totp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' },
});
assert.equal('totp' in sanitized, false);
});
it('host.open creates a terminal session for a vault host', async () => {
const host: Host = {
id: 'host-open-1',
@@ -79,6 +79,7 @@ const SENSITIVE_HOST_KEYS = new Set([
'telnetPassword',
'privateKey',
'passphrase',
'totp',
]);
/**
@@ -301,6 +301,9 @@ export async function encryptHostSecrets(host: Host): Promise<Host> {
if (out.proxyConfig?.password) {
out.proxyConfig = { ...out.proxyConfig, password: await encryptField(out.proxyConfig.password) };
}
if (out.totp?.uri) {
out.totp = { ...out.totp, uri: (await encryptField(out.totp.uri)) ?? out.totp.uri };
}
return out;
}
@@ -314,6 +317,12 @@ export async function decryptHostSecrets(host: Host): Promise<Host> {
password: persistDecryptedSecret(out.proxyConfig.password, await decryptField(out.proxyConfig.password)),
};
}
if (out.totp?.uri) {
out.totp = {
...out.totp,
uri: persistDecryptedSecret(out.totp.uri, await decryptField(out.totp.uri)) ?? out.totp.uri,
};
}
return out;
}
@@ -15,6 +15,7 @@ import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../..
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from '../../domain/sshAuth';
import { resolveHostKeepalive } from '../../domain/host';
import { connectionTotpFields } from '../../domain/totpConfig';
import { resolveHostSshConnectionTimeouts } from '../../domain/sshConnectionTimeouts';
import {
findIncompleteProxyIdentityId,
@@ -998,6 +999,7 @@ export const startPortForward = async (
authMethod: jumpResolved.authMethod,
requiresMfa: !!jumpHost.requiresMfa,
password: jumpPassword,
...connectionTotpFields(jumpHost),
privateKey: jumpKeyAuth.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpKeyAuth.passphrase,
@@ -1133,6 +1135,7 @@ export const startPortForward = async (
authMethod: resolved.authMethod,
requiresMfa: !!host.requiresMfa,
password,
...connectionTotpFields(host),
privateKey: keyAuth.privateKey,
certificate: key?.certificate,
keyId: resolved.keyId,
+8 -20
View File
@@ -29,7 +29,9 @@
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"iconv-lite": "^0.6.3",
"jsqr": "^1.4.0",
"node-pty": "1.1.0",
"re2js": "2.8.6",
"serialport": "^13.0.0",
"socks-proxy-agent": "^8.0.5",
"ssh2-sftp-client": "^12.0.1",
@@ -102,7 +104,6 @@
"opencc-js": "^1.4.2",
"patch-package": "^8.0.0",
"pinyin-pro": "^3.28.1",
"re2js": "2.8.6",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-test-renderer": "^19.2.4",
@@ -5023,9 +5024,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5047,9 +5045,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5071,9 +5066,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5095,9 +5087,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5119,9 +5108,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5143,9 +5129,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -14240,6 +14223,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/katex": {
"version": "0.18.4",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.4.tgz",
@@ -17628,7 +17617,6 @@
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz",
"integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
+2 -1
View File
@@ -93,7 +93,9 @@
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"iconv-lite": "^0.6.3",
"jsqr": "^1.4.0",
"node-pty": "1.1.0",
"re2js": "2.8.6",
"serialport": "^13.0.0",
"socks-proxy-agent": "^8.0.5",
"ssh2-sftp-client": "^12.0.1",
@@ -163,7 +165,6 @@
"opencc-js": "^1.4.2",
"patch-package": "^8.0.0",
"pinyin-pro": "^3.28.1",
"re2js": "2.8.6",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-test-renderer": "^19.2.4",
+16 -1
View File
@@ -54,6 +54,22 @@ test("build.files includes the single-channel SSH banner matcher", () => {
assert.equal(banner.remoteSoftwareRequiresSingleChannel("JumpServer"), false);
});
test("build.files includes the shared TOTP runtime required by SSH bridges", () => {
assert.ok(
config.files.includes("domain/totp.shared.cjs"),
"packaged SSH bridges require domain/totp.shared.cjs at load",
);
assert.ok(
config.files.includes("domain/totpPrompt.shared.cjs"),
"the shared TOTP runtime requires the prompt exclusion list",
);
const totp = require("../domain/totp.shared.cjs");
const prompts = require("../domain/totpPrompt.shared.cjs");
assert.equal(typeof totp.generateTotp, "function");
assert.equal(typeof totp.planTotpAutoSubmit, "function");
assert.equal(totp.NON_TOTP_PROMPT, prompts.NON_TOTP_PROMPT);
});
test("unpacked Tool CLI includes capability runtime dependencies", () => {
assert.ok(
config.asarUnpack.includes("electron/cli/**/*"),
@@ -193,7 +209,6 @@ test("build.files excludes Vite-bundled renderer-only packages", () => {
"!node_modules/@streamdown/**/*",
"!node_modules/@tanstack/react-virtual/**/*",
"!node_modules/pinyin-pro/**/*",
"!node_modules/re2js/**/*",
"!node_modules/@eslint-community/regexpp/**/*",
"!node_modules/clsx/**/*",
"!node_modules/tailwind-merge/**/*",
+24
View File
@@ -157,6 +157,30 @@ declare global {
credentialsAvailable?(): Promise<boolean>;
credentialsEncrypt?(plaintext: string): Promise<string>;
credentialsDecrypt?(value: string): Promise<string>;
previewTotp?(payload: { text?: string; uri?: string }): Promise<{
ok: boolean;
uri?: string;
code?: string;
secondsRemaining?: number;
period?: number;
algorithm?: string;
digits?: number;
issuer?: string;
label?: string;
error?: string;
}>;
scanTotpQrFromScreen?(): Promise<{
ok: boolean;
uri?: string;
code?: string;
secondsRemaining?: number;
period?: number;
algorithm?: string;
digits?: number;
issuer?: string;
label?: string;
error?: string;
}>;
}
}
+2
View File
@@ -218,6 +218,7 @@ declare global {
authMethod?: import("../../domain/models").HostAuthMethod;
requiresMfa?: boolean;
password?: string;
totp?: NetcattyTotpConfig;
privateKey?: string;
certificate?: string;
publicKey?: string;
@@ -591,6 +592,7 @@ declare global {
savedPassword?: string | null;
/** When false, UI must not offer saving the response as the host password. */
allowSavePassword?: boolean;
totpUri?: string;
scope?: "terminal" | "external";
bootEpoch?: number;
}) => void