Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve whitespace in the master password

When a MobaXterm master password intentionally begins or ends with whitespace, trimming it here changes the bytes used to derive the SHA-512/AES key, so every saved credential fails to decrypt. Pass the password through exactly as entered and only treat the truly empty string as absent; the domain helper currently trims it again as well.

Useful? React with 👍 / 👎.

});
},
[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 on lines +1165 to +1167

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate the master password against Sesspass

When a user mistypes the master password, this reduces [Sesspass] to a boolean and discards the verifier value. Because AES-CFB is unauthenticated and decodeMobaPlaintext accepts printable random output (including its Latin-1 fallback), a wrong key can produce a non-null string that is then saved as the host password; for example, the included credential vector decrypts to non-null garbage with masterPassword: "wrong0". Preserve the Sesspass record and validate the supplied master password before attaching any decrypted credentials.

Useful? React with 👍 / 👎.

}

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