Skip to content
Merged
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
87 changes: 78 additions & 9 deletions apps/extension/src/popup/components/VaultPanel.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -338,8 +402,8 @@ export function VaultPanel() {
</div>

{!showTrash && (
<div className="flex gap-1.5">
{['login', 'card', 'identity'].map((type) => (
<div className="flex flex-wrap gap-1.5">
{NEW_ITEM_TYPES.map((type) => (
<button
key={type}
type="button"
Expand Down Expand Up @@ -401,7 +465,12 @@ export function VaultPanel() {
{!showTrash && (
<label className="cursor-pointer text-xs font-medium text-primary-600 hover:underline">
Import
<input type="file" accept=".csv,text/csv" onChange={onImport} className="hidden" />
<input
type="file"
accept=".csv,text/csv,.opencreds,.json,application/json,application/vnd.logicsrc.opencreds+json"
onChange={onImport}
className="hidden"
/>
</label>
)}
</div>
Expand Down
183 changes: 180 additions & 3 deletions apps/extension/src/popup/components/vault/VaultItemEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<label className="block">
<span className="text-[11px] font-medium text-slate-600">{label}</span>
<textarea
value={value || ''}
onChange={(e) => onChange(e.target.value)}
rows={rows}
spellCheck={false}
className="mt-0.5 w-full rounded-lg border border-slate-300 px-2.5 py-1.5 font-mono text-xs focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</label>
);
}

/**
* A secret that is masked until asked for.
*
* Distinct from PasswordField because these have no generator: nobody generates
* an access token here, they paste the one the provider issued.
*/
function SecretField({ label, value, onChange, multiline = false }) {
const [revealed, setRevealed] = useState(false);

return (
<div>
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-slate-600">{label}</span>
<button
type="button"
onClick={() => setRevealed((v) => !v)}
className="text-[10px] text-primary-600 hover:underline"
>
{revealed ? 'Hide' : 'Show'}
</button>
</div>
{multiline ? (
<textarea
value={revealed ? value || '' : value ? MASK : ''}
onChange={(e) => onChange(e.target.value)}
readOnly={!revealed && Boolean(value)}
rows={3}
spellCheck={false}
className="mt-0.5 w-full rounded-lg border border-slate-300 px-2.5 py-1.5 font-mono text-xs focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
) : (
<input
type={revealed ? 'text' : 'password'}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
spellCheck={false}
className="mt-0.5 w-full rounded-lg border border-slate-300 px-2.5 py-1.5 font-mono text-xs focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
)}
</div>
);
}

/** The password field, with reveal and a generator. */
function PasswordField({ value, onChange }) {
const [revealed, setRevealed] = useState(false);
Expand Down Expand Up @@ -318,6 +390,111 @@ export function VaultItemEditor({ item, type: initialType, onSave, onCancel }) {
</>
)}

{type === 'key' && (
<>
<label className="block">
<span className="text-[11px] font-medium text-slate-600">Kind</span>
<select
value={group.keyType || ''}
onChange={(e) => setField('keyType')(e.target.value)}
className="mt-0.5 w-full rounded-lg border border-slate-300 px-2.5 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option value="">Choose…</option>
{KEY_TYPES.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
</label>

{/* An api, env or symmetric key is one opaque string; the rest are a
keypair. Rendering both would ask for a public key for an API
token, which is a question with no answer. */}
{['api', 'env', 'symmetric'].includes(group.keyType) ? (
<SecretField label="Secret" value={group.value} onChange={setField('value')} />
) : (
<>
<TextArea
label="Public key"
value={group.publicKey}
onChange={setField('publicKey')}
/>
<SecretField
label="Private key"
value={group.privateKey}
onChange={setField('privateKey')}
multiline
/>
<SecretField
label="Key passphrase"
value={group.passphrase}
onChange={setField('passphrase')}
/>
</>
)}

<Field label="Algorithm" value={group.algorithm} onChange={setField('algorithm')} />
{/* Path and mode make a restore total: a private key written back
with the wrong mode is a key ssh refuses to use, and one at the
wrong path is a key nothing finds. */}
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<Field label="Path" value={group.path} onChange={setField('path')} mono />
</div>
<Field label="Mode" value={group.mode} onChange={setField('mode')} mono />
</div>
<Field
label="Fingerprint"
value={group.fingerprint}
onChange={setField('fingerprint')}
mono
/>
</>
)}

{type === 'account' && (
<>
<div className="grid grid-cols-2 gap-2">
<Field label="Provider" value={group.provider} onChange={setField('provider')} />
<Field label="Handle" value={group.handle} onChange={setField('handle')} />
</div>
<Field label="Account id" value={group.accountId} onChange={setField('accountId')} mono />
<Field label="Email" value={group.email} onChange={setField('email')} type="email" />
<SecretField
label="Access token"
value={group.accessToken}
onChange={setField('accessToken')}
/>
<SecretField
label="Refresh token"
value={group.refreshToken}
onChange={setField('refreshToken')}
/>
<div className="grid grid-cols-2 gap-2">
{/* A test key and a live key look identical and are not. */}
<Field
label="Environment"
value={group.environment}
onChange={setField('environment')}
/>
<Field label="Expires" value={group.expiresAt} onChange={setField('expiresAt')} />
</div>
<Field
label="Scopes (comma separated)"
value={Array.isArray(group.scopes) ? group.scopes.join(', ') : group.scopes}
onChange={(value) =>
setField('scopes')(
String(value)
.split(',')
.map((s) => s.trim())
.filter(Boolean)
)
}
/>
</>
)}

<label className="block">
<span className="text-[11px] font-medium text-slate-600">Notes</span>
<textarea
Expand Down
7 changes: 5 additions & 2 deletions apps/web/__tests__/vault-validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,15 @@ describe('validateItemPayload', () => {
expect(validateItemPayload(withoutId, { requireId: false })).toBeNull();
});

it.each([[0], [5], [null], ['login'], [1.5]])('rejects type %s', (type) => {
it.each([[0], [7], [null], ['login'], [1.5]])('rejects type %s', (type) => {
expect(validateItemPayload({ ...validItem(), type })).toMatch(/Unknown item type/);
});

it('accepts every defined type', () => {
for (const type of [1, 2, 3, 4]) {
// The OpenCreds six: 1 login, 2 card, 3 identity, 4 note, 5 key, 6 account.
// Mirrors the vault_items_type_known constraint, so a rejection here and a
// rejection in the database mean the same thing.
for (const type of [1, 2, 3, 4, 5, 6]) {
expect(validateItemPayload({ ...validItem(), type })).toBeNull();
}
});
Expand Down
5 changes: 4 additions & 1 deletion apps/web/lib/vault-validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export function isSaneBlob(value, { required = true, maxLength = MAX_KEY_FIELD_L
export function validateItemPayload(body, { requireId = true } = {}) {
if (!body || typeof body !== 'object') return 'Invalid JSON body';
if (requireId && !isUuid(body.id)) return 'A valid item id is required';
if (!Number.isInteger(body.type) || body.type < 1 || body.type > 4) return 'Unknown item type';
// OpenCreds type codes: 1 login, 2 card, 3 identity, 4 note, 5 key, 6 account.
// Mirrors the vault_items_type_known constraint, so a rejection here and a
// rejection in the database mean the same thing.
if (!Number.isInteger(body.type) || body.type < 1 || body.type > 6) return 'Unknown item type';
if (!isSaneBlob(body.ciphertext, { maxLength: MAX_CIPHERTEXT_LENGTH })) {
return 'Malformed ciphertext';
}
Expand Down
Loading
Loading