feat: add cryptographic utilities and types for secure data handling

This commit is contained in:
shuaiplus
2026-02-28 01:02:34 +08:00
committed by Shuai
parent 7c7d32de30
commit 5509492563
29 changed files with 5757 additions and 2786 deletions
+756
View File
@@ -0,0 +1,756 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Link, Route, Switch, useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import AuthViews from '@/components/AuthViews';
import ConfirmDialog from '@/components/ConfirmDialog';
import ToastHost from '@/components/ToastHost';
import VaultPage from '@/components/VaultPage';
import SettingsPage from '@/components/SettingsPage';
import AdminPage from '@/components/AdminPage';
import HelpPage from '@/components/HelpPage';
import {
changeMasterPassword,
createCipher,
createAuthedFetch,
createInvite,
deleteCipher,
deleteUser,
deriveLoginHash,
bulkMoveCiphers,
getCiphers,
getFolders,
getProfile,
getSetupStatus,
getWebConfig,
listAdminInvites,
listAdminUsers,
loadSession,
loginWithPassword,
registerAccount,
revokeInvite,
saveSession,
setTotp,
setUserStatus,
updateCipher,
unlockVaultKey,
updateProfile,
} from '@/lib/api';
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
interface PendingTotp {
email: string;
passwordHash: string;
masterKey: Uint8Array;
}
export default function App() {
const [location, navigate] = useLocation();
const [phase, setPhase] = useState<AppPhase>('loading');
const [session, setSessionState] = useState<SessionState | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [defaultKdfIterations, setDefaultKdfIterations] = useState(600000);
const [setupRegistered, setSetupRegistered] = useState(true);
const [loginValues, setLoginValues] = useState({ email: '', password: '' });
const [registerValues, setRegisterValues] = useState({
name: '',
email: '',
password: '',
password2: '',
inviteCode: '',
});
const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [totpCode, setTotpCode] = useState('');
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [confirm, setConfirm] = useState<{
title: string;
message: string;
danger?: boolean;
onConfirm: () => void;
} | null>(null);
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
function setSession(next: SessionState | null) {
setSessionState(next);
saveSession(next);
}
function pushToast(type: ToastMessage['type'], text: string) {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
window.setTimeout(() => {
setToasts((prev) => prev.filter((x) => x.id !== id));
}, 4500);
}
const authedFetch = useMemo(
() =>
createAuthedFetch(
() => session,
(next) => {
setSession(next);
if (!next) {
setProfile(null);
setPhase(setupRegistered ? 'login' : 'register');
}
}
),
[session, setupRegistered]
);
useEffect(() => {
let mounted = true;
(async () => {
const [setup, config] = await Promise.all([getSetupStatus(), getWebConfig()]);
if (!mounted) return;
setSetupRegistered(setup.registered);
setDefaultKdfIterations(Number(config.defaultKdfIterations || 600000));
const loaded = loadSession();
if (!loaded) {
setPhase(setup.registered ? 'login' : 'register');
return;
}
setSession(loaded);
try {
const profileResp = await getProfile(
createAuthedFetch(
() => loaded,
(next) => {
if (!next) return;
setSession(next);
}
)
);
if (!mounted) return;
setProfile(profileResp);
setPhase('locked');
} catch {
setSession(null);
setPhase(setup.registered ? 'login' : 'register');
}
})();
return () => {
mounted = false;
};
}, []);
async function finalizeLogin(tokenAccess: string, tokenRefresh: string, email: string, masterKey: Uint8Array) {
const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email };
const tempFetch = createAuthedFetch(
() => baseSession,
() => {}
);
const profileResp = await getProfile(tempFetch);
const keys = await unlockVaultKey(profileResp.key, masterKey);
const nextSession = { ...baseSession, ...keys };
setSession(nextSession);
setProfile(profileResp);
setPendingTotp(null);
setTotpCode('');
setPhase('app');
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
navigate('/vault');
}
pushToast('success', 'Login success');
}
async function handleLogin() {
if (!loginValues.email || !loginValues.password) {
pushToast('error', 'Please input email and password');
return;
}
try {
const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations);
const token = await loginWithPassword(loginValues.email, derived.hash);
if ('access_token' in token && token.access_token) {
await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey);
return;
}
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
if (tokenError.TwoFactorProviders) {
setPendingTotp({
email: loginValues.email.toLowerCase(),
passwordHash: derived.hash,
masterKey: derived.masterKey,
});
setTotpCode('');
return;
}
pushToast('error', tokenError.error_description || tokenError.error || 'Login failed');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Login failed');
}
}
async function handleTotpVerify() {
if (!pendingTotp) return;
if (!totpCode.trim()) {
pushToast('error', 'Please input TOTP code');
return;
}
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, totpCode.trim());
if ('access_token' in token && token.access_token) {
await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey);
return;
}
const tokenError = token as { error_description?: string; error?: string };
pushToast('error', tokenError.error_description || tokenError.error || 'TOTP verify failed');
}
async function handleRegister() {
if (!registerValues.email || !registerValues.password) {
pushToast('error', 'Please input email and password');
return;
}
if (registerValues.password.length < 12) {
pushToast('error', 'Master password must be at least 12 chars');
return;
}
if (registerValues.password !== registerValues.password2) {
pushToast('error', 'Passwords do not match');
return;
}
const resp = await registerAccount({
email: registerValues.email.toLowerCase(),
name: registerValues.name.trim(),
password: registerValues.password,
inviteCode: registerValues.inviteCode.trim(),
fallbackIterations: defaultKdfIterations,
});
if (!resp.ok) {
pushToast('error', resp.message);
return;
}
setLoginValues({ email: registerValues.email.toLowerCase(), password: '' });
setPhase('login');
pushToast('success', 'Registration succeeded. Please sign in.');
}
async function handleUnlock() {
if (!session || !profile) return;
if (!unlockPassword) {
pushToast('error', 'Please input master password');
return;
}
try {
const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations);
const keys = await unlockVaultKey(profile.key, derived.masterKey);
setSession({ ...session, ...keys });
setUnlockPassword('');
setPhase('app');
if (location === '/' || location === '/lock') navigate('/vault');
pushToast('success', 'Unlocked');
} catch {
pushToast('error', 'Unlock failed. Master password is incorrect.');
}
}
function handleLock() {
if (!session) return;
const nextSession = { ...session };
delete nextSession.symEncKey;
delete nextSession.symMacKey;
setSession(nextSession);
setPhase('locked');
navigate('/lock');
}
function handleLogout() {
setConfirm({
title: 'Log Out',
message: 'Are you sure you want to log out?',
onConfirm: () => {
setConfirm(null);
setSession(null);
setProfile(null);
setPendingTotp(null);
setPhase(setupRegistered ? 'login' : 'register');
navigate('/login');
},
});
}
const ciphersQuery = useQuery({
queryKey: ['ciphers', session?.accessToken],
queryFn: () => getCiphers(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
});
const foldersQuery = useQuery({
queryKey: ['folders', session?.accessToken],
queryFn: () => getFolders(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
});
const usersQuery = useQuery({
queryKey: ['admin-users', session?.accessToken],
queryFn: () => listAdminUsers(authedFetch),
enabled: phase === 'app' && profile?.role === 'admin',
});
const invitesQuery = useQuery({
queryKey: ['admin-invites', session?.accessToken],
queryFn: () => listAdminInvites(authedFetch),
enabled: phase === 'app' && profile?.role === 'admin',
});
useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey) {
setDecryptedFolders([]);
setDecryptedCiphers([]);
return;
}
if (!foldersQuery.data || !ciphersQuery.data) return;
let active = true;
(async () => {
try {
const encKey = base64ToBytes(session.symEncKey!);
const macKey = base64ToBytes(session.symMacKey!);
const folders = await Promise.all(
foldersQuery.data.map(async (folder) => ({
...folder,
decName: await decryptStr(folder.name, encKey, macKey),
}))
);
const ciphers = await Promise.all(
ciphersQuery.data.map(async (cipher) => {
let itemEnc = encKey;
let itemMac = macKey;
if (cipher.key) {
try {
const itemKey = await decryptBw(cipher.key, encKey, macKey);
itemEnc = itemKey.slice(0, 32);
itemMac = itemKey.slice(32, 64);
} catch {
// keep user key when item key decrypt fails
}
}
const nextCipher: Cipher = {
...cipher,
decName: await decryptStr(cipher.name || '', itemEnc, itemMac),
decNotes: await decryptStr(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),
uris: await Promise.all(
(cipher.login.uris || []).map(async (u) => ({
...u,
decUri: await decryptStr(u.uri || '', itemEnc, itemMac),
}))
),
};
}
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),
};
}
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),
};
}
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),
};
}
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),
}))
);
}
return nextCipher;
})
);
if (!active) return;
setDecryptedFolders(folders);
setDecryptedCiphers(ciphers);
} catch (error) {
if (!active) return;
pushToast('error', error instanceof Error ? error.message : 'Decrypt failed');
}
})();
return () => {
active = false;
};
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]);
async function saveProfileAction(name: string, email: string) {
try {
const updated = await updateProfile(authedFetch, { name: name.trim(), email: email.trim().toLowerCase() });
setProfile(updated);
pushToast('success', 'Profile updated');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Save profile failed');
}
}
async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) {
if (!profile) return;
if (!currentPassword || !nextPassword) {
pushToast('error', 'Current/new password is required');
return;
}
if (nextPassword.length < 12) {
pushToast('error', 'New password must be at least 12 chars');
return;
}
if (nextPassword !== nextPassword2) {
pushToast('error', 'New passwords do not match');
return;
}
try {
await changeMasterPassword(authedFetch, {
email: profile.email,
currentPassword,
newPassword: nextPassword,
currentIterations: defaultKdfIterations,
profileKey: profile.key,
});
handleLogout();
pushToast('success', 'Master password changed. Please login again.');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Change password failed');
}
}
async function enableTotpAction(secret: string, token: string) {
if (!secret.trim() || !token.trim()) {
pushToast('error', 'Secret and code are required');
return;
}
try {
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
pushToast('success', 'TOTP enabled');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Enable TOTP failed');
}
}
async function disableTotpAction() {
if (!profile) return;
if (!disableTotpPassword) {
pushToast('error', 'Please input master password');
return;
}
try {
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
setDisableTotpOpen(false);
setDisableTotpPassword('');
pushToast('success', 'TOTP disabled');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Disable TOTP failed');
}
}
async function refreshVault() {
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Vault synced');
}
async function createVaultItem(draft: VaultDraft) {
if (!session) return;
try {
await createCipher(authedFetch, session, draft);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Item created');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Create item failed');
throw error;
}
}
async function updateVaultItem(cipher: Cipher, draft: VaultDraft) {
if (!session) return;
try {
await updateCipher(authedFetch, session, cipher, draft);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Item updated');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Update item failed');
throw error;
}
}
async function deleteVaultItem(cipher: Cipher) {
try {
await deleteCipher(authedFetch, cipher.id);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Item deleted');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Delete item failed');
throw error;
}
}
async function bulkDeleteVaultItems(ids: string[]) {
try {
for (const id of ids) {
await deleteCipher(authedFetch, id);
}
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Deleted selected items');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Bulk delete failed');
throw error;
}
}
async function bulkMoveVaultItems(ids: string[], folderId: string | null) {
try {
await bulkMoveCiphers(authedFetch, ids, folderId);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Moved selected items');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Bulk move failed');
throw error;
}
}
useEffect(() => {
if (phase === 'app' && location === '/') navigate('/vault');
}, [phase, location, navigate]);
if (phase === 'loading') {
return (
<>
<div className="loading-screen">Loading NodeWarden...</div>
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
</>
);
}
if (phase === 'register' || phase === 'login' || phase === 'locked') {
return (
<>
<AuthViews
mode={phase}
loginValues={loginValues}
registerValues={registerValues}
unlockPassword={unlockPassword}
emailForLock={profile?.email || session?.email || ''}
onChangeLogin={setLoginValues}
onChangeRegister={setRegisterValues}
onChangeUnlock={setUnlockPassword}
onSubmitLogin={() => void handleLogin()}
onSubmitRegister={() => void handleRegister()}
onSubmitUnlock={() => void handleUnlock()}
onGotoLogin={() => setPhase('login')}
onGotoRegister={() => setPhase('register')}
onLogout={handleLogout}
/>
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
<ConfirmDialog
open={!!pendingTotp}
title="Two-step verification"
message="Password is already verified."
confirmText="Verify"
cancelText="Cancel"
onConfirm={() => void handleTotpVerify()}
onCancel={() => {
setPendingTotp(null);
setTotpCode('');
}}
>
<label className="field">
<span>TOTP Code</span>
<input className="input" value={totpCode} onInput={(e) => setTotpCode((e.currentTarget as HTMLInputElement).value)} />
</label>
</ConfirmDialog>
</>
);
}
return (
<>
<div className="app-shell">
<header className="topbar">
<div className="brand">NodeWarden</div>
<nav className="nav">
<Link href="/vault" className={`nav-link ${location === '/vault' ? 'active' : ''}`}>
Vault
</Link>
<Link href="/settings" className={`nav-link ${location === '/settings' ? 'active' : ''}`}>
Settings
</Link>
{profile?.role === 'admin' && (
<Link href="/admin" className={`nav-link ${location === '/admin' ? 'active' : ''}`}>
Admin
</Link>
)}
<Link href="/help" className={`nav-link ${location === '/help' ? 'active' : ''}`}>
Help
</Link>
</nav>
<div className="topbar-actions">
<span className="user-email">{profile?.email}</span>
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
Lock
</button>
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
Log Out
</button>
</div>
</header>
<main className="content">
<Switch>
<Route path="/vault">
<VaultPage
ciphers={decryptedCiphers}
folders={decryptedFolders}
loading={ciphersQuery.isFetching || foldersQuery.isFetching}
onRefresh={refreshVault}
onCreate={createVaultItem}
onUpdate={updateVaultItem}
onDelete={deleteVaultItem}
onBulkDelete={bulkDeleteVaultItems}
onBulkMove={bulkMoveVaultItems}
/>
</Route>
<Route path="/settings">
{profile && (
<SettingsPage
profile={profile}
onSaveProfile={saveProfileAction}
onChangePassword={changePasswordAction}
onEnableTotp={enableTotpAction}
onOpenDisableTotp={() => setDisableTotpOpen(true)}
/>
)}
</Route>
<Route path="/admin">
<AdminPage
users={usersQuery.data || []}
invites={invitesQuery.data || []}
onRefresh={() => {
void usersQuery.refetch();
void invitesQuery.refetch();
}}
onCreateInvite={async (hours) => {
await createInvite(authedFetch, hours);
await invitesQuery.refetch();
pushToast('success', 'Invite created');
}}
onToggleUserStatus={async (userId, status) => {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await usersQuery.refetch();
pushToast('success', 'User status updated');
}}
onDeleteUser={async (userId) => {
setConfirm({
title: 'Delete user',
message: 'Delete this user and all user data?',
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteUser(authedFetch, userId);
await usersQuery.refetch();
pushToast('success', 'User deleted');
})();
},
});
}}
onRevokeInvite={async (code) => {
await revokeInvite(authedFetch, code);
await invitesQuery.refetch();
pushToast('success', 'Invite revoked');
}}
/>
</Route>
<Route path="/help">
<HelpPage />
</Route>
</Switch>
</main>
</div>
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ''}
message={confirm?.message || ''}
danger={confirm?.danger}
onConfirm={() => confirm?.onConfirm()}
onCancel={() => setConfirm(null)}
/>
<ConfirmDialog
open={disableTotpOpen}
title="Disable TOTP"
message="Enter master password to disable two-step verification."
confirmText="Disable TOTP"
cancelText="Cancel"
danger
onConfirm={() => void disableTotpAction()}
onCancel={() => {
setDisableTotpOpen(false);
setDisableTotpPassword('');
}}
>
<label className="field">
<span>Master Password</span>
<input
className="input"
type="password"
value={disableTotpPassword}
onInput={(e) => setDisableTotpPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
</>
);
}