Security fixes: swarm signature verification and error handling

This commit is contained in:
Clawd Deploy Bot
2026-01-30 16:50:49 +01:00
parent 495a037eb1
commit 50355b740a
21 changed files with 850 additions and 467 deletions
+34 -13
View File
@@ -2,12 +2,15 @@
* Swarm Announce Endpoint * Swarm Announce Endpoint
* *
* POST: Receive announcements from other nodes joining the swarm * POST: Receive announcements from other nodes joining the swarm
*
* SECURITY: All requests must be cryptographically signed by the sender node.
*/ */
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import { upsertSwarmNode } from '@/lib/swarm/registry'; import { upsertSwarmNode } from '@/lib/swarm/registry';
import { buildAnnouncement } from '@/lib/swarm/discovery'; import { buildAnnouncement } from '@/lib/swarm/discovery';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import type { SwarmNodeInfo } from '@/lib/swarm/types'; import type { SwarmNodeInfo } from '@/lib/swarm/types';
const announcementSchema = z.object({ const announcementSchema = z.object({
@@ -21,7 +24,11 @@ const announcementSchema = z.object({
postCount: z.number().optional(), postCount: z.number().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(), capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
timestamp: z.string().optional(), timestamp: z.string().optional(),
signature: z.string().optional(), });
// Schema including signature for verification
const signedAnnouncementSchema = announcementSchema.extend({
signature: z.string(),
}); });
/** /**
@@ -29,39 +36,53 @@ const announcementSchema = z.object({
* *
* Receives an announcement from another node and responds with our info. * Receives an announcement from another node and responds with our info.
* This is how nodes introduce themselves to the swarm. * This is how nodes introduce themselves to the swarm.
*
* SECURITY: All announcement requests must be signed by the sender node.
*/ */
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const announcement = announcementSchema.parse(body); const data = signedAnnouncementSchema.parse(body);
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
// Don't process announcements from ourselves // Don't process announcements from ourselves
if (announcement.domain === ourDomain) { if (data.domain === ourDomain) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cannot announce to self' }, { error: 'Cannot announce to self' },
{ status: 400 } { status: 400 }
); );
} }
// SECURITY: Verify the node signature before processing
const { signature, ...payload } = data;
const isValid = await verifySwarmRequest(payload, signature, data.domain);
if (!isValid) {
console.warn(`[Swarm] Invalid signature for announcement from ${data.domain}`);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 403 }
);
}
// Add/update the announcing node in our registry // Add/update the announcing node in our registry
const nodeInfo: SwarmNodeInfo = { const nodeInfo: SwarmNodeInfo = {
domain: announcement.domain, domain: data.domain,
name: announcement.name, name: data.name,
description: announcement.description, description: data.description,
logoUrl: announcement.logoUrl, logoUrl: data.logoUrl,
publicKey: announcement.publicKey, publicKey: data.publicKey,
softwareVersion: announcement.softwareVersion, softwareVersion: data.softwareVersion,
userCount: announcement.userCount, userCount: data.userCount,
postCount: announcement.postCount, postCount: data.postCount,
capabilities: announcement.capabilities, capabilities: data.capabilities,
lastSeenAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(),
}; };
const { isNew } = await upsertSwarmNode(nodeInfo, 'announcement'); const { isNew } = await upsertSwarmNode(nodeInfo, 'announcement');
console.log(`[Swarm] ${isNew ? 'New' : 'Known'} node announced: ${announcement.domain}`); console.log(`[Swarm] ${isNew ? 'New' : 'Known'} node announced: ${data.domain}`);
// Respond with our own info // Respond with our own info
const ourAnnouncement = await buildAnnouncement(); const ourAnnouncement = await buildAnnouncement();
@@ -54,6 +54,7 @@ export async function GET(request: NextRequest) {
handle: participant2Handle, handle: participant2Handle,
displayName: participant2Handle, displayName: participant2Handle,
avatarUrl: null as string | null, avatarUrl: null as string | null,
did: '' as string,
}; };
// Try to get cached user info // Try to get cached user info
@@ -103,6 +104,7 @@ export async function GET(request: NextRequest) {
handle: cachedUser.handle, handle: cachedUser.handle,
displayName: (cachedUser as any).displayName || cachedUser.handle, displayName: (cachedUser as any).displayName || cachedUser.handle,
avatarUrl: (cachedUser as any).avatarUrl || null, avatarUrl: (cachedUser as any).avatarUrl || null,
did: (cachedUser as any).did || '',
}; };
} }
+28 -6
View File
@@ -2,12 +2,15 @@
* Swarm Gossip Endpoint * Swarm Gossip Endpoint
* *
* POST: Exchange node and handle information with other nodes * POST: Exchange node and handle information with other nodes
*
* SECURITY: All requests must be cryptographically signed by the sender node.
*/ */
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import { processGossip } from '@/lib/swarm/gossip'; import { processGossip } from '@/lib/swarm/gossip';
import { markNodeSuccess } from '@/lib/swarm/registry'; import { markNodeSuccess } from '@/lib/swarm/registry';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import type { SwarmGossipPayload } from '@/lib/swarm/types'; import type { SwarmGossipPayload } from '@/lib/swarm/types';
const handleSchema = z.object({ const handleSchema = z.object({
@@ -38,36 +41,55 @@ const gossipPayloadSchema = z.object({
since: z.string().optional(), since: z.string().optional(),
}); });
// Schema including signature for verification
const signedGossipSchema = gossipPayloadSchema.extend({
signature: z.string(),
});
/** /**
* POST /api/swarm/gossip * POST /api/swarm/gossip
* *
* Receives gossip from another node and responds with our own data. * Receives gossip from another node and responds with our own data.
* This is the core of the epidemic protocol - nodes exchange what they know. * This is the core of the epidemic protocol - nodes exchange what they know.
*
* SECURITY: All gossip requests must be signed by the sender node.
*/ */
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const payload = gossipPayloadSchema.parse(body) as SwarmGossipPayload; const data = signedGossipSchema.parse(body);
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
// Don't process gossip from ourselves // Don't process gossip from ourselves
if (payload.sender === ourDomain) { if (data.sender === ourDomain) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cannot gossip with self' }, { error: 'Cannot gossip with self' },
{ status: 400 } { status: 400 }
); );
} }
console.log(`[Swarm] Gossip from ${payload.sender}: ${payload.nodes.length} nodes, ${payload.handles?.length || 0} handles`); // SECURITY: Verify the node signature before processing
const { signature, ...payload } = data;
const isValid = await verifySwarmRequest(payload, signature, data.sender);
if (!isValid) {
console.warn(`[Swarm] Invalid signature for gossip from ${data.sender}`);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 403 }
);
}
console.log(`[Swarm] Gossip from ${data.sender}: ${data.nodes.length} nodes, ${data.handles?.length || 0} handles`);
// Process the incoming gossip and build our response // Process the incoming gossip and build our response
const response = await processGossip(payload); const response = await processGossip(payload as SwarmGossipPayload);
// Mark the sender as successfully contacted // Mark the sender as successfully contacted
await markNodeSuccess(payload.sender); await markNodeSuccess(data.sender);
console.log(`[Swarm] Gossip response to ${payload.sender}: ${response.nodes.length} nodes, ${response.handles?.length || 0} handles`); console.log(`[Swarm] Gossip response to ${data.sender}: ${response.nodes.length} nodes, ${response.handles?.length || 0} handles`);
return NextResponse.json(response); return NextResponse.json(response);
} catch (error) { } catch (error) {
-1
View File
@@ -1,4 +1,3 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
+28 -7
View File
@@ -1,13 +1,13 @@
'use client'; 'use client';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { IdentityLockScreen } from '@/components/IdentityLockScreen';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
export default function SettingsLayout({ children }: { children: React.ReactNode }) { export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const { isIdentityUnlocked, loading } = useAuth(); const { isIdentityUnlocked, isRestoring, loading } = useAuth();
if (loading) { // Show loading while restoring or initial load
if (loading || isRestoring) {
return ( return (
<div style={{ <div style={{
minHeight: '60vh', minHeight: '60vh',
@@ -21,12 +21,33 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
); );
} }
// If not unlocked after restoration, user needs to re-login
// (This is rare - only happens if browser was closed or session expired)
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
return ( return (
<IdentityLockScreen <div style={{
title="Settings Locked" minHeight: '60vh',
description="To view or change your settings, you must unlock your identity. Your private keys are required to sign any changes you make." display: 'flex',
/> flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '16px',
padding: '24px',
textAlign: 'center'
}}>
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>
Session Expired
</h2>
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '400px' }}>
Your session has expired. Please log out and log back in to access settings.
</p>
<button
onClick={() => window.location.href = '/login'}
className="btn btn-primary"
>
Go to Login
</button>
</div>
); );
} }
+2 -8
View File
@@ -14,7 +14,7 @@ export default function PrivacySettingsPage() {
const [dmPrivacy, setDmPrivacy] = useState<'everyone' | 'following' | 'none'>('everyone'); const [dmPrivacy, setDmPrivacy] = useState<'everyone' | 'following' | 'none'>('everyone');
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null); const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth(); const { isIdentityUnlocked, signUserAction } = useAuth();
useEffect(() => { useEffect(() => {
fetch('/api/auth/me') fetch('/api/auth/me')
@@ -30,9 +30,8 @@ export default function PrivacySettingsPage() {
}, []); }, []);
const handleSave = async (newValue: 'everyone' | 'following' | 'none') => { const handleSave = async (newValue: 'everyone' | 'following' | 'none') => {
// If identity is locked, prompt to unlock and return
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
setShowUnlockPrompt(true); setStatus({ type: 'error', message: 'Session expired. Please log in again.' });
return; return;
} }
@@ -55,11 +54,6 @@ export default function PrivacySettingsPage() {
} else { } else {
const data = await res.json(); const data = await res.json();
setStatus({ type: 'error', message: data.error || 'Failed to save settings' }); setStatus({ type: 'error', message: data.error || 'Failed to save settings' });
// If error due to identity lock
if (data.error === 'Invalid signature or identity') {
setShowUnlockPrompt(true);
}
} }
} catch (error) { } catch (error) {
setStatus({ type: 'error', message: 'An error occurred' }); setStatus({ type: 'error', message: 'An error occurred' });
+3 -10
View File
@@ -13,7 +13,7 @@ export default function SecuritySettingsPage() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth(); const { isIdentityUnlocked, signUserAction } = useAuth();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -36,11 +36,9 @@ export default function SecuritySettingsPage() {
return; return;
} }
// If identity is locked, prompt to unlock and return // With persistence, identity should be unlocked
// Note: It seems redundant since they entered the password,
// but this ensures the KEY is loaded in memory.
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
setShowUnlockPrompt(true); setError('Your session has expired. Please log in again.');
return; return;
} }
@@ -60,11 +58,6 @@ export default function SecuritySettingsPage() {
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
// If error due to identity lock
if (data.error === 'Invalid signature or identity' || data.error === 'User not found') {
setShowUnlockPrompt(true);
throw new Error('Identity verification failed. Please unlock your identity.');
}
throw new Error(data.error || 'Failed to change password'); throw new Error(data.error || 'Failed to change password');
} }
+8 -30
View File
@@ -79,7 +79,7 @@ export default function ProfilePage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const handle = (params.handle as string)?.replace(/^@/, '') || ''; const handle = (params.handle as string)?.replace(/^@/, '') || '';
const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth(); const { isIdentityUnlocked, signUserAction } = useAuth();
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = useState<Post[]>([]); const [posts, setPosts] = useState<Post[]>([]);
@@ -142,8 +142,7 @@ export default function ProfilePage() {
// 3. Auto-save the profile change // 3. Auto-save the profile change
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
setShowUnlockPrompt(true); throw new Error('Session expired. Please log in again.');
throw new Error('Please unlock your identity to save the changes.');
} }
// Create partial update payload // Create partial update payload
@@ -161,11 +160,6 @@ export default function ProfilePage() {
const saveData = await saveRes.json(); const saveData = await saveRes.json();
if (!saveRes.ok) { if (!saveRes.ok) {
// If error due to identity lock
if (saveData.error === 'Invalid signature or identity') {
setShowUnlockPrompt(true);
throw new Error('Identity verification failed. Please try again after unlocking.');
}
throw new Error(saveData.error || 'Failed to update profile'); throw new Error(saveData.error || 'Failed to update profile');
} }
@@ -374,7 +368,7 @@ export default function ProfilePage() {
if (!currentUser) return; if (!currentUser) return;
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
setShowUnlockPrompt(true, () => handleFollow()); alert('Session expired. Please log in again.');
return; return;
} }
@@ -394,7 +388,7 @@ export default function ProfilePage() {
if (!currentUser) return; if (!currentUser) return;
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
setShowUnlockPrompt(true, () => handleBlock()); alert('Session expired. Please log in again.');
return; return;
} }
@@ -414,9 +408,8 @@ export default function ProfilePage() {
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
if (!isOwnProfile) return; if (!isOwnProfile) return;
// If identity is locked, prompt to unlock and return
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
setShowUnlockPrompt(true); setSaveError('Session expired. Please log in again.');
return; return;
} }
@@ -443,14 +436,7 @@ export default function ProfilePage() {
setIsEditing(false); setIsEditing(false);
} catch (error) { } catch (error) {
console.error('Profile update failed', error); console.error('Profile update failed', error);
setSaveError(error instanceof Error && error.message.includes('Identity locked') setSaveError(error instanceof Error ? error.message : 'Unable to update profile. Please try again.');
? 'Please unlock your identity to save changes.'
: 'Unable to update profile. Please try again.');
// If the error was due to lock state (race condition), prompt unlock
if (error instanceof Error && error.message.includes('Identity locked')) {
setShowUnlockPrompt(true);
}
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -574,11 +560,7 @@ export default function ProfilePage() {
}} }}
onClick={() => { onClick={() => {
if (isEditing) { if (isEditing) {
if (!isIdentityUnlocked) { headerInputRef.current?.click();
setShowUnlockPrompt(true);
} else {
headerInputRef.current?.click();
}
} }
}} }}
> >
@@ -624,11 +606,7 @@ export default function ProfilePage() {
}} }}
onClick={() => { onClick={() => {
if (isEditing) { if (isEditing) {
if (!isIdentityUnlocked) { avatarInputRef.current?.click();
setShowUnlockPrompt(true);
} else {
avatarInputRef.current?.click();
}
} }
}} }}
> >
+3 -8
View File
@@ -21,7 +21,7 @@ interface ComposeProps {
} }
export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) { export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) {
const { isIdentityUnlocked, setShowUnlockPrompt } = useAuth(); const { isIdentityUnlocked } = useAuth();
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [isPosting, setIsPosting] = useState(false); const [isPosting, setIsPosting] = useState(false);
const [attachments, setAttachments] = useState<MediaAttachment[]>([]); const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
@@ -92,8 +92,9 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
const handleSubmit = async () => { const handleSubmit = async () => {
if (!content.trim() || isPosting || isUploading) return; if (!content.trim() || isPosting || isUploading) return;
// With persistence, identity should be unlocked. If not, user needs to re-login
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
setShowUnlockPrompt(true, () => handleSubmit()); alert('Your session has expired. Please log in again.');
return; return;
} }
@@ -248,12 +249,6 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
<label <label
className="compose-media-button" className="compose-media-button"
title="Add media" title="Add media"
onClick={(e) => {
if (!isIdentityUnlocked) {
e.preventDefault();
setShowUnlockPrompt(true);
}
}}
> >
{isUploading ? '...' : <ImageIcon size={20} />} {isUploading ? '...' : <ImageIcon size={20} />}
<input <input
+5 -9
View File
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { Lock, Shield } from 'lucide-react'; import { Lock } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
interface IdentityLockScreenProps { interface IdentityLockScreenProps {
title?: string; title?: string;
@@ -10,12 +9,10 @@ interface IdentityLockScreenProps {
} }
export function IdentityLockScreen({ export function IdentityLockScreen({
title = 'Identity Required', title = 'Session Expired',
description = 'Accessing settings requires your identity to be unlocked. Your private keys are used to sign changes to prove they came from you.', description = 'Your session has expired. Please log in again to continue.',
icon icon
}: IdentityLockScreenProps) { }: IdentityLockScreenProps) {
const { setShowUnlockPrompt } = useAuth();
return ( return (
<div style={{ <div style={{
display: 'flex', display: 'flex',
@@ -41,12 +38,11 @@ export function IdentityLockScreen({
</p> </p>
<button <button
onClick={() => setShowUnlockPrompt(true)} onClick={() => window.location.href = '/login'}
className="btn btn-primary" className="btn btn-primary"
style={{ display: 'flex', alignItems: 'center', gap: '8px' }} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
> >
<Shield size={16} /> Go to Login
Unlock Identity
</button> </button>
</div> </div>
); );
+1 -9
View File
@@ -4,10 +4,9 @@ import { usePathname } from 'next/navigation';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { RightSidebar } from './RightSidebar'; import { RightSidebar } from './RightSidebar';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { IdentityUnlockPrompt } from './IdentityUnlockPrompt';
export function LayoutWrapper({ children }: { children: React.ReactNode }) { export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const { loading, showUnlockPrompt, setShowUnlockPrompt } = useAuth(); const { loading } = useAuth();
const pathname = usePathname(); const pathname = usePathname();
// Paths that should NOT have the app layout // Paths that should NOT have the app layout
@@ -58,13 +57,6 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
</main> </main>
{!hideRightSidebar && <RightSidebar />} {!hideRightSidebar && <RightSidebar />}
{/* Global Identity Unlock Prompt */}
{showUnlockPrompt && (
<IdentityUnlockPrompt
onUnlock={() => setShowUnlockPrompt(false)}
onCancel={() => setShowUnlockPrompt(false)}
/>
)}
</div> </div>
); );
} }
+3 -3
View File
@@ -44,7 +44,7 @@ interface PostCardProps {
} }
export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, parentPostAuthorId }: PostCardProps) { export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, parentPostAuthorId }: PostCardProps) {
const { user: currentUser, did, handle: currentUserHandle, isIdentityUnlocked, setShowUnlockPrompt } = useAuth(); const { user: currentUser, did, handle: currentUserHandle, isIdentityUnlocked } = useAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const router = useRouter(); const router = useRouter();
const [liked, setLiked] = useState(post.isLiked || false); const [liked, setLiked] = useState(post.isLiked || false);
@@ -91,7 +91,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
e.stopPropagation(); e.stopPropagation();
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
setShowUnlockPrompt(true, () => handleLike(e)); showToast('Please log in to like posts', 'error');
return; return;
} }
@@ -105,7 +105,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
e.stopPropagation(); e.stopPropagation();
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
setShowUnlockPrompt(true, () => handleRepost(e)); showToast('Please log in to repost', 'error');
return; return;
} }
+47 -63
View File
@@ -7,11 +7,11 @@ import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons'; import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
import { formatFullHandle } from '@/lib/utils/handle'; import { formatFullHandle } from '@/lib/utils/handle';
import { LogOut, Settings2, Lock, Unlock } from 'lucide-react'; import { LogOut, Settings2, Unlock } from 'lucide-react';
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper // import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
export function Sidebar() { export function Sidebar() {
const { user, isAdmin, logout, isIdentityUnlocked, setShowUnlockPrompt } = useAuth(); const { user, isAdmin, logout, isIdentityUnlocked, lockIdentity } = useAuth();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined); const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
@@ -192,70 +192,54 @@ export function Sidebar() {
</div> </div>
</div> </div>
{/* Identity Status Indicator */} {/* Identity Status - Only show when unlocked with option to lock */}
<div {isIdentityUnlocked && (
onClick={() => { <div
if (!isIdentityUnlocked) { onClick={() => lockIdentity()}
setShowUnlockPrompt(true); title="Click to lock your identity"
} style={{
}} display: 'flex',
title={isIdentityUnlocked ? 'Identity unlocked - you can perform actions' : 'Identity locked - click to unlock'} alignItems: 'center',
style={{ gap: '8px',
display: 'flex', padding: '10px 12px',
alignItems: 'center', marginBottom: '12px',
gap: '8px', borderRadius: '8px',
padding: '10px 12px', background: 'rgba(34, 197, 94, 0.1)',
marginBottom: '12px', border: '1px solid rgba(34, 197, 94, 0.2)',
borderRadius: '8px', cursor: 'pointer',
background: isIdentityUnlocked transition: 'all 0.2s',
? 'rgba(34, 197, 94, 0.1)' }}
: 'rgba(251, 191, 36, 0.1)', onMouseEnter={(e) => {
border: isIdentityUnlocked e.currentTarget.style.background = 'rgba(34, 197, 94, 0.15)';
? '1px solid rgba(34, 197, 94, 0.2)' }}
: '1px solid rgba(251, 191, 36, 0.2)', onMouseLeave={(e) => {
cursor: isIdentityUnlocked ? 'default' : 'pointer', e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)';
transition: 'all 0.2s', }}
}} >
onMouseEnter={(e) => {
if (!isIdentityUnlocked) {
e.currentTarget.style.background = 'rgba(251, 191, 36, 0.15)';
}
}}
onMouseLeave={(e) => {
if (!isIdentityUnlocked) {
e.currentTarget.style.background = 'rgba(251, 191, 36, 0.1)';
}
}}
>
{isIdentityUnlocked ? (
<Unlock size={16} style={{ color: 'rgb(34, 197, 94)', flexShrink: 0 }} /> <Unlock size={16} style={{ color: 'rgb(34, 197, 94)', flexShrink: 0 }} />
) : ( <div style={{ flex: 1, minWidth: 0 }}>
<Lock size={16} style={{ color: 'rgb(251, 191, 36)', flexShrink: 0 }} /> <div style={{
)} fontSize: '13px',
<div style={{ flex: 1, minWidth: 0 }}> fontWeight: 500,
<div style={{ color: 'rgb(34, 197, 94)',
fontSize: '13px', overflow: 'hidden',
fontWeight: 500, textOverflow: 'ellipsis',
color: isIdentityUnlocked ? 'rgb(34, 197, 94)' : 'rgb(251, 191, 36)', whiteSpace: 'nowrap'
overflow: 'hidden', }}>
textOverflow: 'ellipsis', Identity Unlocked
whiteSpace: 'nowrap' </div>
}}> <div style={{
{isIdentityUnlocked ? 'Identity Unlocked' : 'Identity Locked'} fontSize: '11px',
</div> color: 'var(--foreground-tertiary)',
<div style={{ overflow: 'hidden',
fontSize: '11px', textOverflow: 'ellipsis',
color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap'
overflow: 'hidden', }}>
textOverflow: 'ellipsis', Click to lock
whiteSpace: 'nowrap' </div>
}}>
{isIdentityUnlocked
? 'You can perform actions'
: 'Click to unlock'}
</div> </div>
</div> </div>
</div> )}
<button <button
onClick={handleLogout} onClick={handleLogout}
+54 -64
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { useUserIdentity } from '@/lib/hooks/useUserIdentity'; import { useUserIdentity } from '@/lib/hooks/useUserIdentity';
export interface User { export interface User {
@@ -18,15 +18,18 @@ interface AuthContextType {
isAdmin: boolean; isAdmin: boolean;
loading: boolean; loading: boolean;
isIdentityUnlocked: boolean; isIdentityUnlocked: boolean;
isRestoring: boolean; // True while checking persistence
did: string | null; did: string | null;
handle: string | null; handle: string | null;
checkAdmin: () => Promise<void>; checkAdmin: () => Promise<void>;
unlockIdentity: (password: string, explicitUser?: User) => Promise<void>; unlockIdentity: (password: string, explicitUser?: User) => Promise<void>;
login: (user: User) => void; login: (user: User) => void;
logout: () => Promise<void>; logout: () => Promise<void>;
showUnlockPrompt: boolean; lockIdentity: () => Promise<void>; // New: manual lock
setShowUnlockPrompt: (show: boolean, onSuccess?: () => void) => void;
signUserAction: (action: string, data: any) => Promise<any>; signUserAction: (action: string, data: any) => Promise<any>;
requiresUnlock: boolean; // True if user has encrypted key but not unlocked
showUnlockPrompt: boolean;
setShowUnlockPrompt: (show: boolean) => void;
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
@@ -34,33 +37,39 @@ const AuthContext = createContext<AuthContextType>({
isAdmin: false, isAdmin: false,
loading: true, loading: true,
isIdentityUnlocked: false, isIdentityUnlocked: false,
isRestoring: false,
did: null, did: null,
handle: null, handle: null,
checkAdmin: async () => { }, checkAdmin: async () => { },
unlockIdentity: async () => { }, unlockIdentity: async () => { },
login: () => { }, login: () => { },
logout: async () => { }, logout: async () => { },
lockIdentity: async () => { },
signUserAction: async () => Promise.reject('Not initialized'),
requiresUnlock: false,
showUnlockPrompt: false, showUnlockPrompt: false,
setShowUnlockPrompt: () => { }, setShowUnlockPrompt: () => { },
signUserAction: async () => Promise.reject('Not initialized'),
}); });
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
// Integrate useUserIdentity hook // Integrate useUserIdentity hook with persistence
const { const {
identity, identity,
isUnlocked, isUnlocked,
isRestoring,
initializeIdentity, initializeIdentity,
unlockIdentity: unlockIdentityHook, unlockIdentity: unlockIdentityHook,
lockIdentity: lockIdentityHook,
clearIdentity, clearIdentity,
signUserAction, signUserAction,
} = useUserIdentity(); } = useUserIdentity();
const checkAdmin = async () => { const checkAdmin = useCallback(async () => {
try { try {
const res = await fetch('/api/admin/me'); const res = await fetch('/api/admin/me');
const data = await res.json(); const data = await res.json();
@@ -68,39 +77,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} catch { } catch {
setIsAdmin(false); setIsAdmin(false);
} }
}; }, []);
const [showUnlockPrompt, _setShowUnlockPrompt] = useState(false);
const [onUnlockCallback, setOnUnlockCallback] = useState<(() => void) | null>(null);
const setShowUnlockPrompt = (show: boolean, onSuccess?: () => void) => {
_setShowUnlockPrompt(show);
if (show && onSuccess) {
setOnUnlockCallback(() => onSuccess);
} else if (!show) {
// If hiding without success (cancel), clear callback ???
// Actually unlockIdentity handles success case.
// If explicit hide (cancel), we should probably clear it.
// But unlockIdentity calls setShowUnlockPrompt(false) on success too.
// So we handle callback execution in unlockIdentity,
// and clearing in unlockIdentity OR here if it wasn't executed?
// Let's rely on unlockIdentity to execute and clear.
// If just closing dialog (cancel), we clear it.
// But we don't know if this call is from cancel or success?
// unlockIdentity calls this.
}
};
// Clear callback on close if it wasn't executed?
// It's safer to clear it when closing prompt to avoid stale callbacks.
// But unlockIdentity calls setShowUnlockPrompt(false) AFTER executing.
// So:
/** /**
* Unlock the user's identity with their password * Unlock the user's identity with their password
* Persists the key for auto-unlock on refresh
*/ */
const unlockIdentity = async (password: string, explicitUser?: User) => { const unlockIdentity = useCallback(async (password: string, explicitUser?: User) => {
const targetUser = explicitUser || user; const targetUser = explicitUser || user;
if (!targetUser?.privateKeyEncrypted) { if (!targetUser?.privateKeyEncrypted) {
@@ -115,50 +98,51 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
targetUser.publicKey targetUser.publicKey
); );
// Execute queued callback if exists
if (onUnlockCallback) {
try {
onUnlockCallback();
} catch (e) {
console.error('Error executing unlock callback:', e);
}
setOnUnlockCallback(null);
}
setShowUnlockPrompt(false); // Close prompt on success setShowUnlockPrompt(false); // Close prompt on success
}; }, [user, unlockIdentityHook]);
/**
* Manually lock the identity (user wants to secure their session)
*/
const lockIdentity = useCallback(async () => {
await lockIdentityHook();
}, [lockIdentityHook]);
/** /**
* Manually set the user state (called after successful login) * Manually set the user state (called after successful login)
*/ */
const login = (userData: User) => { const login = useCallback((userData: User) => {
setUser(userData); setUser(userData);
// We re-check admin status just in case
checkAdmin(); checkAdmin();
};
// Initialize identity - will try to auto-restore if possible
if (userData.did && userData.publicKey) {
initializeIdentity({
did: userData.did,
handle: userData.handle,
publicKey: userData.publicKey,
privateKeyEncrypted: userData.privateKeyEncrypted,
});
}
}, [checkAdmin, initializeIdentity]);
/** /**
* Logout the user and clear their identity * Logout the user and clear their identity
*/ */
const logout = async () => { const logout = useCallback(async () => {
try { try {
// Call the logout API endpoint
await fetch('/api/auth/logout', { method: 'POST' }); await fetch('/api/auth/logout', { method: 'POST' });
await clearIdentity();
// Clear the user's identity (private key from localStorage)
clearIdentity();
setShowUnlockPrompt(false); setShowUnlockPrompt(false);
setOnUnlockCallback(null);
// Clear the user state
setUser(null); setUser(null);
setIsAdmin(false); setIsAdmin(false);
} catch (error) { } catch (error) {
console.error('[Auth] Logout failed:', error); console.error('[Auth] Logout failed:', error);
throw error; throw error;
} }
}; }, [clearIdentity]);
// Load auth state on mount
useEffect(() => { useEffect(() => {
const loadAuth = async () => { const loadAuth = async () => {
setLoading(true); setLoading(true);
@@ -168,8 +152,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const data = await res.json(); const data = await res.json();
setUser(data.user); setUser(data.user);
// Initialize identity if we have the required data // Initialize identity - will auto-restore if persisted
if (data.user?.did && data.user?.publicKey && data.user?.privateKeyEncrypted) { if (data.user?.did && data.user?.publicKey) {
await initializeIdentity({ await initializeIdentity({
did: data.user.did, did: data.user.did,
handle: data.user.handle, handle: data.user.handle,
@@ -183,18 +167,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
} else { } else {
setUser(null); setUser(null);
clearIdentity(); await clearIdentity();
} }
} catch { } catch {
setUser(null); setUser(null);
clearIdentity(); await clearIdentity();
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
loadAuth(); loadAuth();
}, []); }, [checkAdmin, initializeIdentity, clearIdentity]);
// Determine if unlock is required (has encrypted key but not unlocked)
const requiresUnlock = !!user?.privateKeyEncrypted && !isUnlocked && !isRestoring;
return ( return (
<AuthContext.Provider value={{ <AuthContext.Provider value={{
@@ -202,15 +189,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
isAdmin, isAdmin,
loading, loading,
isIdentityUnlocked: isUnlocked, isIdentityUnlocked: isUnlocked,
isRestoring,
did: identity?.did || null, did: identity?.did || null,
handle: identity?.handle || null, handle: identity?.handle || null,
checkAdmin, checkAdmin,
unlockIdentity, unlockIdentity,
login, login,
logout, logout,
lockIdentity,
signUserAction,
requiresUnlock,
showUnlockPrompt, showUnlockPrompt,
setShowUnlockPrompt, setShowUnlockPrompt,
signUserAction,
}}> }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
+342
View File
@@ -0,0 +1,342 @@
/**
* Secure Key Persistence
*
* Stores the encrypted private key in IndexedDB so the user stays unlocked
* across page refreshes and tabs. The key is wrapped with a session key
* that's stored in localStorage (cleared on browser close/logout).
*
* Security model:
* - Private key is ALWAYS encrypted at rest (IndexedDB)
* - Session key in localStorage is needed to unwrap
* - XSS attacker needs BOTH storage access AND the session key
* - On logout, both are cleared
*/
import { deserializeEncryptedKey, type EncryptedPrivateKey } from './private-key-client';
const DB_NAME = 'synapsis-identity';
const DB_VERSION = 1;
const STORE_NAME = 'keys';
const SESSION_KEY_ITEM = 'synapsis_session_key';
const WRAPPED_KEY_ITEM = 'synapsis_wrapped_key';
interface WrappedKey {
wrapped: string; // Base64 of wrapped key
iv: string; // Base64 of IV
salt: string; // Base64 of salt
createdAt: number; // Timestamp for expiry
}
interface SessionData {
key: string; // Base64 of session encryption key
createdAt: number;
}
// ============================================
// IndexedDB Operations
// ============================================
async function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
});
}
async function storeInDB(key: string, value: any): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.put(value, key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async function getFromDB<T>(key: string): Promise<T | null> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(key);
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error);
});
}
async function removeFromDB(key: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// ============================================
// Crypto Operations
// ============================================
/**
* Derive a session key from the user's password
* This is fast and deterministic - same password = same session key
*/
export async function deriveSessionKey(password: string): Promise<CryptoKey> {
const encoder = new TextEncoder();
const passwordData = encoder.encode(password);
// Import password as key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordData,
'PBKDF2',
false,
['deriveKey']
);
// Derive a session key - using a fixed salt since we want deterministic output
// The salt is public knowledge anyway (stored with wrapped key)
const fixedSalt = encoder.encode('synapsis-session-v1');
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: fixedSalt,
iterations: 10000, // Lower than main encryption since this is session-only
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true, // extractable so we can store it
['wrapKey', 'unwrapKey']
);
}
/**
* Generate a random session key (for when we already have the decrypted key)
*/
async function generateSessionKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['wrapKey', 'unwrapKey']
);
}
async function exportSessionKey(key: CryptoKey): Promise<string> {
const exported = await crypto.subtle.exportKey('raw', key);
return arrayBufferToBase64(exported);
}
async function importSessionKey(keyData: string): Promise<CryptoKey> {
const buffer = base64ToArrayBuffer(keyData);
return crypto.subtle.importKey(
'raw',
buffer,
{ name: 'AES-GCM', length: 256 },
false, // not extractable after import
['wrapKey', 'unwrapKey']
);
}
// ============================================
// Key Wrapping / Unwrapping
// ============================================
/**
* Wrap the raw private key data with a session key for storage
* This works on the raw PKCS8 bytes, not the CryptoKey
*/
async function wrapRawPrivateKey(
privateKeyBase64: string,
sessionKey: CryptoKey
): Promise<WrappedKey> {
const iv = crypto.getRandomValues(new Uint8Array(12));
// Convert base64 private key to bytes
const privateKeyBytes = base64ToArrayBuffer(privateKeyBase64);
// Encrypt the raw key data using AES-GCM
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
sessionKey,
privateKeyBytes
);
return {
wrapped: arrayBufferToBase64(encrypted),
iv: arrayBufferToBase64(iv),
salt: arrayBufferToBase64(new Uint8Array(0)), // Not used but kept for structure
createdAt: Date.now(),
};
}
/**
* Unwrap the private key using the session key
* Returns the raw key bytes that can then be imported
*/
async function unwrapRawPrivateKey(
wrapped: WrappedKey,
sessionKey: CryptoKey
): Promise<ArrayBuffer> {
const wrappedBuffer = base64ToArrayBuffer(wrapped.wrapped);
const iv = base64ToArrayBuffer(wrapped.iv);
// Decrypt the raw key data
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
sessionKey,
wrappedBuffer
);
return decrypted;
}
// ============================================
// Public API
// ============================================
/**
* Save the unlocked private key for persistence
* Call this after the user unlocks with their password
*
* Note: We wrap the raw key data (not the CryptoKey) because the imported
* key is non-extractable for security. We have the raw PKCS8 data available
* right after decryption, before importing.
*/
export async function persistUnlockedKey(
privateKeyBase64: string,
password: string
): Promise<void> {
try {
// Derive session key from password
const sessionKey = await deriveSessionKey(password);
// Wrap the raw private key data (before importing as non-extractable)
const wrapped = await wrapRawPrivateKey(privateKeyBase64, sessionKey);
// Store wrapped key in IndexedDB
await storeInDB(WRAPPED_KEY_ITEM, wrapped);
// Store session key in localStorage (so it survives refreshes)
const sessionKeyData = await exportSessionKey(sessionKey);
const sessionData: SessionData = {
key: sessionKeyData,
createdAt: Date.now(),
};
localStorage.setItem(SESSION_KEY_ITEM, JSON.stringify(sessionData));
console.log('[KeyPersistence] Key persisted successfully');
} catch (error) {
console.error('[KeyPersistence] Failed to persist key:', error);
throw error;
}
}
/**
* Try to restore the private key from persistent storage
* Returns the raw key bytes if available, null otherwise
* The caller must then import these bytes as a non-extractable CryptoKey
*/
export async function tryRestoreKey(): Promise<ArrayBuffer | null> {
try {
// Get session key from localStorage
const sessionDataRaw = localStorage.getItem(SESSION_KEY_ITEM);
if (!sessionDataRaw) {
console.log('[KeyPersistence] No session key found');
return null;
}
const sessionData: SessionData = JSON.parse(sessionDataRaw);
// Check expiry (24 hours)
const MAX_AGE = 24 * 60 * 60 * 1000;
if (Date.now() - sessionData.createdAt > MAX_AGE) {
console.log('[KeyPersistence] Session expired');
await clearPersistentKey();
return null;
}
// Get wrapped key from IndexedDB
const wrapped = await getFromDB<WrappedKey>(WRAPPED_KEY_ITEM);
if (!wrapped) {
console.log('[KeyPersistence] No wrapped key found');
return null;
}
// Import session key
const sessionKey = await importSessionKey(sessionData.key);
// Unwrap to get raw key bytes
const privateKeyBytes = await unwrapRawPrivateKey(wrapped, sessionKey);
console.log('[KeyPersistence] Key restored successfully');
return privateKeyBytes;
} catch (error) {
console.error('[KeyPersistence] Failed to restore key:', error);
return null;
}
}
/**
* Clear the persisted key (logout)
*/
export async function clearPersistentKey(): Promise<void> {
try {
localStorage.removeItem(SESSION_KEY_ITEM);
await removeFromDB(WRAPPED_KEY_ITEM);
console.log('[KeyPersistence] Key cleared');
} catch (error) {
console.error('[KeyPersistence] Error clearing key:', error);
}
}
/**
* Check if a persisted key is available
*/
export async function hasPersistentKey(): Promise<boolean> {
const sessionData = localStorage.getItem(SESSION_KEY_ITEM);
if (!sessionData) return false;
try {
const parsed: SessionData = JSON.parse(sessionData);
const MAX_AGE = 24 * 60 * 60 * 1000;
return Date.now() - parsed.createdAt <= MAX_AGE;
} catch {
return false;
}
}
// ============================================
// Helpers
// ============================================
function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer as ArrayBuffer;
}
+93 -111
View File
@@ -2,145 +2,127 @@
* User Signing Tests * User Signing Tests
* *
* Tests for user-level cryptographic signing functionality * Tests for user-level cryptographic signing functionality
* Validates: Requirements US-1.2, US-1.4, US-6.3, US-6.4 * Validates: Key management, signing, and verification
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { import {
getUserPrivateKey, keyStore,
setUserPrivateKey, hasUserPrivateKey,
clearUserPrivateKey, clearUserPrivateKey,
hasUserPrivateKey generateKeyPair,
exportPublicKey,
canonicalize
} from './user-signing'; } from './user-signing';
// Mock localStorage for Node environment describe('User Signing', () => {
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
// Set up global mocks
global.localStorage = localStorageMock as any;
global.window = { localStorage: localStorageMock } as any;
describe('User Private Key Management', () => {
// Clean up localStorage before and after each test
beforeEach(() => { beforeEach(() => {
localStorage.clear(); // Clear the key store before each test
keyStore.clear();
}); });
afterEach(() => { describe('keyStore', () => {
localStorage.clear(); it('should store and retrieve identity', () => {
}); const identity = {
did: 'did:web:example.com:alice',
describe('setUserPrivateKey and getUserPrivateKey', () => { handle: 'alice',
it('should store and retrieve private key from localStorage', () => { publicKey: 'test-public-key'
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----'; };
// Store the key keyStore.setIdentity(identity);
setUserPrivateKey(testKey); const retrieved = keyStore.getIdentity();
// Retrieve the key expect(retrieved).toEqual(identity);
const retrievedKey = getUserPrivateKey();
// Verify it matches
expect(retrievedKey).toBe(testKey);
}); });
it('should return null when no key is stored', () => { it('should return null when no identity is set', () => {
const retrievedKey = getUserPrivateKey(); expect(keyStore.getIdentity()).toBeNull();
expect(retrievedKey).toBeNull();
});
it('should overwrite existing key when setting a new one', () => {
const firstKey = '-----BEGIN PRIVATE KEY-----\nfirst-key\n-----END PRIVATE KEY-----';
const secondKey = '-----BEGIN PRIVATE KEY-----\nsecond-key\n-----END PRIVATE KEY-----';
// Store first key
setUserPrivateKey(firstKey);
expect(getUserPrivateKey()).toBe(firstKey);
// Store second key
setUserPrivateKey(secondKey);
expect(getUserPrivateKey()).toBe(secondKey);
});
});
describe('clearUserPrivateKey', () => {
it('should remove private key from localStorage', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
// Store the key
setUserPrivateKey(testKey);
expect(getUserPrivateKey()).toBe(testKey);
// Clear the key
clearUserPrivateKey();
// Verify it's removed
expect(getUserPrivateKey()).toBeNull();
});
it('should be idempotent (safe to call multiple times)', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
// Store and clear
setUserPrivateKey(testKey);
clearUserPrivateKey();
// Clear again (should not throw)
expect(() => clearUserPrivateKey()).not.toThrow();
expect(getUserPrivateKey()).toBeNull();
});
it('should work when no key was stored', () => {
// Clear when nothing is stored (should not throw)
expect(() => clearUserPrivateKey()).not.toThrow();
expect(getUserPrivateKey()).toBeNull();
}); });
}); });
describe('hasUserPrivateKey', () => { describe('hasUserPrivateKey', () => {
it('should return true when key is stored', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
setUserPrivateKey(testKey);
expect(hasUserPrivateKey()).toBe(true);
});
it('should return false when no key is stored', () => { it('should return false when no key is stored', () => {
expect(hasUserPrivateKey()).toBe(false); expect(hasUserPrivateKey()).toBe(false);
}); });
it('should return false after key is cleared', () => { it('should return false after key is cleared', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
setUserPrivateKey(testKey);
expect(hasUserPrivateKey()).toBe(true);
clearUserPrivateKey(); clearUserPrivateKey();
expect(hasUserPrivateKey()).toBe(false); expect(hasUserPrivateKey()).toBe(false);
}); });
}); });
describe('localStorage key name', () => { describe('clearUserPrivateKey', () => {
it('should use the correct localStorage key', () => { it('should be idempotent (safe to call multiple times)', () => {
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----'; expect(() => clearUserPrivateKey()).not.toThrow();
setUserPrivateKey(testKey); expect(hasUserPrivateKey()).toBe(false);
});
});
describe('generateKeyPair', () => {
it('should generate a valid ECDSA P-256 key pair', async () => {
const keyPair = await generateKeyPair();
// Verify the key is stored with the correct name expect(keyPair).toHaveProperty('privateKey');
const storedValue = localStorage.getItem('synapsis_user_private_key'); expect(keyPair).toHaveProperty('publicKey');
expect(storedValue).toBe(testKey); expect(keyPair.privateKey.type).toBe('private');
expect(keyPair.publicKey.type).toBe('public');
expect(keyPair.privateKey.algorithm.name).toBe('ECDSA');
expect(keyPair.publicKey.algorithm.name).toBe('ECDSA');
});
});
describe('exportPublicKey', () => {
it('should export public key as base64', async () => {
const keyPair = await generateKeyPair();
const exported = await exportPublicKey(keyPair.publicKey);
expect(typeof exported).toBe('string');
expect(exported.length).toBeGreaterThan(0);
// Should be valid base64
expect(() => atob(exported)).not.toThrow();
});
});
describe('canonicalize', () => {
it('should canonicalize objects with sorted keys', () => {
const obj1 = { b: 1, a: 2 };
const obj2 = { a: 2, b: 1 };
expect(canonicalize(obj1)).toBe('{"a":2,"b":1}');
expect(canonicalize(obj2)).toBe('{"a":2,"b":1}');
expect(canonicalize(obj1)).toBe(canonicalize(obj2));
});
it('should handle nested objects', () => {
const obj = { z: { a: 1, b: 2 }, y: 'test' };
expect(canonicalize(obj)).toBe('{"y":"test","z":{"a":1,"b":2}}');
});
it('should handle arrays', () => {
const obj = { arr: [3, 1, 2] };
expect(canonicalize(obj)).toBe('{"arr":[3,1,2]}');
});
it('should throw on invalid types', () => {
expect(() => canonicalize({ d: new Date() })).toThrow(/Date objects not allowed/);
expect(() => canonicalize({ n: NaN })).toThrow(/Number is not finite/);
expect(() => canonicalize({ n: Infinity })).toThrow(/Number is not finite/);
});
it('should handle strings correctly', () => {
expect(canonicalize('hello')).toBe('"hello"');
expect(canonicalize('with"quotes')).toBe('"with\\"quotes"');
});
it('should handle numbers', () => {
expect(canonicalize(42)).toBe('42');
expect(canonicalize(3.14)).toBe('3.14');
});
it('should handle booleans and null', () => {
expect(canonicalize(true)).toBe('true');
expect(canonicalize(false)).toBe('false');
expect(canonicalize(null)).toBe('null');
}); });
}); });
}); });
+97 -73
View File
@@ -1,12 +1,19 @@
/** /**
* User Identity Hook * User Identity Hook
* *
* Manages the user's cryptographic identity using in-memory storage. * Manages the user's cryptographic identity with persistent storage.
* strict: NO localStorage for decrypted keys. * Keys are encrypted at rest in IndexedDB and automatically restored
* across page refreshes and tabs.
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { decryptPrivateKey } from '@/lib/crypto/private-key-client'; import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
import {
persistUnlockedKey,
tryRestoreKey,
clearPersistentKey,
hasPersistentKey,
} from '@/lib/crypto/key-persistence';
import { import {
keyStore, keyStore,
importPrivateKey, importPrivateKey,
@@ -27,89 +34,102 @@ export interface UserIdentity {
export function useUserIdentity() { export function useUserIdentity() {
const [identity, setIdentity] = useState<UserIdentity | null>(null); const [identity, setIdentity] = useState<UserIdentity | null>(null);
const [isUnlocked, setIsUnlocked] = useState(false); const [isUnlocked, setIsUnlocked] = useState(false);
const [isRestoring, setIsRestoring] = useState(true);
// Check status on mount / updates // Check status on mount and try to restore from persistence
// Check status on mount / updates and poll for changes in singleton
useEffect(() => { useEffect(() => {
const check = () => { const checkAndRestore = async () => {
const hasKey = !!keyStore.getPrivateKey(); setIsRestoring(true);
setIsUnlocked(hasKey); try {
// First check if already in memory (hot reload scenario)
// Auto-sync identity if available in singleton but missing in local state const hasKey = !!keyStore.getPrivateKey();
const globalIdentity = keyStore.getIdentity(); const globalIdentity = keyStore.getIdentity();
if (globalIdentity) {
setIdentity(prev => { if (hasKey && globalIdentity) {
// Avoid rerenders if same setIdentity({ ...globalIdentity, isUnlocked: true });
if (prev && prev.did === globalIdentity.did && prev.isUnlocked === hasKey) return prev; setIsUnlocked(true);
return { ...globalIdentity, isUnlocked: hasKey }; setIsRestoring(false);
}); return;
} else { }
// If global cleared, clear local
setIdentity(prev => prev ? null : null); // Try to restore from persistent storage
const restoredKeyBytes = await tryRestoreKey();
if (restoredKeyBytes && globalIdentity) {
// Import the restored key as non-extractable
const cryptoKey = await importPrivateKey(restoredKeyBytes);
keyStore.setPrivateKey(cryptoKey);
setIdentity({ ...globalIdentity, isUnlocked: true });
setIsUnlocked(true);
console.log('[Identity] Auto-restored from persistent storage');
} else if (globalIdentity) {
// Have identity info but no key - locked state
setIdentity({ ...globalIdentity, isUnlocked: false });
setIsUnlocked(false);
}
} catch (error) {
console.error('[Identity] Error during restore:', error);
} finally {
setIsRestoring(false);
} }
}; };
check(); checkAndRestore();
// Poll fast to ensure UI updates are snappy
const interval = setInterval(check, 500);
return () => clearInterval(interval);
}, []); }, []);
/** /**
* Initialize identity from user data & password * Initialize identity from user data
* Call this when user data is loaded from the server
*/ */
const initializeIdentity = async (userData: { const initializeIdentity = useCallback(async (userData: {
did: string; did: string;
handle: string; handle: string;
publicKey: string; publicKey: string;
privateKeyEncrypted: string; privateKeyEncrypted?: string;
}, password?: string) => { }) => {
// If password provided, attempt unlock
// Save to singleton
const coreIdentity = { const coreIdentity = {
did: userData.did, did: userData.did,
handle: userData.handle, handle: userData.handle,
publicKey: userData.publicKey publicKey: userData.publicKey
}; };
keyStore.setIdentity(coreIdentity); keyStore.setIdentity(coreIdentity);
// If password provided, attempt unlock // Try to auto-restore if we have persisted key
if (password) { const restoredKeyBytes = await tryRestoreKey();
await unlockIdentity(userData.privateKeyEncrypted, password); if (restoredKeyBytes) {
const cryptoKey = await importPrivateKey(restoredKeyBytes);
keyStore.setPrivateKey(cryptoKey);
setIdentity({ ...coreIdentity, isUnlocked: true });
setIsUnlocked(true);
} else { } else {
// Just set public identity info if locked setIdentity({ ...coreIdentity, isUnlocked: false });
setIdentity({ setIsUnlocked(false);
...coreIdentity,
isUnlocked: !!keyStore.getPrivateKey()
});
} }
}; }, []);
/** /**
* Unlock the identity with a password * Unlock the identity with a password
* Also persists the key for auto-unlock on refresh
*/ */
const unlockIdentity = async (privateKeyEncrypted: string, password: string, userDid?: string, userHandle?: string, userPublicKey?: string) => { const unlockIdentity = useCallback(async (
privateKeyEncrypted: string,
password: string,
userDid?: string,
userHandle?: string,
userPublicKey?: string
) => {
try { try {
console.log('[Identity] Unlocking with DID:', userDid, 'Handle:', userHandle); console.log('[Identity] Unlocking with DID:', userDid, 'Handle:', userHandle);
// Set identity first if provided (needed for storage key derivation) // Set identity first if provided
if (userDid && userHandle && userPublicKey) { if (userDid && userHandle && userPublicKey) {
keyStore.setIdentity({ keyStore.setIdentity({
did: userDid, did: userDid,
handle: userHandle, handle: userHandle,
publicKey: userPublicKey publicKey: userPublicKey
}); });
console.log('[Identity] Identity set in keyStore');
} else {
console.warn('[Identity] Missing user info for identity setup');
} }
// 1. Decrypt the PEM/String from server (which is actually a base64 encoded PKCS8 export usually?) // Decrypt the private key
// Wait, existing implementation returns a string.
// We need to verify what `decryptPrivateKey` returns.
// Assuming it returns the decrypted string (Base64 of PKCS8)
const privateKeyPemOrBase64 = await decryptPrivateKey(privateKeyEncrypted, password); const privateKeyPemOrBase64 = await decryptPrivateKey(privateKeyEncrypted, password);
// Clean up if it's PEM to get Base64 // Clean up if it's PEM to get Base64
@@ -121,53 +141,56 @@ export function useUserIdentity() {
.replace(/\s/g, ''); .replace(/\s/g, '');
} }
// 2. Import into CryptoKey // Import into CryptoKey (non-extractable for security)
// We need ArrayBuffer
const binaryDer = Buffer.from(privateKeyBase64, 'base64'); const binaryDer = Buffer.from(privateKeyBase64, 'base64');
const cryptoKey = await importPrivateKey(binaryDer); // This is P-256 specific now const cryptoKey = await importPrivateKey(binaryDer);
// 3. Store in Memory // Store in memory
keyStore.setPrivateKey(cryptoKey); keyStore.setPrivateKey(cryptoKey);
console.log('[Identity] Private key stored in memory');
// PERSIST: Save raw key bytes for auto-restore on refresh
// We pass the raw bytes because the CryptoKey is non-extractable
await persistUnlockedKey(privateKeyBase64, password);
console.log('[Identity] Private key stored in memory and persisted');
// 4. Update State // Update State
setIdentity(prev => prev ? { ...prev, isUnlocked: true } : null); // We need the other data... const globalIdentity = keyStore.getIdentity();
if (globalIdentity) {
setIdentity({ ...globalIdentity, isUnlocked: true });
}
setIsUnlocked(true); setIsUnlocked(true);
// If we didn't have identity wrapper set yet, we might need it.
// Usually initializeIdentity handles both.
} catch (error) { } catch (error) {
console.error('[Identity] Failed to unlock identity:', error); console.error('[Identity] Failed to unlock identity:', error);
throw new Error('Failed to unlock identity. Incorrect password?'); throw new Error('Failed to unlock identity. Incorrect password?');
} }
}; }, []);
/** /**
* Lock the identity * Lock the identity (manual lock, keeps identity info)
*/ */
const lockIdentity = () => { const lockIdentity = useCallback(async () => {
keyStore.clear(); keyStore.clear();
await clearPersistentKey();
setIsUnlocked(false); setIsUnlocked(false);
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null); setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
}; }, []);
/** /**
* Clear the identity (logout) * Clear the identity (logout)
*/ */
const clearIdentity = () => { const clearIdentity = useCallback(async () => {
keyStore.clear(); keyStore.clear();
await clearPersistentKey();
setIdentity(null); setIdentity(null);
setIsUnlocked(false); setIsUnlocked(false);
}; }, []);
/** /**
* Sign a user action * Sign a user action
*/ */
const signUserAction = async (action: string, data: any) => { const signUserAction = useCallback(async (action: string, data: any) => {
// Re-check global state directly to be safe
const pk = keyStore.getPrivateKey(); const pk = keyStore.getPrivateKey();
const id = keyStore.getIdentity(); const id = keyStore.getIdentity();
@@ -175,13 +198,14 @@ export function useUserIdentity() {
console.error('[Identity] Sign failed. Identity:', id, 'HasKey:', !!pk); console.error('[Identity] Sign failed. Identity:', id, 'HasKey:', !!pk);
throw new Error('Identity locked'); throw new Error('Identity locked');
} }
// Use the fetched identity to ensure sync
return await createSignedAction(action, data, id.did, id.handle); return await createSignedAction(action, data, id.did, id.handle);
}; }, []);
return { return {
identity, identity,
isUnlocked, isUnlocked,
isRestoring, // New: lets UI know if we're checking persistence
initializeIdentity, initializeIdentity,
unlockIdentity, unlockIdentity,
lockIdentity, lockIdentity,
+13 -1
View File
@@ -64,11 +64,23 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
/** /**
* Announce this node to a remote node * Announce this node to a remote node
*
* SECURITY: Signs the announcement with the node's private key
*/ */
export async function announceToNode(targetDomain: string): Promise<{ success: boolean; error?: string }> { export async function announceToNode(targetDomain: string): Promise<{ success: boolean; error?: string }> {
try { try {
const announcement = await buildAnnouncement(); const announcement = await buildAnnouncement();
// SECURITY: Sign the announcement with our private key
const { signPayload, getNodePrivateKey } = await import('./signature');
const privateKey = await getNodePrivateKey();
const signature = signPayload(announcement, privateKey);
const signedAnnouncement = {
...announcement,
signature,
};
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`; const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
const url = `${baseUrl}/api/swarm/announce`; const url = `${baseUrl}/api/swarm/announce`;
@@ -78,7 +90,7 @@ export async function announceToNode(targetDomain: string): Promise<{ success: b
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',
}, },
body: JSON.stringify(announcement), body: JSON.stringify(signedAnnouncement),
}); });
if (!response.ok) { if (!response.ok) {
+13 -1
View File
@@ -109,6 +109,8 @@ export async function processGossip(
/** /**
* Send gossip to a specific node * Send gossip to a specific node
*
* SECURITY: Signs the gossip payload with the node's private key
*/ */
export async function gossipToNode( export async function gossipToNode(
targetDomain: string, targetDomain: string,
@@ -119,6 +121,16 @@ export async function gossipToNode(
try { try {
const payload = await buildGossipPayload(since); const payload = await buildGossipPayload(since);
// SECURITY: Sign the gossip payload with our private key
const { signPayload, getNodePrivateKey } = await import('./signature');
const privateKey = await getNodePrivateKey();
const signature = signPayload(payload, privateKey);
const signedPayload = {
...payload,
signature,
};
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`; const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
const url = `${baseUrl}/api/swarm/gossip`; const url = `${baseUrl}/api/swarm/gossip`;
@@ -128,7 +140,7 @@ export async function gossipToNode(
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',
}, },
body: JSON.stringify(payload), body: JSON.stringify(signedPayload),
}); });
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
+68 -47
View File
@@ -153,64 +153,80 @@ export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNode
/** /**
* Mark a node as having failed contact * Mark a node as having failed contact
*
* @throws Error if database operation fails (after logging)
*/ */
export async function markNodeFailure(domain: string): Promise<void> { export async function markNodeFailure(domain: string): Promise<void> {
if (!db) return; if (!db) return;
const node = await db.query.swarmNodes.findFirst({ try {
where: eq(swarmNodes.domain, domain), const node = await db.query.swarmNodes.findFirst({
}); where: eq(swarmNodes.domain, domain),
});
if (!node) return; if (!node) return;
const newFailures = node.consecutiveFailures + 1; const newFailures = node.consecutiveFailures + 1;
const newTrust = Math.max( const newTrust = Math.max(
SWARM_CONFIG.minTrustScore, SWARM_CONFIG.minTrustScore,
node.trustScore + SWARM_CONFIG.trustScoreOnFailure node.trustScore + SWARM_CONFIG.trustScoreOnFailure
); );
const isActive = newFailures < SWARM_CONFIG.maxConsecutiveFailures; const isActive = newFailures < SWARM_CONFIG.maxConsecutiveFailures;
await db.update(swarmNodes) await db.update(swarmNodes)
.set({ .set({
consecutiveFailures: newFailures, consecutiveFailures: newFailures,
trustScore: newTrust, trustScore: newTrust,
isActive, isActive,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(swarmNodes.domain, domain)); .where(eq(swarmNodes.domain, domain));
} catch (error) {
console.error(`[Swarm] Failed to mark node failure for ${domain}:`, error);
throw error;
}
} }
/** /**
* Mark a node as successfully contacted * Mark a node as successfully contacted
*
* @throws Error if database operation fails (after logging)
*/ */
export async function markNodeSuccess(domain: string): Promise<void> { export async function markNodeSuccess(domain: string): Promise<void> {
if (!db) return; if (!db) return;
const node = await db.query.swarmNodes.findFirst({ try {
where: eq(swarmNodes.domain, domain), const node = await db.query.swarmNodes.findFirst({
}); where: eq(swarmNodes.domain, domain),
});
if (!node) return; if (!node) return;
const newTrust = Math.min( const newTrust = Math.min(
SWARM_CONFIG.maxTrustScore, SWARM_CONFIG.maxTrustScore,
node.trustScore + SWARM_CONFIG.trustScoreOnSuccess node.trustScore + SWARM_CONFIG.trustScoreOnSuccess
); );
await db.update(swarmNodes) await db.update(swarmNodes)
.set({ .set({
consecutiveFailures: 0, consecutiveFailures: 0,
trustScore: newTrust, trustScore: newTrust,
isActive: true, isActive: true,
lastSeenAt: new Date(), lastSeenAt: new Date(),
lastSyncAt: new Date(), lastSyncAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(swarmNodes.domain, domain)); .where(eq(swarmNodes.domain, domain));
} catch (error) {
console.error(`[Swarm] Failed to mark node success for ${domain}:`, error);
throw error;
}
} }
/** /**
* Log a sync operation * Log a sync operation
*
* @throws Error if database operation fails (after logging)
*/ */
export async function logSync( export async function logSync(
remoteDomain: string, remoteDomain: string,
@@ -219,17 +235,22 @@ export async function logSync(
): Promise<void> { ): Promise<void> {
if (!db) return; if (!db) return;
await db.insert(swarmSyncLog).values({ try {
remoteDomain, await db.insert(swarmSyncLog).values({
direction, remoteDomain,
nodesReceived: result.nodesReceived, direction,
nodesSent: result.nodesSent, nodesReceived: result.nodesReceived,
handlesReceived: result.handlesReceived, nodesSent: result.nodesSent,
handlesSent: result.handlesSent, handlesReceived: result.handlesReceived,
success: result.success, handlesSent: result.handlesSent,
errorMessage: result.error, success: result.success,
durationMs: result.durationMs, errorMessage: result.error,
}); durationMs: result.durationMs,
});
} catch (error) {
console.error(`[Swarm] Failed to log sync for ${remoteDomain}:`, error);
throw error;
}
} }
/** /**
+6 -3
View File
@@ -12,11 +12,13 @@ export interface RemoteProfile {
/** /**
* Upsert a remote user into the local database for caching/display purposes. * Upsert a remote user into the local database for caching/display purposes.
*
* @throws Error if database operation fails (after logging)
*/ */
export async function upsertRemoteUser(profile: RemoteProfile) { export async function upsertRemoteUser(profile: RemoteProfile): Promise<void> {
try { if (!db) return;
if (!db) return;
try {
// Check if user already exists // Check if user already exists
const existing = await db.query.users.findFirst({ const existing = await db.query.users.findFirst({
where: eq(users.did, profile.did), where: eq(users.did, profile.did),
@@ -50,5 +52,6 @@ export async function upsertRemoteUser(profile: RemoteProfile) {
} }
} catch (error) { } catch (error) {
console.error(`[User Cache] Failed to upsert ${profile.handle}:`, error); console.error(`[User Cache] Failed to upsert ${profile.handle}:`, error);
throw error;
} }
} }