From 205ccdad8bbbd9e2cf47ae1fca35afd0a7723f1e Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 1 Mar 2026 01:28:36 +0800 Subject: [PATCH] feat: add SSH key utilities and improve field decryption --- .gitignore | 1 + public/index.html | 13 -- webapp/src/App.tsx | 119 +++++++------ webapp/src/components/VaultPage.tsx | 249 ++++++++++++++++++++++------ webapp/src/lib/api.ts | 12 ++ webapp/src/lib/ssh.ts | 90 ++++++++++ webapp/src/styles.css | 170 +++++++++++++++++-- 7 files changed, 522 insertions(+), 132 deletions(-) delete mode 100644 public/index.html create mode 100644 webapp/src/lib/ssh.ts diff --git a/.gitignore b/.gitignore index f18e0e6..cdcb49f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist/ build/ public/ public2/ +public/index.html # IDE diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 1331f6d..0000000 --- a/public/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - NodeWarden - - - - -
- - diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 126a6fa..a9edef4 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; import { Link, Route, Switch, useLocation } from 'wouter'; import { useQuery } from '@tanstack/react-query'; -import { CircleHelp, LogOut, Plus, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact'; +import { CircleHelp, LogOut, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact'; import AuthViews from '@/components/AuthViews'; import ConfirmDialog from '@/components/ConfirmDialog'; import ToastHost from '@/components/ToastHost'; @@ -11,6 +11,7 @@ import AdminPage from '@/components/AdminPage'; import HelpPage from '@/components/HelpPage'; import { changeMasterPassword, + createFolder, createCipher, createAuthedFetch, createInvite, @@ -291,13 +292,6 @@ export default function App() { }); } - function handleQuickAdd() { - navigate('/vault'); - window.setTimeout(() => { - window.dispatchEvent(new Event('nodewarden:add-item')); - }, 0); - } - const ciphersQuery = useQuery({ queryKey: ['ciphers', session?.accessToken], queryFn: () => getCiphers(authedFetch), @@ -337,11 +331,24 @@ export default function App() { try { const encKey = base64ToBytes(session.symEncKey!); const macKey = base64ToBytes(session.symMacKey!); + const decryptField = async ( + value: string | null | undefined, + fieldEnc: Uint8Array = encKey, + fieldMac: Uint8Array = macKey + ): Promise => { + if (!value || typeof value !== 'string') return ''; + try { + return await decryptStr(value, fieldEnc, fieldMac); + } catch { + // Backward-compatibility: some records may already be plain text. + return value; + } + }; const folders = await Promise.all( foldersQuery.data.map(async (folder) => ({ ...folder, - decName: await decryptStr(folder.name, encKey, macKey), + decName: await decryptField(folder.name, encKey, macKey), })) ); @@ -361,19 +368,19 @@ export default function App() { const nextCipher: Cipher = { ...cipher, - decName: await decryptStr(cipher.name || '', itemEnc, itemMac), - decNotes: await decryptStr(cipher.notes || '', itemEnc, itemMac), + decName: await decryptField(cipher.name || '', itemEnc, itemMac), + decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac), }; if (cipher.login) { nextCipher.login = { ...cipher.login, - decUsername: await decryptStr(cipher.login.username || '', itemEnc, itemMac), - decPassword: await decryptStr(cipher.login.password || '', itemEnc, itemMac), - decTotp: await decryptStr(cipher.login.totp || '', itemEnc, itemMac), + decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac), + decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac), + decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac), uris: await Promise.all( (cipher.login.uris || []).map(async (u) => ({ ...u, - decUri: await decryptStr(u.uri || '', itemEnc, itemMac), + decUri: await decryptField(u.uri || '', itemEnc, itemMac), })) ), }; @@ -381,51 +388,51 @@ export default function App() { if (cipher.card) { nextCipher.card = { ...cipher.card, - decCardholderName: await decryptStr(cipher.card.cardholderName || '', itemEnc, itemMac), - decNumber: await decryptStr(cipher.card.number || '', itemEnc, itemMac), - decBrand: await decryptStr(cipher.card.brand || '', itemEnc, itemMac), - decExpMonth: await decryptStr(cipher.card.expMonth || '', itemEnc, itemMac), - decExpYear: await decryptStr(cipher.card.expYear || '', itemEnc, itemMac), - decCode: await decryptStr(cipher.card.code || '', itemEnc, itemMac), + decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac), + decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac), + decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac), + decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac), + decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac), + decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac), }; } if (cipher.identity) { nextCipher.identity = { ...cipher.identity, - decTitle: await decryptStr(cipher.identity.title || '', itemEnc, itemMac), - decFirstName: await decryptStr(cipher.identity.firstName || '', itemEnc, itemMac), - decMiddleName: await decryptStr(cipher.identity.middleName || '', itemEnc, itemMac), - decLastName: await decryptStr(cipher.identity.lastName || '', itemEnc, itemMac), - decUsername: await decryptStr(cipher.identity.username || '', itemEnc, itemMac), - decCompany: await decryptStr(cipher.identity.company || '', itemEnc, itemMac), - decSsn: await decryptStr(cipher.identity.ssn || '', itemEnc, itemMac), - decPassportNumber: await decryptStr(cipher.identity.passportNumber || '', itemEnc, itemMac), - decLicenseNumber: await decryptStr(cipher.identity.licenseNumber || '', itemEnc, itemMac), - decEmail: await decryptStr(cipher.identity.email || '', itemEnc, itemMac), - decPhone: await decryptStr(cipher.identity.phone || '', itemEnc, itemMac), - decAddress1: await decryptStr(cipher.identity.address1 || '', itemEnc, itemMac), - decAddress2: await decryptStr(cipher.identity.address2 || '', itemEnc, itemMac), - decAddress3: await decryptStr(cipher.identity.address3 || '', itemEnc, itemMac), - decCity: await decryptStr(cipher.identity.city || '', itemEnc, itemMac), - decState: await decryptStr(cipher.identity.state || '', itemEnc, itemMac), - decPostalCode: await decryptStr(cipher.identity.postalCode || '', itemEnc, itemMac), - decCountry: await decryptStr(cipher.identity.country || '', itemEnc, itemMac), + decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac), + decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac), + decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac), + decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac), + decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac), + decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac), + decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac), + decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac), + decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac), + decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac), + decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac), + decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac), + decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac), + decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac), + decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac), + decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac), + decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac), + decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac), }; } if (cipher.sshKey) { nextCipher.sshKey = { ...cipher.sshKey, - decPrivateKey: await decryptStr(cipher.sshKey.privateKey || '', itemEnc, itemMac), - decPublicKey: await decryptStr(cipher.sshKey.publicKey || '', itemEnc, itemMac), - decFingerprint: await decryptStr(cipher.sshKey.fingerprint || '', itemEnc, itemMac), + decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac), + decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac), + decFingerprint: await decryptField(cipher.sshKey.fingerprint || '', itemEnc, itemMac), }; } if (cipher.fields) { nextCipher.fields = await Promise.all( cipher.fields.map(async (field) => ({ ...field, - decName: await decryptStr(field.name || '', itemEnc, itemMac), - decValue: await decryptStr(field.value || '', itemEnc, itemMac), + decName: await decryptField(field.name || '', itemEnc, itemMac), + decValue: await decryptField(field.value || '', itemEnc, itemMac), })) ); } @@ -587,6 +594,22 @@ export default function App() { await verifyMasterPassword(authedFetch, derived.hash); } + async function createFolderAction(name: string) { + const folderName = name.trim(); + if (!folderName) { + pushToast('error', 'Folder name is required'); + return; + } + try { + await createFolder(authedFetch, folderName); + await foldersQuery.refetch(); + pushToast('success', 'Folder created'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Create folder failed'); + throw error; + } + } + useEffect(() => { if (phase === 'app' && location === '/') navigate('/vault'); }, [phase, location, navigate]); @@ -686,13 +709,6 @@ export default function App() { Support Center -
- -
@@ -710,6 +726,7 @@ export default function App() { onBulkMove={bulkMoveVaultItems} onVerifyMasterPassword={verifyMasterPasswordAction} onNotify={pushToast} + onCreateFolder={createFolderAction} /> diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 7d40592..b53dcc3 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useState } from 'preact/hooks'; +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import ConfirmDialog from '@/components/ConfirmDialog'; import { calcTotpNow } from '@/lib/crypto'; +import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh'; import { CheckCheck, Clipboard, @@ -10,6 +11,7 @@ import { ExternalLink, FileKey2, Folder as FolderIcon, + FolderPlus, FolderOpen, FolderX, FolderInput, @@ -41,6 +43,7 @@ interface VaultPageProps { onBulkMove: (ids: string[], folderId: string | null) => Promise; onVerifyMasterPassword: (email: string, password: string) => Promise; onNotify: (type: 'success' | 'error', text: string) => void; + onCreateFolder: (name: string) => Promise; } type TypeFilter = 'all' | 'favorite' | 'login' | 'card' | 'identity' | 'note' | 'ssh'; @@ -58,6 +61,15 @@ const CREATE_TYPE_OPTIONS: TypeOption[] = [ { type: 5, label: 'SSH Key' }, ]; +function CreateTypeIcon({ type }: { type: number }) { + if (type === 1) return ; + if (type === 3) return ; + if (type === 4) return ; + if (type === 2) return ; + if (type === 5) return ; + return ; +} + const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [ { value: 0, label: 'Text' }, { value: 1, label: 'Hidden' }, @@ -301,12 +313,17 @@ export default function VaultPage(props: VaultPageProps) { const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [moveOpen, setMoveOpen] = useState(false); const [moveFolderId, setMoveFolderId] = useState('__none__'); + const [createFolderOpen, setCreateFolderOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState>({}); const [busy, setBusy] = useState(false); const [repromptOpen, setRepromptOpen] = useState(false); const [repromptPassword, setRepromptPassword] = useState(''); const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState(null); + const createMenuRef = useRef(null); + const sshSeedTicketRef = useRef(0); + const sshFingerprintTicketRef = useRef(0); useEffect(() => { const onQuickAdd = () => { @@ -316,6 +333,25 @@ export default function VaultPage(props: VaultPageProps) { return () => window.removeEventListener('nodewarden:add-item', onQuickAdd); }, []); + useEffect(() => { + const onPointerDown = (event: Event) => { + if (!createMenuOpen) return; + const target = event.target as Node | null; + if (createMenuRef.current && target && !createMenuRef.current.contains(target)) { + setCreateMenuOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setCreateMenuOpen(false); + }; + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [createMenuOpen]); + useEffect(() => { setRepromptApprovedCipherId(null); setRepromptPassword(''); @@ -328,6 +364,11 @@ export default function VaultPage(props: VaultPageProps) { return () => window.clearTimeout(timer); }, [searchInput, searchComposing]); + useEffect(() => { + if (!isEditing || !draft || draft.type !== 5) return; + void recalculateSshFingerprint(draft.sshPublicKey); + }, [isEditing, draft?.id, draft?.type]); + const filteredCiphers = useMemo(() => { return props.ciphers.filter((cipher) => { if (!matchesTypeFilter(cipher, typeFilter)) return false; @@ -407,6 +448,7 @@ export default function VaultPage(props: VaultPageProps) { setSelectedCipherId(''); setShowPassword(false); setLocalError(''); + if (type === 5) void seedSshDefaults(); } function startEdit(): void { @@ -429,6 +471,46 @@ export default function VaultPage(props: VaultPageProps) { setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); } + async function seedSshDefaults(force = false): Promise { + const ticket = ++sshSeedTicketRef.current; + try { + const generated = await generateDefaultSshKeyMaterial(); + if (ticket !== sshSeedTicketRef.current) return; + setDraft((prev) => { + if (!prev || prev.type !== 5) return prev; + if (!force && (prev.sshPrivateKey.trim() || prev.sshPublicKey.trim())) return prev; + return { + ...prev, + sshPrivateKey: generated.privateKey, + sshPublicKey: generated.publicKey, + sshFingerprint: generated.fingerprint, + }; + }); + } catch { + // Browser may not support Ed25519 generation; user can still paste keys manually. + } + } + + async function recalculateSshFingerprint(publicKey: string): Promise { + const ticket = ++sshFingerprintTicketRef.current; + const fingerprint = await computeSshFingerprint(publicKey); + if (ticket !== sshFingerprintTicketRef.current) return; + setDraft((prev) => { + if (!prev || prev.type !== 5) return prev; + if (prev.sshPublicKey !== publicKey) return prev; + if (prev.sshFingerprint === fingerprint) return prev; + return { ...prev, sshFingerprint: fingerprint }; + }); + } + + function updateSshPublicKey(nextValue: string): void { + setDraft((prev) => { + if (!prev || prev.type !== 5) return prev; + return { ...prev, sshPublicKey: nextValue }; + }); + void recalculateSshFingerprint(nextValue); + } + function updateDraftCustomFields(nextFields: VaultDraftField[]): void { setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev)); } @@ -453,16 +535,24 @@ export default function VaultPage(props: VaultPageProps) { async function saveDraft(): Promise { if (!draft) return; - if (!draft.name.trim()) { + let nextDraft = draft; + if (nextDraft.type === 5) { + const computedFingerprint = await computeSshFingerprint(nextDraft.sshPublicKey); + if (computedFingerprint !== nextDraft.sshFingerprint) { + nextDraft = { ...nextDraft, sshFingerprint: computedFingerprint }; + setDraft(nextDraft); + } + } + if (!nextDraft.name.trim()) { setLocalError('Item name is required.'); return; } setBusy(true); try { if (isCreating) { - await props.onCreate(draft); + await props.onCreate(nextDraft); } else if (selectedCipher) { - await props.onUpdate(selectedCipher, draft); + await props.onUpdate(selectedCipher, nextDraft); } setIsCreating(false); setIsEditing(false); @@ -544,57 +634,62 @@ export default function VaultPage(props: VaultPageProps) { } } + async function confirmCreateFolder(): Promise { + if (!newFolderName.trim()) { + props.onNotify('error', 'Folder name is required'); + return; + } + setBusy(true); + try { + await props.onCreateFolder(newFolderName); + setCreateFolderOpen(false); + setNewFolderName(''); + } finally { + setBusy(false); + } + } + return ( <>
-
+
+ setSearchInput((e.currentTarget as HTMLInputElement).value)} + onCompositionStart={() => setSearchComposing(true)} + onCompositionEnd={(e) => { + setSearchComposing(false); + setSearchInput((e.currentTarget as HTMLInputElement).value); + }} + /> +
+
-
+
@@ -637,7 +748,8 @@ export default function VaultPage(props: VaultPageProps) {
{CREATE_TYPE_OPTIONS.map((option) => ( ))}
@@ -689,8 +801,8 @@ export default function VaultPage(props: VaultPageProps) {
- {cipher.decName || '(No Name)'} - {listSubtitle(cipher)} + {cipher.decName || '(No Name)'} + {listSubtitle(cipher)}
@@ -721,7 +833,11 @@ export default function VaultPage(props: VaultPageProps) { className="input" value={draft.type} disabled={!isCreating} - onInput={(e) => updateDraft({ type: Number((e.currentTarget as HTMLSelectElement).value) })} + onInput={(e) => { + const nextType = Number((e.currentTarget as HTMLSelectElement).value); + updateDraft({ type: nextType }); + if (nextType === 5) void seedSshDefaults(); + }} > {CREATE_TYPE_OPTIONS.map((option) => (