diff --git a/apps/extension/src/popup/components/VaultPanel.jsx b/apps/extension/src/popup/components/VaultPanel.jsx index f9794d5..db46d77 100644 --- a/apps/extension/src/popup/components/VaultPanel.jsx +++ b/apps/extension/src/popup/components/VaultPanel.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { parseImport } from '@marksyncr/vault'; +import { detectImportKind, inspectOpenCredsFile, parseImport, parseOpenCredsImport } from '@marksyncr/vault'; import { VaultUnlock } from './vault/VaultUnlock.jsx'; import { VaultItemEditor } from './vault/VaultItemEditor.jsx'; @@ -31,8 +31,13 @@ const TYPE_META = { card: { label: 'Card', badge: 'bg-violet-50 text-violet-700' }, identity: { label: 'Identity', badge: 'bg-amber-50 text-amber-700' }, note: { label: 'Note', badge: 'bg-slate-100 text-slate-600' }, + key: { label: 'Key', badge: 'bg-emerald-50 text-emerald-700' }, + account: { label: 'Account', badge: 'bg-rose-50 text-rose-700' }, }; +/** The types offered as new-item buttons. Notes are reachable from import. */ +const NEW_ITEM_TYPES = ['login', 'card', 'identity', 'key', 'account']; + /** Search across the fields a person would actually search by. */ export function filterItems(items, query) { const q = String(query || '').trim().toLowerCase(); @@ -251,23 +256,82 @@ export function VaultPanel() { await refreshStatus(); }; + /** + * Import a CSV export from another manager, or an OpenCreds database. + * + * An OpenCreds file is a whole vault rather than a table of logins: it + * carries folders, password history, TOTP seeds, and `key` and `account` + * items that no CSV has a column for. When it is encrypted — the default — + * the header still says what it holds, and that claim is authenticated, so + * the passphrase prompt can name a real number before anyone types anything. + */ const onImport = async (event) => { const file = event.target.files?.[0]; if (!file) return; event.target.value = ''; const text = await file.text(); - const { source, items: parsed, skipped } = parseImport(text); - if (!parsed.length) { - notify(skipped[0]?.reason || 'Nothing to import'); + if (detectImportKind(text) !== 'opencreds') { + const { source, items: parsed, skipped } = parseImport(text); + if (!parsed.length) { + notify(skipped[0]?.reason || 'Nothing to import'); + return; + } + const res = await sendMessage({ type: 'VAULT_IMPORT', payload: { items: parsed } }); + if (res?.success) { + await loadItems(showTrash); + notify( + skipped.length + ? `Imported ${res.imported} from ${source}; skipped ${skipped.length}` + : `Imported ${res.imported} from ${source}` + ); + } else { + notify(res?.error || 'Import failed'); + } + return; + } + + let header; + try { + header = inspectOpenCredsFile(text); + } catch (err) { + notify(err.message); return; } - const res = await sendMessage({ type: 'VAULT_IMPORT', payload: { items: parsed } }); + let passphrase; + if (header.protected) { + // The popup has no modal layer, and asking here is better than importing + // nothing and explaining why afterwards. + passphrase = window.prompt( + `This OpenCreds file holds ${header.itemCount} ${ + header.itemCount === 1 ? 'item' : 'items' + }. Enter its export passphrase to import.` + ); + if (passphrase === null) return; + } else if ( + !window.confirm( + `This OpenCreds file is unprotected — every secret in it is in the clear. Import ${header.itemCount} items anyway?` + ) + ) { + return; + } + + let parsed; + try { + parsed = await parseOpenCredsImport(text, { passphrase }); + } catch (err) { + // A manifest mismatch, a wrong passphrase and an altered file all land + // here, and in every one of them nothing has been written. + notify(err.message); + return; + } + + const res = await sendMessage({ type: 'VAULT_IMPORT', payload: { items: parsed.items } }); if (res?.success) { await loadItems(showTrash); - notify(`Imported ${res.imported} from ${source}`); + notify(`Imported ${res.imported} of ${parsed.items.length} from OpenCreds`); } else { notify(res?.error || 'Import failed'); } @@ -338,8 +402,8 @@ export function VaultPanel() { {!showTrash && ( -
- {['login', 'card', 'identity'].map((type) => ( +
+ {NEW_ITEM_TYPES.map((type) => (
diff --git a/apps/extension/src/popup/components/vault/VaultItemEditor.jsx b/apps/extension/src/popup/components/vault/VaultItemEditor.jsx index fe2991e..8efcf25 100644 --- a/apps/extension/src/popup/components/vault/VaultItemEditor.jsx +++ b/apps/extension/src/popup/components/vault/VaultItemEditor.jsx @@ -4,11 +4,23 @@ import { generatePassword, generatePassphrase, passwordEntropyBits } from '@mark /** * Create and edit vault items. * - * One editor for all three types — logins, cards and identities differ only in - * which field group they render, because they are one record shape underneath. + * One editor for every type — logins, cards, identities, notes, keys and + * accounts differ only in which field group they render, because they are one + * record shape underneath. The types are the OpenCreds six; see + * https://logicsrc.com/opencreds. */ -const TYPE_LABELS = { login: 'Login', card: 'Card', identity: 'Identity', note: 'Note' }; +const TYPE_LABELS = { + login: 'Login', + card: 'Card', + identity: 'Identity', + note: 'Note', + key: 'Key', + account: 'Account', +}; + +/** Key kinds, in the order people reach for them. */ +const KEY_TYPES = ['ssh', 'api', 'pgp', 'certificate', 'symmetric', 'env']; /** * Placeholder shown in place of a hidden password. Built rather than written as @@ -36,6 +48,66 @@ function Field({ label, value, onChange, type = 'text', mono = false, autoFocus ); } +/** A multi-line field, for key bodies and anything else that wraps. */ +function TextArea({ label, value, onChange, rows = 3 }) { + return ( +