diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 43e17b7..2a562f1 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -563,9 +563,13 @@ async function encryptUris( mac: Uint8Array ): Promise>> { const out: Array> = []; + const seen = new Set(); for (const entry of uris || []) { const trimmed = String(entry?.uri || '').trim(); if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); const preservedExtra = entry?.extra && typeof entry.extra === 'object' ? { ...entry.extra } diff --git a/webapp/src/lib/app-support.ts b/webapp/src/lib/app-support.ts index 246138b..b5bc87a 100644 --- a/webapp/src/lib/app-support.ts +++ b/webapp/src/lib/app-support.ts @@ -31,6 +31,25 @@ export function asText(value: unknown): string { return String(value); } +function isImportTotpFieldName(value: unknown): boolean { + const name = asText(value).trim().toLowerCase().replace(/[\s_-]+/g, ''); + return [ + 'totp', + 'totpuri', + 'otp', + 'otpuri', + 'otpurl', + 'otpauth', + 'onetimepassword', + 'onetimepasscode', + '2fa', + 'twofactor', + 'twofactorauthentication', + 'authenticator', + 'verificationcode', + ].includes(name); +} + export function readInviteCodeFromUrl(): string { if (typeof window === 'undefined') return ''; @@ -162,6 +181,7 @@ export function importCipherToDraft(cipher: Record, folderId: s draft.loginPassword = asText(login.password); draft.loginTotp = asText(login.totp); const urisRaw = Array.isArray(login.uris) ? login.uris : []; + const seenUris = new Set(); const uris = urisRaw .map((u) => { const row = (u || {}) as Record; @@ -176,8 +196,21 @@ export function importCipherToDraft(cipher: Record, folderId: s ), }; }) - .filter((u) => !!u.uri); + .filter((u) => { + if (!u.uri) return false; + const key = u.uri.toLowerCase(); + if (seenUris.has(key)) return false; + seenUris.add(key); + return true; + }); draft.loginUris = uris.length ? uris : [{ uri: '', match: null, originalUri: '', extra: {} }]; + if (!draft.loginTotp) { + const totpFieldIndex = draft.customFields.findIndex((field) => isImportTotpFieldName(field.label)); + if (totpFieldIndex >= 0) { + draft.loginTotp = asText(draft.customFields[totpFieldIndex].value); + draft.customFields = draft.customFields.filter((_, index) => index !== totpFieldIndex); + } + } draft.loginFido2Credentials = Array.isArray(login.fido2Credentials) ? login.fido2Credentials.filter((item): item is Record => !!item && typeof item === 'object') : []; diff --git a/webapp/src/lib/import-format-shared.ts b/webapp/src/lib/import-format-shared.ts index e254dda..67363c3 100644 --- a/webapp/src/lib/import-format-shared.ts +++ b/webapp/src/lib/import-format-shared.ts @@ -19,6 +19,71 @@ export function normalizeUri(raw: string): string | null { return s.slice(0, 1000); } +export function normalizeUriList(rawUris: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of rawUris) { + const uri = normalizeUri(raw); + if (!uri) continue; + const key = uri.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(uri); + } + return out; +} + +export function setLoginUris(login: Record, rawUris: string[]): void { + const uris = normalizeUriList(rawUris); + login.uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null; +} + +export function addLoginUri(login: Record, rawUri: string): void { + const existing = Array.isArray(login.uris) + ? login.uris.map((entry) => txt((entry as Record)?.uri)).filter(Boolean) + : []; + setLoginUris(login, [...existing, rawUri]); +} + +export function isTotpFieldName(raw: unknown): boolean { + const name = txt(raw).toLowerCase().replace(/[\s_-]+/g, ''); + if (!name) return false; + return [ + 'totp', + 'totpuri', + 'otp', + 'otpuri', + 'otpurl', + 'otpauth', + 'onetimepassword', + 'onetimepasscode', + '2fa', + 'twofactor', + 'twofactorauthentication', + 'authenticator', + 'verificationcode', + ].includes(name); +} + +export function extractTotpValue(raw: unknown): string { + if (raw === null || raw === undefined) return ''; + if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') return txt(raw); + if (Array.isArray(raw)) { + for (const item of raw) { + const value = extractTotpValue(item); + if (value) return value; + } + return ''; + } + if (typeof raw !== 'object') return ''; + const obj = raw as Record; + for (const key of ['totpUri', 'otpAuth', 'otpauth', 'uri', 'url', 'secret', 'totp', 'otp', 'value', 'code']) { + const value = extractTotpValue(obj[key]); + if (value) return value; + } + return ''; +} + export function parseSerializedUris(raw: string): string[] { const source = txt(raw); if (!source) return []; @@ -38,15 +103,7 @@ export function parseSerializedUris(raw: string): string[] { .filter(Boolean) : [source]; - const seen = new Set(); - const uris: string[] = []; - for (const part of parts) { - const normalized = normalizeUri(part); - if (!normalized || seen.has(normalized)) continue; - seen.add(normalized); - uris.push(normalized); - } - return uris; + return normalizeUriList(parts); } export function nameFromUrl(raw: string): string | null { diff --git a/webapp/src/lib/import-formats-onepassword.ts b/webapp/src/lib/import-formats-onepassword.ts index b0a1e7e..1141faa 100644 --- a/webapp/src/lib/import-formats-onepassword.ts +++ b/webapp/src/lib/import-formats-onepassword.ts @@ -1,14 +1,18 @@ import type { CiphersImportPayload } from '@/lib/api/vault'; import { addFolder, + addLoginUri, cardBrand, convertToNoteIfNeeded, + extractTotpValue, + isTotpFieldName, makeLoginCipher, normalizeUri, parseCardExpiry, parseCsv, parseEpochMaybe, processKvp, + setLoginUris, txt, val, } from '@/lib/import-format-shared'; @@ -91,9 +95,12 @@ export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImp login.password = rawVal; continue; } - if ((!Array.isArray(login.uris) || !login.uris.length) && (lower === 'url' || lower === 'website')) { - const uri = normalizeUri(rawVal); - login.uris = uri ? [{ uri, match: null }] : null; + if (lower === 'url' || lower === 'website') { + addLoginUri(login, rawVal); + continue; + } + if (!txt(login.totp) && isTotpFieldName(lower)) { + login.totp = rawVal; continue; } } else if (Number(cipher.type) === 3 && cipher.card) { @@ -149,7 +156,7 @@ export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImp } } - if (!ignored.has(lower) && !lower.startsWith('section:') && !lower.startsWith('section ')) { + if (!ignored.has(lower) && !isTotpFieldName(lower) && !lower.startsWith('section:') && !lower.startsWith('section ')) { if (!altUsername && lower === 'email') altUsername = rawVal; if (lower === 'created date' || lower === 'modified date') { const readable = parseEpochMaybe(rawVal); @@ -197,8 +204,8 @@ function parseOnePasswordFieldsIntoCipher( login.password = value; continue; } - if (!txt(login.totp) && designation.startsWith('totp_')) { - login.totp = value; + if (!txt(login.totp) && (designation.startsWith('totp_') || isTotpFieldName(designation) || isTotpFieldName(fieldName))) { + login.totp = extractTotpValue(raw) || value; continue; } } else if (Number(cipher.type) === 3 && cipher.card) { @@ -327,7 +334,7 @@ export function parseOnePassword1Pif(textRaw: string): CiphersImportPayload { if (uri) uris.push(uri); } if (Number(cipher.type) === 1) { - (cipher.login as Record).uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null; + setLoginUris(cipher.login as Record, uris); (cipher.login as Record).password = val(details?.password); } cipher.notes = val(details?.notesPlain); @@ -394,7 +401,7 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload } const fallbackUrl = normalizeUri(item?.overview?.url || ''); if (fallbackUrl) urls.push(fallbackUrl); - (cipher.login as Record).uris = urls.length ? urls.map((uri) => ({ uri, match: null })) : null; + setLoginUris(cipher.login as Record, urls); } for (const loginField of item?.details?.loginFields || []) { @@ -413,7 +420,7 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload login.password = lv; continue; } - if (designation.includes('totp') || fieldName.toLowerCase().includes('totp')) { + if (designation.includes('totp') || isTotpFieldName(fieldName) || isTotpFieldName(fieldType)) { login.totp = lv; continue; } @@ -510,12 +517,7 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload } else if (Number(cipher.type) === 1) { const login = cipher.login as Record; if (fieldId === 'url') { - const uri = normalizeUri(fieldValue); - if (uri) { - const uris = Array.isArray(login.uris) ? login.uris : []; - uris.push({ uri, match: null }); - login.uris = uris; - } + addLoginUri(login, fieldValue); continue; } if (fieldId === 'username' && !txt(login.username)) { @@ -526,8 +528,11 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload login.password = fieldValue; continue; } - if ((fieldId === 'oneTimePassword' || fieldId === 'totp') && !txt(login.totp)) { - login.totp = fieldValue; + if ( + (fieldId === 'oneTimePassword' || fieldId === 'totp' || fieldType === 'otp' || isTotpFieldName(fieldTitleLocal)) && + !txt(login.totp) + ) { + login.totp = extractTotpValue(fieldValueObj) || fieldValue; continue; } }