Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions application/i18n/locales/en/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export const enVaultMessages: Messages = {
'vault.import.mobaxterm.utf8Desc': 'Use for files saved or converted as UTF-8.',
'vault.import.mobaxterm.gb18030': 'Chinese Windows',
'vault.import.mobaxterm.gb18030Desc': 'Use for legacy files exported on Chinese Windows.',
'vault.import.mobaxterm.masterPassword': 'Master password (optional)',
'vault.import.mobaxterm.masterPasswordPlaceholder': 'Enter the MobaXterm master password',
'vault.import.mobaxterm.masterPasswordHint':
'Needed to import saved passwords from .mobaconf or MobaXterm.ini. Leave blank to import sessions only.',
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
'vault.import.sshConfig.importOnly': 'Import Only',
Expand Down
4 changes: 4 additions & 0 deletions application/i18n/locales/es/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export const esVaultMessages: Messages = {
'vault.import.mobaxterm.utf8Desc': 'Úsalo para archivos guardados o convertidos como UTF-8.',
'vault.import.mobaxterm.gb18030': 'Windows en chino',
'vault.import.mobaxterm.gb18030Desc': 'Úsalo para archivos antiguos exportados en Windows en chino.',
'vault.import.mobaxterm.masterPassword': 'Contraseña maestra (opcional)',
'vault.import.mobaxterm.masterPasswordPlaceholder': 'Introduce la contraseña maestra de MobaXterm',
'vault.import.mobaxterm.masterPasswordHint':
'Hace falta para importar las contraseñas guardadas de .mobaconf o MobaXterm.ini. Déjalo vacío para importar solo las sesiones.',
'vault.import.sshConfig.chooseMode': 'Elige cómo importar tu archivo de configuración SSH.',
'vault.import.sshConfig.modeQuestion': '¿Cómo te gustaría importar?',
'vault.import.sshConfig.importOnly': 'Solo importar',
Expand Down
4 changes: 4 additions & 0 deletions application/i18n/locales/ru/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ export const ruVaultMessages: Messages = {
'vault.import.mobaxterm.utf8Desc': 'Для файлов, сохранённых или преобразованных в UTF-8.',
'vault.import.mobaxterm.gb18030': 'Китайская Windows',
'vault.import.mobaxterm.gb18030Desc': 'Для старых файлов, экспортированных в китайской Windows.',
'vault.import.mobaxterm.masterPassword': 'Мастер-пароль (необязательно)',
'vault.import.mobaxterm.masterPasswordPlaceholder': 'Введите мастер-пароль MobaXterm',
'vault.import.mobaxterm.masterPasswordHint':
'Нужен, чтобы импортировать сохранённые пароли из .mobaconf или MobaXterm.ini. Оставьте пустым, чтобы импортировать только сеансы.',
'vault.import.sshConfig.chooseMode': 'Выберите, как импортировать ваш файл SSH-конфига.',
'vault.import.sshConfig.modeQuestion': 'Как вы хотите выполнить импорт?',
'vault.import.sshConfig.importOnly': 'Только импорт',
Expand Down
3 changes: 3 additions & 0 deletions application/i18n/locales/vaultBulkImportLocales.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const KEYS = [
"vault.import.progress.persistFailed",
"vault.import.progress.rollbackFailed",
"vault.import.sshConfig.managedDestinationHint",
"vault.import.mobaxterm.masterPassword",
"vault.import.mobaxterm.masterPasswordPlaceholder",
"vault.import.mobaxterm.masterPasswordHint",
] as const;

test("bulk vault import and group-selection copy exists in every locale", () => {
Expand Down
4 changes: 4 additions & 0 deletions application/i18n/locales/zh-CN/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,10 @@ export const zhCNCoreMessages: Messages = {
'vault.import.mobaxterm.utf8Desc': '适用于已保存或转换为 UTF-8 的文件。',
'vault.import.mobaxterm.gb18030': '中文 Windows',
'vault.import.mobaxterm.gb18030Desc': '适用于中文 Windows 上导出的旧版文件。',
'vault.import.mobaxterm.masterPassword': '主密码(可选)',
'vault.import.mobaxterm.masterPasswordPlaceholder': '输入 MobaXterm 主密码',
'vault.import.mobaxterm.masterPasswordHint':
'导入 .mobaconf 或 MobaXterm.ini 中保存的密码时需要填写。留空则只导入会话。',
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
'vault.import.sshConfig.importOnly': '仅导入',
Expand Down
4 changes: 4 additions & 0 deletions application/i18n/locales/zh-TW/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,10 @@ export const zhTWCoreMessages: Messages = {
'vault.import.mobaxterm.utf8Desc': '適用於已儲存或轉換為 UTF-8 的檔案。',
'vault.import.mobaxterm.gb18030': '中文 Windows',
'vault.import.mobaxterm.gb18030Desc': '適用於中文 Windows 上匯出的舊版檔案。',
'vault.import.mobaxterm.masterPassword': '主密碼(選填)',
'vault.import.mobaxterm.masterPasswordPlaceholder': '輸入 MobaXterm 主密碼',
'vault.import.mobaxterm.masterPasswordHint':
'匯入 .mobaconf 或 MobaXterm.ini 中已儲存的密碼時需要填寫。留空則只匯入工作階段。',
'vault.import.sshConfig.chooseMode': '選擇如何匯入你的 SSH config 檔案。',
'vault.import.sshConfig.modeQuestion': '你希望如何匯入?',
'vault.import.sshConfig.importOnly': '僅匯入',
Expand Down
1 change: 1 addition & 0 deletions application/state/useVaultImportHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export function useVaultImportHandlers({
format,
files,
encoding: options?.encoding,
masterPassword: options?.masterPassword,
signal,
onProgress: (progress) => {
if (!signal.aborted) updateProgress(progress);
Expand Down
1 change: 1 addition & 0 deletions application/state/vaultImportOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type VaultImportOptions = {
managed?: boolean;
filePath?: string;
encoding?: VaultImportFileEncoding;
masterPassword?: string;
destination?: VaultImportDestination;
};

Expand Down
31 changes: 28 additions & 3 deletions components/vault/ImportVaultDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
} from "../../domain/vaultImport";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import {
Dialog,
DialogContent,
Expand All @@ -55,7 +56,7 @@ const OPTIONS: ImportOption[] = [
format: "mobaxterm",
label: "MobaXterm",
iconSrc: "/import/moba.jpg",
accept: ".ini,.mxtsessions,.txt",
accept: ".ini,.mxtsessions,.txt,.mobaconf",
},
{
format: "csv",
Expand Down Expand Up @@ -406,6 +407,7 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
const existingGroupQueryRef = useRef(existingGroupQuery);
existingGroupQueryRef.current = existingGroupQuery;
const [newGroup, setNewGroup] = useState("");
const [mobaMasterPassword, setMobaMasterPassword] = useState("");
const destination = buildVaultImportDestination({
mode: destinationMode,
existingGroup,
Expand Down Expand Up @@ -438,6 +440,7 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
setExistingGroup(groups[0] ?? "");
setExistingGroupQuery(groups[0] ?? "");
setNewGroup("");
setMobaMasterPassword("");
}, [groups, open]);
const pluginImporter = usePluginVaultImporter({
open,
Expand Down Expand Up @@ -511,9 +514,12 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
const handleMobaEncodingChoice = useCallback(
(encoding: VaultImportFileEncoding) => {
setStep("format");
pickFile("mobaxterm", ".ini,.mxtsessions,.txt", { encoding });
pickFile("mobaxterm", ".ini,.mxtsessions,.txt,.mobaconf", {
encoding,
masterPassword: mobaMasterPassword.trim() || undefined,
Comment thread
binaricat marked this conversation as resolved.
Outdated
});
},
[pickFile],
[mobaMasterPassword, pickFile],
);

const handleSecureCrtChoice = useCallback(
Expand Down Expand Up @@ -846,6 +852,25 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
</button>
))}
</div>
<div className="space-y-2">
<label
htmlFor="moba-master-password"
className="block text-center text-sm font-medium text-muted-foreground"
>
{t("vault.import.mobaxterm.masterPassword")}
</label>
<Input
id="moba-master-password"
type="password"
autoComplete="off"
value={mobaMasterPassword}
onChange={(event) => setMobaMasterPassword(event.target.value)}
placeholder={t("vault.import.mobaxterm.masterPasswordPlaceholder")}
/>
<p className="text-center text-xs text-muted-foreground">
{t("vault.import.mobaxterm.masterPasswordHint")}
</p>
</div>
<button
type="button"
onClick={() => setStep("format")}
Expand Down
58 changes: 58 additions & 0 deletions domain/vaultImport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,64 @@ test("MobaXterm import handles incomplete standard session records safely", () =
assert.equal(result.issues.length, 3);
});

test("MobaXterm import attaches master-password secrets from a full config export", () => {
const result = importVaultHostsFromText("mobaxterm", [
"[Misc]",
"SessionP=165821882556840",
"[Sesspass]",
"Administrator@WIN=dummy",
"[Passwords]",
"deploy@10.0.0.20=1du11XKQBOxud/FWh4ouWA==",
"[Credentials]",
"prod=root:0XROpGmLAYVx",
"[Bookmarks]",
"SubRep=",
"ImgNum=42",
`web-server=${mobaXtermSshSession("10.0.0.20", 2222, "deploy")}`,
`root-server=${mobaXtermSshSession("root.example.com", 22, "root")}`,
].join("\n"), { masterPassword: "12345678" });

assert.equal(result.hosts.length, 2);
const web = result.hosts.find((host) => host.label === "web-server");
const root = result.hosts.find((host) => host.label === "root-server");
assert.equal(web?.password, "Lw3+cZ2s.w@U@f]U");
assert.equal(web?.savePassword, true);
assert.equal(root?.password, "HyperSine");
});

test("MobaXterm import leaves sessions intact when encrypted passwords need a master password", () => {
const result = importVaultHostsFromText("mobaxterm", [
"[Sesspass]",
"Administrator@WIN=dummy",
"[Passwords]",
"deploy@10.0.0.20=1du11XKQBOxud/FWh4ouWA==",
"[Bookmarks]",
"SubRep=",
"ImgNum=42",
`web-server=${mobaXtermSshSession("10.0.0.20", 2222, "deploy")}`,
].join("\n"));

assert.equal(result.hosts.length, 1);
assert.equal(result.hosts[0].password, undefined);
assert.match(result.issues[0]?.message ?? "", /master password/i);
});

test("detectVaultImportFormat recognizes full MobaXterm configuration exports", () => {
assert.equal(
detectVaultImportFormat([
"[Misc]",
"SessionP=165821882556840",
"[Passwords]",
"deploy@10.0.0.20=1du11XKQBOxud/FWh4ouWA==",
"[Bookmarks]",
"SubRep=",
"ImgNum=42",
`server=${mobaXtermSshSession("10.0.0.1")}`,
].join("\n")),
"mobaxterm",
);
});

test("applyVaultHostImport skips duplicates by default", () => {
const existing: Host = {
id: "existing-1",
Expand Down
57 changes: 51 additions & 6 deletions domain/vaultImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
import { parseQuickConnectInput } from "./quickConnect";
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
export {
exportHostsToCsvWithStats,
getVaultCsvTemplate,
Expand Down Expand Up @@ -1104,13 +1105,20 @@ const importFromSecureCrt = (text: string, fileName?: string): VaultImportResult
};
};

const importFromMobaXterm = (text: string): VaultImportResult => {
const importFromMobaXterm = (
text: string,
options?: { masterPassword?: string },
): VaultImportResult => {
const issues: VaultImportIssue[] = [];
const lines = text.split(/\r?\n/);

type Entry = { section: string; key: string; value: string };
const entries: Entry[] = [];
const sectionGroups = new Map<string, string | undefined>();
const passwordEntries = new Map<string, string>();
const credentialEntries = new Map<string, { username: string; ciphertext: string }>();
const misc = new Map<string, string>();
let hasSesspass = false;

let section = "";
for (const line of lines) {
Expand All @@ -1128,13 +1136,36 @@ const importFromMobaXterm = (text: string): VaultImportResult => {
if (!mKv) continue;
const key = mKv[1].trim();
const value = mKv[2].trim();
const isBookmarkSection = /^bookmarks(?:_\d+)?$/i.test(section.trim());
const sectionName = section.trim();
const isBookmarkSection = /^bookmarks(?:_\d+)?$/i.test(sectionName);

if (isBookmarkSection && key.toLowerCase() === "subrep") {
sectionGroups.set(section, normalizeGroupPath(value));
continue;
}
if (isBookmarkSection && key.toLowerCase() === "imgnum") continue;
if (/^passwords$/i.test(sectionName) && key && value) {
passwordEntries.set(key, value);
continue;
}
if (/^credentials$/i.test(sectionName) && key && value) {
const colon = value.indexOf(":");
if (colon > 0) {
credentialEntries.set(key, {
username: value.slice(0, colon),
ciphertext: value.slice(colon + 1),
});
}
continue;
}
if (/^misc$/i.test(sectionName)) {
misc.set(key.toLowerCase(), value);
continue;
}
if (/^sesspass$/i.test(sectionName)) {
hasSesspass = true;
continue;
Comment thread
binaricat marked this conversation as resolved.
Comment thread
binaricat marked this conversation as resolved.
}

entries.push({ section, key, value });
}
Expand Down Expand Up @@ -1253,7 +1284,18 @@ const importFromMobaXterm = (text: string): VaultImportResult => {
);
}

const { hosts, duplicates } = dedupeHosts(parsedHosts);
const { hosts: uniqueHosts, duplicates } = dedupeHosts(parsedHosts);
const attached = attachMobaXtermPasswords(uniqueHosts, {
passwords: passwordEntries,
credentials: credentialEntries,
sessionP: misc.get("sessionp"),
sysUsername: misc.get("mpsetaccount"),
sysHostname: misc.get("mpsetcomputer"),
passwordsInRegistry: misc.get("passwordsinregistry") === "1",
hasSesspass,
}, { masterPassword: options?.masterPassword });
issues.push(...attached.issues);
const hosts = attached.hosts;
const groups = uniq(hosts.map((h) => h.group).filter(Boolean) as string[]);
return {
hosts,
Expand All @@ -1266,7 +1308,7 @@ const importFromMobaXterm = (text: string): VaultImportResult => {
export const importVaultHostsFromText = (
format: VaultImportFormat,
text: string,
options?: { fileName?: string },
options?: { fileName?: string; masterPassword?: string },
): VaultImportResult => {
const input = text ?? "";
switch (format) {
Expand All @@ -1279,7 +1321,7 @@ export const importVaultHostsFromText = (
case "securecrt":
return importFromSecureCrt(input, options?.fileName);
case "mobaxterm":
return importFromMobaXterm(input);
return importFromMobaXterm(input, options);
default: {
const _exhaustive: never = format;
return _exhaustive;
Expand All @@ -1301,9 +1343,12 @@ export function detectVaultImportFormat(text: string): VaultImportFormat | null
const hasMobaBookmarkSection = /^\[Bookmarks(?:_\d+)?\]\s*$/im.test(input);
const hasMobaBookmarkMetadata = /^SubRep=.*$/im.test(input) && /^ImgNum=\d+\s*$/im.test(input);
const hasMobaSessionLine = /^[^=\r\n]+=\s*(?:; logout)?\s*#\d+#\d+%[^%\r\n]+%\d+/im.test(input);
const hasMobaFullConfig = /^\[Misc\]\s*$/im.test(input)
&& (/^SessionP=/im.test(input) || /^\[Passwords\]\s*$/im.test(input) || /^\[Credentials\]\s*$/im.test(input));
if (
/\[MobaXterm\]/i.test(input)
|| (hasMobaBookmarkSection && (hasMobaBookmarkMetadata || hasMobaSessionLine))
|| (hasMobaBookmarkSection && (hasMobaBookmarkMetadata || hasMobaSessionLine || hasMobaFullConfig))
|| (hasMobaFullConfig && hasMobaBookmarkSection)
) {
return "mobaxterm";
}
Expand Down
Loading