Security fixes: swarm signature verification and error handling
This commit is contained in:
@@ -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 || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
handle: 'alice',
|
||||||
|
publicKey: 'test-public-key'
|
||||||
|
};
|
||||||
|
|
||||||
describe('setUserPrivateKey and getUserPrivateKey', () => {
|
keyStore.setIdentity(identity);
|
||||||
it('should store and retrieve private key from localStorage', () => {
|
const retrieved = keyStore.getIdentity();
|
||||||
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
|
|
||||||
|
|
||||||
// Store the key
|
expect(retrieved).toEqual(identity);
|
||||||
setUserPrivateKey(testKey);
|
|
||||||
|
|
||||||
// Retrieve the key
|
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Verify the key is stored with the correct name
|
describe('generateKeyPair', () => {
|
||||||
const storedValue = localStorage.getItem('synapsis_user_private_key');
|
it('should generate a valid ECDSA P-256 key pair', async () => {
|
||||||
expect(storedValue).toBe(testKey);
|
const keyPair = await generateKeyPair();
|
||||||
|
|
||||||
|
expect(keyPair).toHaveProperty('privateKey');
|
||||||
|
expect(keyPair).toHaveProperty('publicKey');
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,46 +34,58 @@ 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)
|
||||||
|
const hasKey = !!keyStore.getPrivateKey();
|
||||||
|
const globalIdentity = keyStore.getIdentity();
|
||||||
|
|
||||||
// Auto-sync identity if available in singleton but missing in local state
|
if (hasKey && globalIdentity) {
|
||||||
const globalIdentity = keyStore.getIdentity();
|
setIdentity({ ...globalIdentity, isUnlocked: true });
|
||||||
if (globalIdentity) {
|
setIsUnlocked(true);
|
||||||
setIdentity(prev => {
|
setIsRestoring(false);
|
||||||
// Avoid rerenders if same
|
return;
|
||||||
if (prev && prev.did === globalIdentity.did && prev.isUnlocked === hasKey) return prev;
|
}
|
||||||
return { ...globalIdentity, isUnlocked: hasKey };
|
|
||||||
});
|
// Try to restore from persistent storage
|
||||||
} else {
|
const restoredKeyBytes = await tryRestoreKey();
|
||||||
// If global cleared, clear local
|
if (restoredKeyBytes && globalIdentity) {
|
||||||
setIdentity(prev => prev ? null : null);
|
// 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,
|
||||||
@@ -74,42 +93,43 @@ export function useUserIdentity() {
|
|||||||
};
|
};
|
||||||
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');
|
|
||||||
|
|
||||||
// 4. Update State
|
// PERSIST: Save raw key bytes for auto-restore on refresh
|
||||||
setIdentity(prev => prev ? { ...prev, isUnlocked: true } : null); // We need the other data...
|
// 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');
|
||||||
|
|
||||||
|
// Update State
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user