mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e856ad9bb | ||
|
|
08aff13469 | ||
|
|
21a174b573 | ||
|
|
147ae96c06 | ||
|
|
90e3e95f88 | ||
|
|
426e30cd35 | ||
|
|
1769005f6d | ||
|
|
75ced1e093 | ||
|
|
aa15e36880 | ||
|
|
101cb8eeb9 | ||
|
|
33776fb6cc | ||
|
|
d12363ecf8 |
No files matched your search
@@ -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 && (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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-ключа',
|
||||
|
||||
@@ -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 密钥密码',
|
||||
|
||||
@@ -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': '身份不存在',
|
||||
|
||||
@@ -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 金鑰密碼',
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -125,6 +125,7 @@ export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
|
||||
identityFileId: _identityFileId,
|
||||
identityFilePaths: _identityFilePaths,
|
||||
password: _password,
|
||||
totp: _totp,
|
||||
savePassword: _savePassword,
|
||||
authMethod: _authMethod,
|
||||
authPolicyVersion: _authPolicyVersion,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 } : {};
|
||||
}
|
||||
@@ -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;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export const NON_TOTP_PROMPT: RegExp;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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]";
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Generated
+8
-20
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user