From ebd817e3d03cce88a53dff394881bc51a1827c10 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 10:11:38 +0000 Subject: [PATCH] Implement OpenCreds: two more item types, and a portable vault The vault could hold logins, cards, identities and notes, and could only leave through a CSV. A CSV is plaintext by construction, drops whatever it has no column for -- password history, TOTP seeds, folders, custom fields, URI match rules -- and carries no integrity: one truncated at 3,000 rows imports 3,000 rows and reports success. This implements OpenCreds 0.1 (https://logicsrc.com/opencreds), which specifies the record, the envelope and a portable file. Two more item types, both things people already keep in a vault and neither expressible as a login: - key (5): SSH and PGP keys, API tokens, certificates, .env secrets. Carries path and mode, so a restore is total -- a private key written back 0644 is a key ssh refuses to use, and one at the wrong path is a key nothing finds. - account (6): a provider account and the OAuth tokens that act as it. Deliberately not a login: a login is what a person types at a sign-in form, an account is what a machine presents to an API. Conflating them is how a rotated refresh token ends up in a password history array. Codes 1-4 are untouched. This vault's deployed codes are what fixed them in the specification; renumbering would break every ciphertext already written. For the same reason the vault declares the namespace `marksyncr` rather than adopting `opencreds` -- its labels are compiled into the AAD of every ciphertext it has written, so editing one would not migrate a vault, it would make it undecryptable. `marksyncr` is a registered namespace, and not one existing vault needed re-encrypting. The portable database, both directions. Encrypted by default under a key derived from an export passphrase, not the vault's user key -- a file encrypted under the user key only opens inside the vault it came from. The header is bound as AAD over the payload, so the manifest is authenticated by the same tag as the data: the item count is previewable before anyone types a passphrase, and cannot be a lie. Import writes nothing when the passphrase is wrong, the file was altered, or the manifest disagrees with the payload. Import accepts an .opencreds file alongside the CSVs, prompting for the passphrase with the real item count in the prompt. The editor gains fields for both new types, and vault_items accepts type 5 and 6 in the database and in the API validator. packages/vault/__tests__/opencreds-interop.test.js drives this vault against the reference implementation in both directions and asserts the item records come back byte-identical -- a round trip inside one implementation only proves the code agrees with itself. It skips when the reference is not installed rather than reddening CI; point OPENCREDS_REF at a built checkout to run it. Verified passing against @logicsrc/opencreds: six types out and back, history, key modes and account scopes intact, wrong passphrases and restated manifests refused. 149 vault tests, 786 extension tests, lint clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5 --- .../src/popup/components/VaultPanel.jsx | 87 +++- .../components/vault/VaultItemEditor.jsx | 183 ++++++++- apps/web/__tests__/vault-validation.test.js | 7 +- apps/web/lib/vault-validation.js | 5 +- docs/opencreds.md | 122 ++++++ .../vault/__tests__/opencreds-interop.test.js | 167 ++++++++ packages/vault/__tests__/opencreds.test.js | 333 +++++++++++++++ packages/vault/package.json | 3 +- packages/vault/src/import.js | 104 ++++- packages/vault/src/index.js | 20 + packages/vault/src/items.js | 75 ++++ packages/vault/src/opencreds.js | 378 ++++++++++++++++++ .../20260829180000_vault_opencreds_types.sql | 28 ++ 13 files changed, 1490 insertions(+), 22 deletions(-) create mode 100644 docs/opencreds.md create mode 100644 packages/vault/__tests__/opencreds-interop.test.js create mode 100644 packages/vault/__tests__/opencreds.test.js create mode 100644 packages/vault/src/opencreds.js create mode 100644 supabase/migrations/20260829180000_vault_opencreds_types.sql 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 ( +