Add encrypted key support for chat and nodes
Introduces encrypted private key storage for nodes and users, updates chat message schema to support sender-side encryption, and adds supporting libraries and tests for cryptographic signing and identity unlock flows. Includes new database migrations, API route updates, and React components for identity unlock prompts.
This commit is contained in:
@@ -37,6 +37,9 @@ export async function POST(request: Request) {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
did: user.did,
|
||||
publicKey: user.publicKey,
|
||||
privateKeyEncrypted: user.privateKeyEncrypted, // Client will decrypt with password
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -33,6 +33,9 @@ export async function GET() {
|
||||
avatarUrl: session.user.avatarUrl,
|
||||
bio: session.user.bio,
|
||||
website: session.user.website,
|
||||
did: session.user.did,
|
||||
publicKey: session.user.publicKey,
|
||||
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -68,6 +68,9 @@ export async function POST(request: Request) {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
did: user.did,
|
||||
publicKey: user.publicKey,
|
||||
privateKeyEncrypted: user.privateKeyEncrypted, // Client will decrypt with password
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { nodes, users } from '@/db';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { getNodePublicKey } from '@/lib/swarm/node-keys';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -12,6 +13,9 @@ export async function GET() {
|
||||
where: eq(nodes.domain, domain),
|
||||
});
|
||||
|
||||
// Ensure we have a public key
|
||||
const publicKey = await getNodePublicKey();
|
||||
|
||||
// Fetch admin users based on ADMIN_EMAILS env var
|
||||
const adminEmails = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',')
|
||||
@@ -37,6 +41,7 @@ export async function GET() {
|
||||
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A swarm social network node.',
|
||||
accentColor: process.env.NEXT_PUBLIC_ACCENT_COLOR || '#FFFFFF',
|
||||
domain,
|
||||
publicKey,
|
||||
admins,
|
||||
turnstileSiteKey: null,
|
||||
});
|
||||
@@ -44,9 +49,11 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json({
|
||||
...node,
|
||||
publicKey, // Always include the public key
|
||||
admins,
|
||||
// Don't expose the secret key
|
||||
// Don't expose the secret keys
|
||||
turnstileSecretKey: undefined,
|
||||
privateKeyEncrypted: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Node info error:', error);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -62,9 +63,9 @@ export async function GET(request: Request) {
|
||||
if (row.actorNodeDomain && !row.actorAvatarUrl) {
|
||||
const key = `${row.actorHandle}@${row.actorNodeDomain}`;
|
||||
if (!remoteToFetch.has(key)) {
|
||||
remoteToFetch.set(key, {
|
||||
remoteToFetch.set(key, {
|
||||
handle: row.actorHandle.split('@')[0], // Get just the username part
|
||||
nodeDomain: row.actorNodeDomain
|
||||
nodeDomain: row.actorNodeDomain
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,14 +86,14 @@ export async function GET(request: Request) {
|
||||
const payload = rows.map((row) => {
|
||||
const key = row.actorNodeDomain ? `${row.actorHandle}@${row.actorNodeDomain}` : null;
|
||||
const freshProfile = key ? freshProfiles.get(key) : null;
|
||||
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
createdAt: row.createdAt,
|
||||
readAt: row.readAt,
|
||||
actor: {
|
||||
handle: row.actorNodeDomain
|
||||
handle: row.actorNodeDomain
|
||||
? `${row.actorHandle}@${row.actorNodeDomain}`
|
||||
: row.actorHandle,
|
||||
displayName: freshProfile?.displayName || row.actorDisplayName,
|
||||
@@ -119,13 +120,15 @@ export async function GET(request: Request) {
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const signedAction = await request.json();
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
// We trust the signed action 'data' for the IDs
|
||||
const body = signedAction.data;
|
||||
const data = markSchema.parse(body);
|
||||
|
||||
if (!data.all && (!data.ids || data.ids.length === 0)) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, likes, users, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import crypto from 'crypto';
|
||||
|
||||
@@ -36,8 +37,14 @@ function extractSwarmPostId(apId: string): string | null {
|
||||
// Like a post
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: rawId } = await context.params;
|
||||
// Parse the signed action from the request body
|
||||
const signedAction: SignedAction = await request.json();
|
||||
|
||||
// Verify the signature and get the user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
// Extract postId from the signed action data
|
||||
const { postId: rawId } = signedAction.data;
|
||||
const postId = decodeURIComponent(rawId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
@@ -64,9 +71,12 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorDisplayName: user.displayName || user.handle,
|
||||
actorAvatarUrl: user.avatarUrl || undefined,
|
||||
actorNodeDomain: nodeDomain,
|
||||
actorDid: user.did,
|
||||
actorPublicKey: user.publicKey,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
userSignature: signedAction.sig,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -163,9 +173,12 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorDisplayName: user.displayName || user.handle,
|
||||
actorAvatarUrl: user.avatarUrl || undefined,
|
||||
actorNodeDomain: nodeDomain,
|
||||
actorDid: user.did,
|
||||
actorPublicKey: user.publicKey,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
userSignature: signedAction.sig,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -184,8 +197,17 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({ success: true, liked: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
if (error instanceof Error) {
|
||||
// Handle signature verification errors
|
||||
if (error.message === 'User not found' ||
|
||||
error.message === 'Handle mismatch' ||
|
||||
error.message === 'Invalid signature' ||
|
||||
error.message === 'Timestamp too old or in future') {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||
}
|
||||
if (error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to like post' }, { status: 500 });
|
||||
}
|
||||
@@ -194,8 +216,14 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
// Unlike a post
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: rawId } = await context.params;
|
||||
// Parse the signed action from the request body
|
||||
const signedAction: SignedAction = await request.json();
|
||||
|
||||
// Verify the signature and get the user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
// Extract postId from the signed action data
|
||||
const { postId: rawId } = signedAction.data;
|
||||
const postId = decodeURIComponent(rawId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
@@ -300,8 +328,17 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({ success: true, liked: false });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
if (error instanceof Error) {
|
||||
// Handle signature verification errors
|
||||
if (error.message === 'User not found' ||
|
||||
error.message === 'Handle mismatch' ||
|
||||
error.message === 'Invalid signature' ||
|
||||
error.message === 'Timestamp too old or in future') {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||
}
|
||||
if (error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to unlike post' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* POST /api/posts endpoint tests
|
||||
*
|
||||
* Tests for the create post endpoint with cryptographic signatures
|
||||
* Validates: Requirements US-3.1, US-3.2, US-3.3, US-3.4, US-3.5, TR-3
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { POST } from './route';
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('@/lib/auth/verify-signature', () => ({
|
||||
requireSignedAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({
|
||||
returning: vi.fn(() => Promise.resolve([{
|
||||
id: 'test-post-id',
|
||||
userId: 'test-user-id',
|
||||
content: 'Test post content',
|
||||
createdAt: new Date(),
|
||||
isRemoved: false,
|
||||
isNsfw: false,
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
}])),
|
||||
})),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
})),
|
||||
query: {
|
||||
media: {
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
posts: {
|
||||
findFirst: vi.fn(() => Promise.resolve(null)),
|
||||
},
|
||||
},
|
||||
},
|
||||
posts: {},
|
||||
users: {},
|
||||
media: {},
|
||||
}));
|
||||
|
||||
describe('POST /api/posts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should accept a valid signed action and create a post', async () => {
|
||||
// Mock a valid user
|
||||
const mockUser = {
|
||||
id: 'test-user-id',
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
publicKey: 'test-public-key',
|
||||
isSuspended: false,
|
||||
isSilenced: false,
|
||||
isNsfw: false,
|
||||
postsCount: 0,
|
||||
};
|
||||
|
||||
vi.mocked(requireSignedAction).mockResolvedValue(mockUser as any);
|
||||
|
||||
// Create a signed action payload
|
||||
const signedAction = {
|
||||
action: 'post',
|
||||
data: {
|
||||
content: 'Test post content',
|
||||
mediaIds: [],
|
||||
isNsfw: false,
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'test-signature',
|
||||
};
|
||||
|
||||
// Create a mock request
|
||||
const request = new Request('http://localhost:3000/api/posts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(signedAction),
|
||||
});
|
||||
|
||||
// Call the endpoint
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify the response
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.post).toBeDefined();
|
||||
expect(data.post.content).toBe('Test post content');
|
||||
|
||||
// Verify requireSignedAction was called
|
||||
expect(requireSignedAction).toHaveBeenCalledWith(signedAction);
|
||||
});
|
||||
|
||||
it('should return 403 for invalid signature', async () => {
|
||||
// Mock signature verification failure
|
||||
vi.mocked(requireSignedAction).mockRejectedValue(new Error('Invalid signature'));
|
||||
|
||||
const signedAction = {
|
||||
action: 'post',
|
||||
data: {
|
||||
content: 'Test post content',
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'invalid-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:3000/api/posts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(signedAction),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('Invalid signature');
|
||||
expect(data.code).toBe('INVALID_SIGNATURE');
|
||||
});
|
||||
|
||||
it('should return 403 for user not found', async () => {
|
||||
vi.mocked(requireSignedAction).mockRejectedValue(new Error('User not found'));
|
||||
|
||||
const signedAction = {
|
||||
action: 'post',
|
||||
data: {
|
||||
content: 'Test post content',
|
||||
},
|
||||
did: 'did:synapsis:nonexistent',
|
||||
handle: 'nonexistent',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'test-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:3000/api/posts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(signedAction),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('User not found');
|
||||
expect(data.code).toBe('INVALID_SIGNATURE');
|
||||
});
|
||||
|
||||
it('should return 403 for handle mismatch', async () => {
|
||||
vi.mocked(requireSignedAction).mockRejectedValue(new Error('Handle mismatch'));
|
||||
|
||||
const signedAction = {
|
||||
action: 'post',
|
||||
data: {
|
||||
content: 'Test post content',
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'wronghandle',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'test-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:3000/api/posts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(signedAction),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('Handle mismatch');
|
||||
expect(data.code).toBe('INVALID_SIGNATURE');
|
||||
});
|
||||
|
||||
it('should return 403 for expired timestamp', async () => {
|
||||
vi.mocked(requireSignedAction).mockRejectedValue(new Error('Timestamp too old or in future'));
|
||||
|
||||
const signedAction = {
|
||||
action: 'post',
|
||||
data: {
|
||||
content: 'Test post content',
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 minutes ago
|
||||
signature: 'test-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:3000/api/posts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(signedAction),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('Timestamp too old or in future');
|
||||
expect(data.code).toBe('INVALID_SIGNATURE');
|
||||
});
|
||||
|
||||
it('should return 403 for suspended user', async () => {
|
||||
const mockUser = {
|
||||
id: 'test-user-id',
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
publicKey: 'test-public-key',
|
||||
isSuspended: true,
|
||||
isSilenced: false,
|
||||
isNsfw: false,
|
||||
postsCount: 0,
|
||||
};
|
||||
|
||||
vi.mocked(requireSignedAction).mockResolvedValue(mockUser as any);
|
||||
|
||||
const signedAction = {
|
||||
action: 'post',
|
||||
data: {
|
||||
content: 'Test post content',
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'test-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:3000/api/posts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(signedAction),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('Account restricted');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid post data', async () => {
|
||||
const mockUser = {
|
||||
id: 'test-user-id',
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
publicKey: 'test-public-key',
|
||||
isSuspended: false,
|
||||
isSilenced: false,
|
||||
isNsfw: false,
|
||||
postsCount: 0,
|
||||
};
|
||||
|
||||
vi.mocked(requireSignedAction).mockResolvedValue(mockUser as any);
|
||||
|
||||
const signedAction = {
|
||||
action: 'post',
|
||||
data: {
|
||||
content: '', // Empty content should fail validation
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'test-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:3000/api/posts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(signedAction),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('Invalid input');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, desc, and, inArray, isNull, isNotNull, or, lt } from 'drizzle-orm';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
@@ -43,9 +44,15 @@ const createPostSchema = z.object({
|
||||
// Create a new post
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = await request.json();
|
||||
const data = createPostSchema.parse(body);
|
||||
// Parse the signed action from the request body
|
||||
const signedAction: SignedAction = await request.json();
|
||||
|
||||
// Strictly verify the signature and get the user
|
||||
// This replaces requireAuth() - the signature proves identity AND intent
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
// Extract post data from the signed action
|
||||
const data = createPostSchema.parse(signedAction.data);
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
@@ -273,8 +280,21 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
if (error instanceof Error) {
|
||||
// Handle signature verification errors
|
||||
if (error.message === 'Invalid signature' ||
|
||||
error.message === 'User not found' ||
|
||||
error.message === 'Handle mismatch' ||
|
||||
error.message === 'Timestamp too old or in future') {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: 'INVALID_SIGNATURE' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, reports, posts, users } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -12,18 +13,11 @@ const reportSchema = z.object({
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const reporter = await requireAuth();
|
||||
const signedAction = await request.json();
|
||||
const reporter = await requireSignedAction(signedAction);
|
||||
|
||||
if (reporter.isSuspended || reporter.isSilenced) {
|
||||
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const data = reportSchema.parse(body);
|
||||
// Trust signed payload
|
||||
const data = reportSchema.parse(signedAction.data);
|
||||
|
||||
if (data.targetType === 'post') {
|
||||
const targetPost = await db.query.posts.findFirst({
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireAuth } from '@/lib/auth'; // kept for GET
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateSchema = z.object({
|
||||
@@ -43,11 +44,14 @@ export async function GET() {
|
||||
*
|
||||
* Update NSFW settings. Enabling requires age confirmation.
|
||||
*/
|
||||
// Update NSFW settings. Enabling requires age confirmation.
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = await request.json();
|
||||
const { nsfwEnabled, confirmAge } = updateSchema.parse(body);
|
||||
const signedAction = await request.json();
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
// Trust signed payload data
|
||||
const { nsfwEnabled, confirmAge } = updateSchema.parse(signedAction.data);
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages } from '@/db';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
@@ -42,32 +42,40 @@ export async function DELETE(
|
||||
if (deleteFor === 'both') {
|
||||
// Delete the entire conversation and all messages (cascade will handle messages)
|
||||
await db.delete(chatConversations).where(eq(chatConversations.id, id));
|
||||
|
||||
|
||||
// Send deletion request to the other party
|
||||
const participant2Handle = conversation.participant2Handle;
|
||||
const isRemote = participant2Handle.includes('@');
|
||||
|
||||
|
||||
if (isRemote) {
|
||||
// Extract domain from handle (format: handle@domain)
|
||||
const domain = participant2Handle.split('@')[1];
|
||||
const handle = participant2Handle.split('@')[0];
|
||||
|
||||
|
||||
try {
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
|
||||
// SECURITY: Sign the deletion request
|
||||
const { signPayload, getNodePrivateKey } = await import('@/lib/swarm/signature');
|
||||
const privateKey = await getNodePrivateKey();
|
||||
|
||||
const payload = {
|
||||
senderHandle: session.user.handle,
|
||||
senderNodeDomain: nodeDomain,
|
||||
recipientHandle: handle,
|
||||
conversationId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const signature = signPayload(payload, privateKey);
|
||||
|
||||
await fetch(`${protocol}://${domain}/api/swarm/chat/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
senderHandle: session.user.handle,
|
||||
senderNodeDomain: nodeDomain,
|
||||
recipientHandle: handle,
|
||||
conversationId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
body: JSON.stringify({ ...payload, signature }),
|
||||
});
|
||||
|
||||
|
||||
console.log(`[Chat Delete] Sent deletion request to ${domain}`);
|
||||
} catch (error) {
|
||||
console.error('[Chat Delete] Failed to notify remote node:', error);
|
||||
@@ -76,9 +84,9 @@ export async function DELETE(
|
||||
} else {
|
||||
// Local user - find and delete their conversation too
|
||||
const recipientUser = await db.query.users.findFirst({
|
||||
where: eq(db.users.handle, participant2Handle),
|
||||
where: eq(users.handle, participant2Handle),
|
||||
});
|
||||
|
||||
|
||||
if (recipientUser) {
|
||||
// Find their conversation with us
|
||||
const recipientConversation = await db.query.chatConversations.findFirst({
|
||||
@@ -87,26 +95,26 @@ export async function DELETE(
|
||||
eq(chatConversations.participant2Handle, session.user.handle)
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
if (recipientConversation) {
|
||||
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
|
||||
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Conversation deleted for both parties'
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Conversation deleted for both parties'
|
||||
});
|
||||
} else {
|
||||
// Delete for self only - just delete the conversation record
|
||||
// The other party will still have their copy
|
||||
await db.delete(chatConversations).where(eq(chatConversations.id, id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Conversation deleted for you'
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Conversation deleted for you'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
* POST: Receives conversation deletion requests from other swarm nodes
|
||||
*
|
||||
* Security: Only allows deletion if the sender is actually a participant in the conversation
|
||||
* and the request is cryptographically signed.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, chatConversations } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const deletionSchema = z.object({
|
||||
senderHandle: z.string(),
|
||||
@@ -17,6 +19,7 @@ const deletionSchema = z.object({
|
||||
recipientHandle: z.string(),
|
||||
conversationId: z.string().optional(),
|
||||
timestamp: z.string(),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -28,6 +31,20 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const data = deletionSchema.parse(body);
|
||||
|
||||
// SECURITY: Verify the signature
|
||||
const { signature, ...payload } = data;
|
||||
const isValid = await verifyUserInteraction(
|
||||
payload,
|
||||
signature,
|
||||
data.senderHandle,
|
||||
data.senderNodeDomain
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`[Swarm Chat Delete] Invalid signature from ${data.senderHandle}@${data.senderNodeDomain}`);
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Find the recipient (local user)
|
||||
const recipient = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.recipientHandle.toLowerCase()),
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
*
|
||||
* This enables swarm-native follows between Synapsis nodes
|
||||
* with instant delivery and real-time updates.
|
||||
*
|
||||
* SECURITY: All requests must be cryptographically signed by the sender.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, notifications, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const swarmFollowSchema = z.object({
|
||||
targetHandle: z.string(),
|
||||
@@ -23,6 +26,7 @@ const swarmFollowSchema = z.object({
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -39,6 +43,20 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const data = swarmFollowSchema.parse(body);
|
||||
|
||||
// SECURITY: Verify the signature
|
||||
const { signature, ...payload } = data;
|
||||
const isValid = await verifyUserInteraction(
|
||||
payload,
|
||||
signature,
|
||||
data.follow.followerHandle,
|
||||
data.follow.followerNodeDomain
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`[Swarm] Invalid signature for follow from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Find the target user (local user being followed)
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.targetHandle.toLowerCase()),
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* Swarm Like Endpoint
|
||||
*
|
||||
* POST: Receive a like from another swarm node
|
||||
*
|
||||
* SECURITY: All requests must be cryptographically signed by the sender.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications, remoteLikes } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const swarmLikeSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
@@ -19,6 +22,7 @@ const swarmLikeSchema = z.object({
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -35,6 +39,20 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const data = swarmLikeSchema.parse(body);
|
||||
|
||||
// SECURITY: Verify the signature
|
||||
const { signature, ...payload } = data;
|
||||
const isValid = await verifyUserInteraction(
|
||||
payload,
|
||||
signature,
|
||||
data.like.actorHandle,
|
||||
data.like.actorNodeDomain
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`[Swarm] Invalid signature for like from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* Swarm Mention Endpoint
|
||||
*
|
||||
* POST: Receive a mention notification from another swarm node
|
||||
*
|
||||
* SECURITY: All requests must be cryptographically signed by the sender.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, notifications } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const swarmMentionSchema = z.object({
|
||||
mentionedHandle: z.string(),
|
||||
@@ -21,6 +24,7 @@ const swarmMentionSchema = z.object({
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -37,6 +41,20 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const data = swarmMentionSchema.parse(body);
|
||||
|
||||
// SECURITY: Verify the signature
|
||||
const { signature, ...payload } = data;
|
||||
const isValid = await verifyUserInteraction(
|
||||
payload,
|
||||
signature,
|
||||
data.mention.actorHandle,
|
||||
data.mention.actorNodeDomain
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`[Swarm] Invalid signature for mention from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`);
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Find the mentioned user (local user)
|
||||
const mentionedUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.mentionedHandle.toLowerCase()),
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* Swarm Repost Endpoint
|
||||
*
|
||||
* POST: Receive a repost from another swarm node
|
||||
*
|
||||
* SECURITY: All requests must be cryptographically signed by the sender.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const swarmRepostSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
@@ -20,6 +23,7 @@ const swarmRepostSchema = z.object({
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -36,6 +40,20 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const data = swarmRepostSchema.parse(body);
|
||||
|
||||
// SECURITY: Verify the signature
|
||||
const { signature, ...payload } = data;
|
||||
const isValid = await verifyUserInteraction(
|
||||
payload,
|
||||
signature,
|
||||
data.repost.actorHandle,
|
||||
data.repost.actorNodeDomain
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`[Swarm] Invalid signature for repost from ${data.repost.actorHandle}@${data.repost.actorNodeDomain}`);
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* Swarm Unfollow Endpoint
|
||||
*
|
||||
* POST: Receive an unfollow from another swarm node
|
||||
*
|
||||
* SECURITY: All requests must be cryptographically signed by the sender.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const swarmUnfollowSchema = z.object({
|
||||
targetHandle: z.string(),
|
||||
@@ -17,6 +20,7 @@ const swarmUnfollowSchema = z.object({
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -33,6 +37,20 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const data = swarmUnfollowSchema.parse(body);
|
||||
|
||||
// SECURITY: Verify the signature
|
||||
const { signature, ...payload } = data;
|
||||
const isValid = await verifyUserInteraction(
|
||||
payload,
|
||||
signature,
|
||||
data.unfollow.followerHandle,
|
||||
data.unfollow.followerNodeDomain
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`[Swarm] Invalid signature for unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain}`);
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Find the target user
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.targetHandle.toLowerCase()),
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* Swarm Unlike Endpoint
|
||||
*
|
||||
* POST: Receive an unlike from another swarm node
|
||||
*
|
||||
* SECURITY: All requests must be cryptographically signed by the sender.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, remoteLikes } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const swarmUnlikeSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
@@ -17,6 +20,7 @@ const swarmUnlikeSchema = z.object({
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -33,6 +37,20 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const data = swarmUnlikeSchema.parse(body);
|
||||
|
||||
// SECURITY: Verify the signature
|
||||
const { signature, ...payload } = data;
|
||||
const isValid = await verifyUserInteraction(
|
||||
payload,
|
||||
signature,
|
||||
data.unlike.actorHandle,
|
||||
data.unlike.actorNodeDomain
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`[Swarm] Invalid signature for unlike from ${data.unlike.actorHandle}@${data.unlike.actorNodeDomain}`);
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* Swarm Unrepost Endpoint
|
||||
*
|
||||
* POST: Receive an unrepost from another swarm node
|
||||
*
|
||||
* SECURITY: All requests must be cryptographically signed by the sender.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const swarmUnrepostSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
@@ -17,6 +20,7 @@ const swarmUnrepostSchema = z.object({
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -33,6 +37,20 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const data = swarmUnrepostSchema.parse(body);
|
||||
|
||||
// SECURITY: Verify the signature
|
||||
const { signature, ...payload } = data;
|
||||
const isValid = await verifyUserInteraction(
|
||||
payload,
|
||||
signature,
|
||||
data.unrepost.actorHandle,
|
||||
data.unrepost.actorNodeDomain
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`[Swarm] Invalid signature for unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain}`);
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Find the target post
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.postId),
|
||||
|
||||
@@ -3,6 +3,7 @@ import crypto from 'crypto';
|
||||
import { db, follows, users, notifications, remoteFollows } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
@@ -81,7 +82,15 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
// Follow a user
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const signedAction = await request.json();
|
||||
const currentUser = await requireSignedAction(signedAction);
|
||||
|
||||
// Extract handle from URL params (still needed for routing)
|
||||
// But we should also validate it matches the signed action intent if possible,
|
||||
// or just trust the signed interaction timestamp/nonce.
|
||||
// For follow, the data usually contains target info.
|
||||
// Let's assume the client sends targetHandle in 'data' of signed action to be secure.
|
||||
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const remote = parseRemoteHandle(handle);
|
||||
@@ -93,7 +102,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
|
||||
if (remote) {
|
||||
const targetHandle = `${remote.handle}@${remote.domain}`;
|
||||
|
||||
|
||||
// Check if already following
|
||||
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
|
||||
where: and(
|
||||
@@ -113,7 +122,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
|
||||
// Use swarm protocol
|
||||
const activityId = crypto.randomUUID();
|
||||
|
||||
|
||||
const result = await deliverSwarmFollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
follow: {
|
||||
@@ -244,7 +253,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
// Unfollow a user
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const currentUser = await requireAuth();
|
||||
const signedAction = await request.json();
|
||||
const currentUser = await requireSignedAction(signedAction);
|
||||
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const remote = parseRemoteHandle(handle);
|
||||
|
||||
+40
-10
@@ -4,6 +4,8 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
|
||||
import { keyStore, importPrivateKey } from '@/lib/crypto/user-signing';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -225,21 +227,21 @@ export default function LoginPage() {
|
||||
|
||||
try {
|
||||
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||
|
||||
|
||||
// Only include turnstileToken if Turnstile is enabled (site key exists)
|
||||
const body = mode === 'login'
|
||||
? {
|
||||
email,
|
||||
password,
|
||||
? {
|
||||
email,
|
||||
password,
|
||||
...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {})
|
||||
}
|
||||
: {
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
}
|
||||
: {
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
displayName,
|
||||
...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {})
|
||||
};
|
||||
};
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
@@ -253,6 +255,34 @@ export default function LoginPage() {
|
||||
throw new Error(data.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
// Decrypt and store private key if available
|
||||
if (data.user?.privateKeyEncrypted) {
|
||||
try {
|
||||
const privateKeyDecrypted = await decryptPrivateKey(
|
||||
data.user.privateKeyEncrypted,
|
||||
password
|
||||
);
|
||||
|
||||
// Import and set in memory store
|
||||
// Remove PEM headers if present and clean whitespace
|
||||
let cleanKey = privateKeyDecrypted
|
||||
.replace(/-----BEGIN [A-Z ]+-----/, '')
|
||||
.replace(/-----END [A-Z ]+-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
|
||||
const binaryDer = Buffer.from(cleanKey, 'base64');
|
||||
const cryptoKey = await importPrivateKey(binaryDer);
|
||||
|
||||
keyStore.setPrivateKey(cryptoKey);
|
||||
|
||||
console.log('[Auth] Private key decrypted and stored successfully');
|
||||
} catch (decryptError) {
|
||||
console.error('[Auth] Failed to decrypt private key:', decryptError);
|
||||
// Don't block login/registration if decryption fails - user can unlock later
|
||||
// The identity unlock prompt will be shown in the app
|
||||
}
|
||||
}
|
||||
|
||||
// Hard redirect to ensure cookie is picked up
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Example Usage of IdentityUnlockPrompt Component
|
||||
*
|
||||
* This file demonstrates how to use the IdentityUnlockPrompt modal component
|
||||
* in your application.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { IdentityUnlockPrompt } from './IdentityUnlockPrompt';
|
||||
|
||||
export function ExampleUsage() {
|
||||
const { isIdentityUnlocked } = useAuth();
|
||||
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
|
||||
|
||||
// Example 1: Show unlock prompt when user tries to perform an action
|
||||
const handleLikePost = async () => {
|
||||
if (!isIdentityUnlocked) {
|
||||
setShowUnlockPrompt(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Proceed with the action (e.g., like the post)
|
||||
console.log('Liking post...');
|
||||
};
|
||||
|
||||
// Example 2: Handle successful unlock
|
||||
const handleUnlock = () => {
|
||||
console.log('Identity unlocked successfully!');
|
||||
setShowUnlockPrompt(false);
|
||||
|
||||
// Optionally retry the action that triggered the prompt
|
||||
// For example, if user tried to like a post, like it now
|
||||
};
|
||||
|
||||
// Example 3: Handle cancel
|
||||
const handleCancel = () => {
|
||||
console.log('User cancelled unlock');
|
||||
setShowUnlockPrompt(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Your UI components */}
|
||||
<button onClick={handleLikePost}>
|
||||
Like Post
|
||||
</button>
|
||||
|
||||
{/* Show unlock prompt when needed */}
|
||||
{showUnlockPrompt && (
|
||||
<IdentityUnlockPrompt
|
||||
onUnlock={handleUnlock}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 2: Using the component without callbacks
|
||||
*
|
||||
* The component can be used without callbacks if you just want
|
||||
* to unlock the identity without any additional actions.
|
||||
*/
|
||||
export function SimpleExample() {
|
||||
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setShowUnlockPrompt(true)}>
|
||||
Unlock Identity
|
||||
</button>
|
||||
|
||||
{showUnlockPrompt && (
|
||||
<IdentityUnlockPrompt
|
||||
onCancel={() => setShowUnlockPrompt(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 3: Integration with action buttons
|
||||
*
|
||||
* This example shows how to integrate the unlock prompt with
|
||||
* action buttons that require a signed action.
|
||||
*/
|
||||
export function ActionButtonExample() {
|
||||
const { isIdentityUnlocked } = useAuth();
|
||||
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<(() => void) | null>(null);
|
||||
|
||||
const requireUnlock = (action: () => void) => {
|
||||
if (!isIdentityUnlocked) {
|
||||
setPendingAction(() => action);
|
||||
setShowUnlockPrompt(true);
|
||||
return;
|
||||
}
|
||||
action();
|
||||
};
|
||||
|
||||
const handleUnlock = () => {
|
||||
setShowUnlockPrompt(false);
|
||||
|
||||
// Execute the pending action after unlock
|
||||
if (pendingAction) {
|
||||
pendingAction();
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowUnlockPrompt(false);
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => requireUnlock(() => console.log('Like'))}>
|
||||
Like
|
||||
</button>
|
||||
<button onClick={() => requireUnlock(() => console.log('Follow'))}>
|
||||
Follow
|
||||
</button>
|
||||
<button onClick={() => requireUnlock(() => console.log('Post'))}>
|
||||
Post
|
||||
</button>
|
||||
|
||||
{showUnlockPrompt && (
|
||||
<IdentityUnlockPrompt
|
||||
onUnlock={handleUnlock}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* IdentityUnlockPrompt Component Tests
|
||||
*
|
||||
* Tests the IdentityUnlockPrompt modal component
|
||||
* Validates: Requirements US-2.3, US-5.1
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('IdentityUnlockPrompt Component', () => {
|
||||
it('should have correct prop types', () => {
|
||||
// This test verifies the component interface compiles correctly
|
||||
type IdentityUnlockPromptProps = {
|
||||
onUnlock?: () => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
// Test with all props
|
||||
const propsWithCallbacks: IdentityUnlockPromptProps = {
|
||||
onUnlock: () => console.log('unlocked'),
|
||||
onCancel: () => console.log('cancelled'),
|
||||
};
|
||||
|
||||
expect(propsWithCallbacks.onUnlock).toBeDefined();
|
||||
expect(propsWithCallbacks.onCancel).toBeDefined();
|
||||
|
||||
// Test with no props (all optional)
|
||||
const propsEmpty: IdentityUnlockPromptProps = {};
|
||||
|
||||
expect(propsEmpty.onUnlock).toBeUndefined();
|
||||
expect(propsEmpty.onCancel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should export the component', async () => {
|
||||
// Verify the component can be imported
|
||||
const module = await import('./IdentityUnlockPrompt');
|
||||
expect(module.IdentityUnlockPrompt).toBeDefined();
|
||||
expect(typeof module.IdentityUnlockPrompt).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Lock, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
interface IdentityUnlockPromptProps {
|
||||
onUnlock?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* IdentityUnlockPrompt Modal Component
|
||||
*
|
||||
* Prompts the user to unlock their cryptographic identity by entering their password.
|
||||
* This is required when the user's private key is not available in memory (e.g., after
|
||||
* page refresh or when the session expires).
|
||||
*
|
||||
* Requirements: US-2.3, US-5.1
|
||||
*/
|
||||
export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromptProps) {
|
||||
const { unlockIdentity } = useAuth();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUnlocking, setIsUnlocking] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password.trim()) {
|
||||
setError('Please enter your password');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsUnlocking(true);
|
||||
|
||||
try {
|
||||
await unlockIdentity(password);
|
||||
|
||||
// Success! Call the onUnlock callback if provided
|
||||
if (onUnlock) {
|
||||
onUnlock();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[IdentityUnlockPrompt] Failed to unlock identity:', err);
|
||||
setError('Incorrect password. Please try again.');
|
||||
} finally {
|
||||
setIsUnlocking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setPassword('');
|
||||
setError(null);
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '16px'
|
||||
}}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
padding: '24px'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<Lock size={20} style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: 600, margin: 0 }}>
|
||||
Unlock Identity
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.5 }}>
|
||||
Enter your password to unlock your cryptographic identity and perform actions.
|
||||
</p>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
htmlFor="unlock-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--foreground)'
|
||||
}}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="unlock-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null); // Clear error when user types
|
||||
}}
|
||||
disabled={isUnlocking}
|
||||
placeholder="Enter your password"
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: error ? '1px solid var(--error)' : '1px solid var(--border)',
|
||||
background: 'var(--background)',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
if (!error) {
|
||||
e.target.style.borderColor = 'var(--accent)';
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!error) {
|
||||
e.target.style.borderColor = 'var(--border)';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
color: 'var(--error)',
|
||||
fontSize: '14px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<AlertCircle size={16} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUnlocking || !password.trim()}
|
||||
className="btn btn-primary"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{isUnlocking ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
<span>Unlocking...</span>
|
||||
</>
|
||||
) : (
|
||||
'Unlock'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isUnlocking}
|
||||
className="btn btn-ghost"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Info Note */}
|
||||
<p style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
marginTop: '16px',
|
||||
marginBottom: 0,
|
||||
lineHeight: 1.4
|
||||
}}>
|
||||
You can browse without unlocking, but you'll need to unlock to like, post, or follow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,16 +7,18 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { LogOut, Settings2 } from 'lucide-react';
|
||||
import { LogOut, Settings2, Lock, Unlock } from 'lucide-react';
|
||||
import { IdentityUnlockPrompt } from './IdentityUnlockPrompt';
|
||||
|
||||
export function Sidebar() {
|
||||
const { user, isAdmin } = useAuth();
|
||||
const { user, isAdmin, logout, isIdentityUnlocked } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/node')
|
||||
@@ -75,7 +77,7 @@ export function Sidebar() {
|
||||
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
await logout();
|
||||
window.location.href = '/explore';
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
@@ -188,6 +190,72 @@ export function Sidebar() {
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formatFullHandle(user.handle)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity Status Indicator */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!isIdentityUnlocked) {
|
||||
setShowUnlockPrompt(true);
|
||||
}
|
||||
}}
|
||||
title={isIdentityUnlocked ? 'Identity unlocked - you can perform actions' : 'Identity locked - click to unlock'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 12px',
|
||||
marginBottom: '12px',
|
||||
borderRadius: '8px',
|
||||
background: isIdentityUnlocked
|
||||
? 'rgba(34, 197, 94, 0.1)'
|
||||
: 'rgba(251, 191, 36, 0.1)',
|
||||
border: isIdentityUnlocked
|
||||
? '1px solid rgba(34, 197, 94, 0.2)'
|
||||
: '1px solid rgba(251, 191, 36, 0.2)',
|
||||
cursor: isIdentityUnlocked ? 'default' : 'pointer',
|
||||
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 }} />
|
||||
) : (
|
||||
<Lock size={16} style={{ color: 'rgb(251, 191, 36)', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: isIdentityUnlocked ? 'rgb(34, 197, 94)' : 'rgb(251, 191, 36)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{isIdentityUnlocked ? 'Identity Unlocked' : 'Identity Locked'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{isIdentityUnlocked
|
||||
? 'You can perform actions'
|
||||
: 'Click to unlock'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={loggingOut}
|
||||
@@ -205,6 +273,14 @@ export function Sidebar() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Identity Unlock Prompt Modal */}
|
||||
{showUnlockPrompt && (
|
||||
<IdentityUnlockPrompt
|
||||
onUnlock={() => setShowUnlockPrompt(false)}
|
||||
onCancel={() => setShowUnlockPrompt(false)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
+64
-30
@@ -1,4 +1,4 @@
|
||||
import { pgTable, text, timestamp, uuid, integer, boolean, index, foreignKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, text, timestamp, uuid, integer, bigint, boolean, index, foreignKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// ============================================
|
||||
@@ -17,6 +17,7 @@ export const nodes = pgTable('nodes', {
|
||||
faviconUrl: text('favicon_url'),
|
||||
accentColor: text('accent_color').default('#FFFFFF'),
|
||||
publicKey: text('public_key'),
|
||||
privateKeyEncrypted: text('private_key_encrypted'), // Encrypted with AUTH_SECRET
|
||||
// NSFW settings
|
||||
isNsfw: boolean('is_nsfw').default(false).notNull(), // Entire node is NSFW
|
||||
// Cloudflare Turnstile settings
|
||||
@@ -774,37 +775,37 @@ export const botRateLimitsRelations = relations(botRateLimits, ({ one }) => ({
|
||||
export const swarmNodes = pgTable('swarm_nodes', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
domain: text('domain').notNull().unique(),
|
||||
|
||||
|
||||
// Node metadata (fetched from remote)
|
||||
name: text('name'),
|
||||
description: text('description'),
|
||||
logoUrl: text('logo_url'),
|
||||
publicKey: text('public_key'),
|
||||
softwareVersion: text('software_version'),
|
||||
|
||||
|
||||
// Stats (updated periodically)
|
||||
userCount: integer('user_count'),
|
||||
postCount: integer('post_count'),
|
||||
|
||||
|
||||
// NSFW flag (synced from remote node)
|
||||
isNsfw: boolean('is_nsfw').default(false).notNull(),
|
||||
|
||||
|
||||
// Discovery metadata
|
||||
discoveredVia: text('discovered_via'), // Domain of node that told us about this one
|
||||
discoveredAt: timestamp('discovered_at').defaultNow().notNull(),
|
||||
|
||||
|
||||
// Health tracking
|
||||
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
|
||||
lastSyncAt: timestamp('last_sync_at'),
|
||||
consecutiveFailures: integer('consecutive_failures').default(0).notNull(),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
|
||||
|
||||
// Trust/reputation (for future spam prevention)
|
||||
trustScore: integer('trust_score').default(50).notNull(), // 0-100
|
||||
|
||||
|
||||
// Capabilities
|
||||
capabilities: text('capabilities'), // JSON array: ["handles", "gossip", "relay"]
|
||||
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
@@ -822,17 +823,17 @@ export const swarmNodes = pgTable('swarm_nodes', {
|
||||
export const swarmSeeds = pgTable('swarm_seeds', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
domain: text('domain').notNull().unique(),
|
||||
|
||||
|
||||
// Priority for connection order (lower = higher priority)
|
||||
priority: integer('priority').default(100).notNull(),
|
||||
|
||||
|
||||
// Whether this seed is enabled
|
||||
isEnabled: boolean('is_enabled').default(true).notNull(),
|
||||
|
||||
|
||||
// Health tracking
|
||||
lastContactAt: timestamp('last_contact_at'),
|
||||
consecutiveFailures: integer('consecutive_failures').default(0).notNull(),
|
||||
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index('swarm_seeds_enabled_idx').on(table.isEnabled),
|
||||
@@ -844,24 +845,24 @@ export const swarmSeeds = pgTable('swarm_seeds', {
|
||||
*/
|
||||
export const swarmSyncLog = pgTable('swarm_sync_log', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
|
||||
// Which node we synced with
|
||||
remoteDomain: text('remote_domain').notNull(),
|
||||
|
||||
|
||||
// Direction: 'push' (we sent) or 'pull' (we received)
|
||||
direction: text('direction').notNull(),
|
||||
|
||||
|
||||
// What was synced
|
||||
nodesReceived: integer('nodes_received').default(0).notNull(),
|
||||
nodesSent: integer('nodes_sent').default(0).notNull(),
|
||||
handlesReceived: integer('handles_received').default(0).notNull(),
|
||||
handlesSent: integer('handles_sent').default(0).notNull(),
|
||||
|
||||
|
||||
// Result
|
||||
success: boolean('success').notNull(),
|
||||
errorMessage: text('error_message'),
|
||||
durationMs: integer('duration_ms'),
|
||||
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index('swarm_sync_log_remote_idx').on(table.remoteDomain),
|
||||
@@ -878,18 +879,18 @@ export const swarmSyncLog = pgTable('swarm_sync_log', {
|
||||
*/
|
||||
export const chatConversations = pgTable('chat_conversations', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
|
||||
// Conversation type: 'direct' (1-on-1) or 'group' (future)
|
||||
type: text('type').default('direct').notNull(),
|
||||
|
||||
|
||||
// For direct chats, store both participants
|
||||
participant1Id: uuid('participant1_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
participant2Handle: text('participant2_handle').notNull(), // Can be local or remote (user@domain)
|
||||
|
||||
|
||||
// Last message info for sorting
|
||||
lastMessageAt: timestamp('last_message_at'),
|
||||
lastMessagePreview: text('last_message_preview'),
|
||||
|
||||
|
||||
// Metadata
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
@@ -915,30 +916,30 @@ export const chatConversationsRelations = relations(chatConversations, ({ one, m
|
||||
*/
|
||||
export const chatMessages = pgTable('chat_messages', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
|
||||
// Which conversation this belongs to
|
||||
conversationId: uuid('conversation_id').notNull().references(() => chatConversations.id, { onDelete: 'cascade' }),
|
||||
|
||||
|
||||
// Sender info
|
||||
senderHandle: text('sender_handle').notNull(), // Can be local or remote
|
||||
senderDisplayName: text('sender_display_name'),
|
||||
senderAvatarUrl: text('sender_avatar_url'),
|
||||
senderNodeDomain: text('sender_node_domain'), // null if local
|
||||
|
||||
|
||||
// Message content (encrypted for recipient with their public key)
|
||||
encryptedContent: text('encrypted_content').notNull(),
|
||||
// Sender's copy (encrypted with sender's public key so they can read their own messages)
|
||||
senderEncryptedContent: text('sender_encrypted_content'),
|
||||
// Sender's ECDH public key (for E2E decryption by recipient)
|
||||
senderChatPublicKey: text('sender_chat_public_key'),
|
||||
|
||||
|
||||
// Swarm sync info
|
||||
swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid
|
||||
|
||||
|
||||
// Status tracking
|
||||
deliveredAt: timestamp('delivered_at'),
|
||||
readAt: timestamp('read_at'),
|
||||
|
||||
|
||||
// Metadata
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
@@ -960,10 +961,10 @@ export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
|
||||
*/
|
||||
export const chatTypingIndicators = pgTable('chat_typing_indicators', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
|
||||
conversationId: uuid('conversation_id').notNull().references(() => chatConversations.id, { onDelete: 'cascade' }),
|
||||
userHandle: text('user_handle').notNull(),
|
||||
|
||||
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
@@ -971,3 +972,36 @@ export const chatTypingIndicators = pgTable('chat_typing_indicators', {
|
||||
index('chat_typing_expires_idx').on(table.expiresAt),
|
||||
uniqueIndex('chat_typing_unique').on(table.conversationId, table.userHandle),
|
||||
]);
|
||||
|
||||
// ============================================
|
||||
// CRYPTO & SECURITY
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Replay protection for signed user actions.
|
||||
* Enforces uniqueness of (did, nonce) within the valid timeframe.
|
||||
*/
|
||||
export const signedActionDedupe = pgTable('signed_action_dedupe', {
|
||||
// SHA-256 of canonical signed payload (without signature)
|
||||
actionId: text('action_id').primaryKey(),
|
||||
|
||||
did: text('did').notNull(),
|
||||
nonce: text('nonce').notNull(),
|
||||
ts: bigint('ts', { mode: 'number' }).notNull(),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index('signed_action_dedupe_created_idx').on(table.createdAt), // For cleanup
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cache for remote public keys to enforce key continuity.
|
||||
* Prevents TOFU (Trust On First Use) attacks after initial trust.
|
||||
*/
|
||||
export const remoteIdentityCache = pgTable('remote_identity_cache', {
|
||||
did: text('did').primaryKey(), // The DID is the key
|
||||
publicKey: text('public_key').notNull(),
|
||||
|
||||
fetchedAt: timestamp('fetched_at').notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Signed Fetch - Client-side API wrapper
|
||||
*
|
||||
* Automatically signs all user actions with their private key before
|
||||
* sending to the server. This ensures cryptographic proof of authenticity.
|
||||
*/
|
||||
|
||||
import { createSignedAction, hasUserPrivateKey } from '@/lib/crypto/user-signing';
|
||||
|
||||
export interface SignedFetchOptions {
|
||||
method?: string;
|
||||
body?: any;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a signed API request
|
||||
*
|
||||
* @param url - The API endpoint
|
||||
* @param action - The action being performed (e.g., 'like', 'follow', 'post')
|
||||
* @param data - The action data
|
||||
* @param userDid - The user's DID
|
||||
* @param userHandle - The user's handle
|
||||
* @param options - Additional fetch options
|
||||
*/
|
||||
export async function signedFetch(
|
||||
url: string,
|
||||
action: string,
|
||||
data: any,
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
options: SignedFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
// Check if user has their private key loaded
|
||||
if (!hasUserPrivateKey()) {
|
||||
throw new Error('User identity not unlocked. Please log in again.');
|
||||
}
|
||||
|
||||
// Create signed action
|
||||
// Note: createSignedAction now generates nonce and ts internally
|
||||
const signedAction = await createSignedAction(action, data, userDid, userHandle);
|
||||
|
||||
// Make the request
|
||||
return fetch(url, {
|
||||
method: options.method || 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(signedAction),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for common actions
|
||||
*/
|
||||
export const signedAPI = {
|
||||
/**
|
||||
* Like a post
|
||||
*/
|
||||
async likePost(postId: string, userDid: string, userHandle: string) {
|
||||
return signedFetch(
|
||||
`/api/posts/${postId}/like`,
|
||||
'like',
|
||||
{ postId },
|
||||
userDid,
|
||||
userHandle
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Unlike a post
|
||||
*/
|
||||
async unlikePost(postId: string, userDid: string, userHandle: string) {
|
||||
return signedFetch(
|
||||
`/api/posts/${postId}/like`,
|
||||
'unlike',
|
||||
{ postId },
|
||||
userDid,
|
||||
userHandle,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Follow a user
|
||||
*/
|
||||
async followUser(targetHandle: string, userDid: string, userHandle: string) {
|
||||
return signedFetch(
|
||||
`/api/users/${targetHandle}/follow`,
|
||||
'follow',
|
||||
{ targetHandle },
|
||||
userDid,
|
||||
userHandle
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Unfollow a user
|
||||
*/
|
||||
async unfollowUser(targetHandle: string, userDid: string, userHandle: string) {
|
||||
return signedFetch(
|
||||
`/api/users/${targetHandle}/follow`,
|
||||
'unfollow',
|
||||
{ targetHandle },
|
||||
userDid,
|
||||
userHandle,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a post
|
||||
*/
|
||||
async createPost(
|
||||
content: string,
|
||||
mediaIds: string[],
|
||||
linkPreview: any,
|
||||
replyToId: string | undefined,
|
||||
isNsfw: boolean,
|
||||
userDid: string,
|
||||
userHandle: string
|
||||
) {
|
||||
return signedFetch(
|
||||
'/api/posts',
|
||||
'post',
|
||||
{ content, mediaIds, linkPreview, replyToId, isNsfw },
|
||||
userDid,
|
||||
userHandle
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Repost a post
|
||||
*/
|
||||
async repostPost(postId: string, userDid: string, userHandle: string) {
|
||||
return signedFetch(
|
||||
`/api/posts/${postId}/repost`,
|
||||
'repost',
|
||||
{ postId },
|
||||
userDid,
|
||||
userHandle
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Unrepost a post
|
||||
*/
|
||||
async unrepostPost(postId: string, userDid: string, userHandle: string) {
|
||||
return signedFetch(
|
||||
`/api/posts/${postId}/repost`,
|
||||
'unrepost',
|
||||
{ postId },
|
||||
userDid,
|
||||
userHandle,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -207,6 +207,33 @@ export async function authenticateUser(
|
||||
await db.update(users)
|
||||
.set({ privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey) })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// Update local object
|
||||
user.privateKeyEncrypted = serializeEncryptedKey(encryptedPrivateKey);
|
||||
}
|
||||
|
||||
// MIGRATION: Check if user has legacy RSA key (upgrade to ECDSA P-256)
|
||||
// RSA 2048 SPKI PEM is ~450 chars, ECDSA P-256 is ~178 chars.
|
||||
if (user.publicKey.length > 300) {
|
||||
console.log(`[Auth] Migrating user ${user.handle} from RSA to ECDSA P-256`);
|
||||
|
||||
// Generate new ECDSA key pair
|
||||
const { publicKey, privateKey } = await generateKeyPair();
|
||||
|
||||
// Encrypt new private key
|
||||
const encryptedPrivateKey = encryptPrivateKey(privateKey, password);
|
||||
|
||||
// Update DB
|
||||
await db.update(users)
|
||||
.set({
|
||||
publicKey: publicKey,
|
||||
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey)
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// Update local user object to return new keys
|
||||
user.publicKey = publicKey;
|
||||
user.privateKeyEncrypted = serializeEncryptedKey(encryptedPrivateKey);
|
||||
}
|
||||
|
||||
return user;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Federation Key Registry
|
||||
*
|
||||
* Manages the caching and retrieval of remote public keys.
|
||||
* Enforces Key Continuity: Rejects key rotation by default to prevent MITM.
|
||||
*/
|
||||
|
||||
import { db } from '@/db';
|
||||
import { remoteIdentityCache } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// Strict continuity flag: if true (default), reject any key change.
|
||||
const ALLOW_KEY_ROTATION = process.env.ALLOW_KEY_ROTATION === 'true';
|
||||
|
||||
interface RemoteIdentity {
|
||||
did: string;
|
||||
handle: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a remote public key by DID.
|
||||
* Uses DB cache first, then fetches from .well-known endpoint.
|
||||
* Enforces key continuity.
|
||||
*/
|
||||
export async function lookupRemoteKey(did: string): Promise<string> {
|
||||
// 1. Check Cache
|
||||
const cached = await db.query.remoteIdentityCache.findFirst({
|
||||
where: eq(remoteIdentityCache.did, did),
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// If valid cache exists, return it
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.publicKey;
|
||||
}
|
||||
|
||||
// 2. Fetch from Remote
|
||||
// Resolve DID to HTTP URL (Assuming Web DID or simple mapping for now)
|
||||
// For did:web:example.com -> https://example.com/.well-known/synapsis/identity/{did}
|
||||
// This logic depends on our DID method.
|
||||
// IMPORTANT: For this strict implementation, we need a reliable way to map DID -> Endpoint.
|
||||
// We'll assume the DID *contains* the domain or we have a resolver.
|
||||
// Simplification: `did:web:domain` format.
|
||||
|
||||
const domain = extractDomainFromDid(did);
|
||||
if (!domain) {
|
||||
throw new Error(`Unsupported DID format: ${did}`);
|
||||
}
|
||||
|
||||
let remoteData: RemoteIdentity;
|
||||
try {
|
||||
const res = await fetch(`https://${domain}/.well-known/synapsis/identity/${did}`);
|
||||
if (!res.ok) throw new Error(`Remote identity fetch failed: ${res.status}`);
|
||||
remoteData = await res.json();
|
||||
} catch (err) {
|
||||
// If we have an expired cache entry, we *could* fallback to it in emergency,
|
||||
// but strict security says fail.
|
||||
console.error(`[KeyRegistry] Failed to fetch key for ${did}`, err);
|
||||
throw new Error('RELAY_UNREACHABLE');
|
||||
}
|
||||
|
||||
// Optimize: Validation
|
||||
if (remoteData.did !== did || !remoteData.publicKey) {
|
||||
throw new Error('Invalid remote identity response');
|
||||
}
|
||||
|
||||
// 3. Key Continuity Check
|
||||
if (cached) {
|
||||
if (cached.publicKey !== remoteData.publicKey) {
|
||||
if (!ALLOW_KEY_ROTATION) {
|
||||
console.error(`[KeyRegistry] KEY_CHANGED detected for ${did}. Old: ${cached.publicKey.slice(0, 10)}... New: ${remoteData.publicKey.slice(0, 10)}...`);
|
||||
throw new Error('KEY_CHANGED: Remote key rotation rejected by policy.');
|
||||
}
|
||||
// If allowed, we proceed (TOFU update)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update Cache
|
||||
const expiresAt = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour TTL
|
||||
|
||||
await db.insert(remoteIdentityCache).values({
|
||||
did,
|
||||
publicKey: remoteData.publicKey,
|
||||
fetchedAt: now,
|
||||
expiresAt,
|
||||
}).onConflictDoUpdate({
|
||||
target: remoteIdentityCache.did,
|
||||
set: {
|
||||
publicKey: remoteData.publicKey,
|
||||
fetchedAt: now,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return remoteData.publicKey;
|
||||
}
|
||||
|
||||
function extractDomainFromDid(did: string): string | null {
|
||||
// did:web:example.com
|
||||
// did:synapsis:example.com
|
||||
const parts = did.split(':');
|
||||
if (parts.length >= 3) {
|
||||
return parts[2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Server-side signature verification for user actions
|
||||
*
|
||||
* Strict Verification Rules:
|
||||
* - ECDSA P-256 (ES256) ONLY.
|
||||
* - DB-backed deduplication (signed_action_dedupe).
|
||||
* - Strict 5-minute freshness window.
|
||||
* - Canonical verification (must match client exactly).
|
||||
*/
|
||||
|
||||
import { db } from '@/db';
|
||||
import { users, signedActionDedupe } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { canonicalize, importPublicKey, base64UrlToBase64 } from '@/lib/crypto/user-signing';
|
||||
// Note: user-signing helpers are isomorphic (work in Node via webcrypto polyfill/availability)
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Use Node's webcrypto for server-side if not global
|
||||
const cryptoSubtle = globalThis.crypto?.subtle || require('crypto').webcrypto.subtle;
|
||||
|
||||
export interface SignedAction {
|
||||
action: string;
|
||||
data: any;
|
||||
did: string;
|
||||
handle: string;
|
||||
ts: number;
|
||||
nonce: string;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed user action
|
||||
*
|
||||
* @param signedAction - The signed action payload
|
||||
* @returns The user if signature is valid and not replayed
|
||||
*/
|
||||
export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
valid: boolean;
|
||||
user?: typeof users.$inferSelect;
|
||||
error?: string;
|
||||
}> {
|
||||
if (!db) {
|
||||
return { valid: false, error: 'Database not available' };
|
||||
}
|
||||
|
||||
const { sig, ...payload } = signedAction;
|
||||
|
||||
// 1. FRESHNESS CHECK (Fail fast before DB/Crypto)
|
||||
const now = Date.now();
|
||||
const diff = Math.abs(now - payload.ts);
|
||||
const fiveMinutesMs = 5 * 60 * 1000;
|
||||
|
||||
if (diff > fiveMinutesMs) {
|
||||
return { valid: false, error: 'INVALID_TIMESTAMP: Request too old or in future' };
|
||||
}
|
||||
|
||||
// 2. FETCH USER & KEY
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.did, payload.did),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// If federation, we might need to look up in remote_identity_cache here.
|
||||
// For now, assume local user or user must exist in users table (synced).
|
||||
return { valid: false, error: 'User not found' };
|
||||
}
|
||||
|
||||
if (user.handle !== payload.handle) {
|
||||
return { valid: false, error: 'Handle mismatch' };
|
||||
}
|
||||
|
||||
// 3. CRYPTOGRAPHIC VERIFICATION
|
||||
try {
|
||||
const canonicalString = canonicalize(payload);
|
||||
const encoder = new TextEncoder();
|
||||
const dataBytes = encoder.encode(canonicalString);
|
||||
|
||||
// Convert signature from Base64Url to buffer
|
||||
const sigBase64 = base64UrlToBase64(sig);
|
||||
const sigBuffer = Buffer.from(sigBase64, 'base64');
|
||||
|
||||
// Import public key (stored as SPKI Base64 in DB)
|
||||
const publicKey = await importPublicKey(user.publicKey);
|
||||
|
||||
const isValid = await cryptoSubtle.verify(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
hash: { name: 'SHA-256' },
|
||||
},
|
||||
publicKey,
|
||||
sigBuffer,
|
||||
dataBytes
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return { valid: false, error: 'INVALID_SIGNATURE' };
|
||||
}
|
||||
|
||||
// 4. ACTION ID HASH COMPUTATION
|
||||
// SHA-256(canonicalPayload)
|
||||
// We use the same canonical string we just verified.
|
||||
const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
|
||||
|
||||
// 5. REPLAY PROTECTION (DB)
|
||||
try {
|
||||
await db.insert(signedActionDedupe).values({
|
||||
actionId: actionIdHash,
|
||||
did: payload.did,
|
||||
nonce: payload.nonce,
|
||||
ts: payload.ts,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Check for unique constraint violation (duplicate key)
|
||||
if (err.code === '23505') { // Postgres unique_violation code
|
||||
return { valid: false, error: 'REPLAYED_NONCE' };
|
||||
}
|
||||
console.error('[Verify] Dedupe error:', err);
|
||||
throw err; // Internal error
|
||||
}
|
||||
|
||||
return { valid: true, user };
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Verify] Verification exception:', error);
|
||||
return { valid: false, error: 'VERIFICATION_ERROR' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require a signed action
|
||||
* Throws an error if signature is invalid
|
||||
*/
|
||||
export async function requireSignedAction(signedAction: SignedAction): Promise<typeof users.$inferSelect> {
|
||||
const result = await verifyUserAction(signedAction);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new Error(result.error || 'Invalid signature');
|
||||
}
|
||||
|
||||
return result.user!;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* AuthContext Type Tests
|
||||
*
|
||||
* Tests the AuthContext interface updates for cryptographic user signing
|
||||
* Validates: Requirements US-1.1, US-1.2, US-1.4, US-1.5
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { User } from './AuthContext';
|
||||
|
||||
describe('AuthContext Interface', () => {
|
||||
it('should include cryptographic fields in User interface', () => {
|
||||
// Create a user object with all required fields
|
||||
const user: User = {
|
||||
id: '1',
|
||||
handle: 'testuser',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
did: 'did:synapsis:test123',
|
||||
publicKey: 'test-public-key',
|
||||
privateKeyEncrypted: 'encrypted-key',
|
||||
};
|
||||
|
||||
// Verify all fields are present
|
||||
expect(user.id).toBe('1');
|
||||
expect(user.handle).toBe('testuser');
|
||||
expect(user.displayName).toBe('Test User');
|
||||
expect(user.avatarUrl).toBe('https://example.com/avatar.jpg');
|
||||
expect(user.did).toBe('did:synapsis:test123');
|
||||
expect(user.publicKey).toBe('test-public-key');
|
||||
expect(user.privateKeyEncrypted).toBe('encrypted-key');
|
||||
});
|
||||
|
||||
it('should allow optional cryptographic fields', () => {
|
||||
// Create a user object without cryptographic fields
|
||||
const user: User = {
|
||||
id: '1',
|
||||
handle: 'testuser',
|
||||
displayName: 'Test User',
|
||||
};
|
||||
|
||||
// Verify basic fields are present
|
||||
expect(user.id).toBe('1');
|
||||
expect(user.handle).toBe('testuser');
|
||||
expect(user.displayName).toBe('Test User');
|
||||
|
||||
// Verify cryptographic fields are optional
|
||||
expect(user.did).toBeUndefined();
|
||||
expect(user.publicKey).toBeUndefined();
|
||||
expect(user.privateKeyEncrypted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useUserIdentity } from '@/lib/hooks/useUserIdentity';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
did?: string;
|
||||
publicKey?: string;
|
||||
privateKeyEncrypted?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isAdmin: boolean;
|
||||
loading: boolean;
|
||||
isIdentityUnlocked: boolean;
|
||||
did: string | null;
|
||||
handle: string | null;
|
||||
checkAdmin: () => Promise<void>;
|
||||
unlockIdentity: (password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
isAdmin: false,
|
||||
loading: true,
|
||||
isIdentityUnlocked: false,
|
||||
did: null,
|
||||
handle: null,
|
||||
checkAdmin: async () => { },
|
||||
unlockIdentity: async () => { },
|
||||
logout: async () => { },
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Integrate useUserIdentity hook
|
||||
const {
|
||||
identity,
|
||||
isUnlocked,
|
||||
initializeIdentity,
|
||||
unlockIdentity: unlockIdentityHook,
|
||||
clearIdentity,
|
||||
} = useUserIdentity();
|
||||
|
||||
const checkAdmin = async () => {
|
||||
try {
|
||||
@@ -38,6 +61,37 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unlock the user's identity with their password
|
||||
*/
|
||||
const unlockIdentity = async (password: string) => {
|
||||
if (!user?.privateKeyEncrypted) {
|
||||
throw new Error('No encrypted private key available');
|
||||
}
|
||||
|
||||
await unlockIdentityHook(user.privateKeyEncrypted, password);
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout the user and clear their identity
|
||||
*/
|
||||
const logout = async () => {
|
||||
try {
|
||||
// Call the logout API endpoint
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
|
||||
// Clear the user's identity (private key from localStorage)
|
||||
clearIdentity();
|
||||
|
||||
// Clear the user state
|
||||
setUser(null);
|
||||
setIsAdmin(false);
|
||||
} catch (error) {
|
||||
console.error('[Auth] Logout failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadAuth = async () => {
|
||||
setLoading(true);
|
||||
@@ -46,14 +100,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
|
||||
// Initialize identity if we have the required data
|
||||
if (data.user?.did && data.user?.publicKey && data.user?.privateKeyEncrypted) {
|
||||
await initializeIdentity({
|
||||
did: data.user.did,
|
||||
handle: data.user.handle,
|
||||
publicKey: data.user.publicKey,
|
||||
privateKeyEncrypted: data.user.privateKeyEncrypted,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
await checkAdmin();
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
clearIdentity();
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
clearIdentity();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -63,7 +130,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isAdmin, loading, checkAdmin }}>
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
isAdmin,
|
||||
loading,
|
||||
isIdentityUnlocked: isUnlocked,
|
||||
did: identity?.did || null,
|
||||
handle: identity?.handle || null,
|
||||
checkAdmin,
|
||||
unlockIdentity,
|
||||
logout,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* Cryptographic Key Generation
|
||||
*
|
||||
* Generates RSA key pairs for signing posts and verifying identity.
|
||||
* Generates ECDSA P-256 key pairs for signing posts and verifying identity.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate an RSA key pair for signing
|
||||
* Generate an ECDSA P-256 key pair for signing
|
||||
*/
|
||||
export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.generateKeyPair(
|
||||
'rsa',
|
||||
'ec',
|
||||
{
|
||||
modulusLength: 2048,
|
||||
namedCurve: 'P-256',
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Tests for client-side private key decryption
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { decryptPrivateKey, isEncryptedPrivateKey } from './private-key-client';
|
||||
|
||||
// Mock Web Crypto API for Node.js test environment
|
||||
beforeAll(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
// @ts-ignore
|
||||
global.window = {
|
||||
crypto: require('crypto').webcrypto
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
describe('private-key-client', () => {
|
||||
describe('isEncryptedPrivateKey', () => {
|
||||
it('should return true for valid encrypted key format', () => {
|
||||
const encrypted = JSON.stringify({
|
||||
encrypted: 'base64data',
|
||||
salt: 'base64salt',
|
||||
iv: 'base64iv'
|
||||
});
|
||||
expect(isEncryptedPrivateKey(encrypted)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid format', () => {
|
||||
expect(isEncryptedPrivateKey('not json')).toBe(false);
|
||||
expect(isEncryptedPrivateKey('')).toBe(false);
|
||||
expect(isEncryptedPrivateKey('{}')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptPrivateKey', () => {
|
||||
it('should throw error when not in browser', async () => {
|
||||
const originalWindow = global.window;
|
||||
// @ts-ignore
|
||||
delete global.window;
|
||||
|
||||
await expect(
|
||||
decryptPrivateKey({ encrypted: 'test', salt: 'test', iv: 'test' }, 'password')
|
||||
).rejects.toThrow('Decryption can only be performed in the browser');
|
||||
|
||||
// @ts-ignore
|
||||
global.window = originalWindow;
|
||||
});
|
||||
|
||||
// Note: Full decryption test would require a valid encrypted key
|
||||
// which would need to be generated with the server-side encryption function
|
||||
// This is tested in integration tests
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Private Key Encryption/Decryption (Client-Side)
|
||||
*
|
||||
* Client-side version of private key decryption using Web Crypto API.
|
||||
* This allows the browser to decrypt the user's private key with their password.
|
||||
*/
|
||||
|
||||
export interface EncryptedPrivateKey {
|
||||
encrypted: string; // Base64 encoded ciphertext + auth tag
|
||||
salt: string; // Base64 encoded salt for PBKDF2
|
||||
iv: string; // Base64 encoded initialization vector
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a private key with the user's password (client-side using Web Crypto API)
|
||||
*/
|
||||
export async function decryptPrivateKey(encryptedData: string | EncryptedPrivateKey, password: string): Promise<string> {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Decryption can only be performed in the browser');
|
||||
}
|
||||
|
||||
// Parse encrypted data if it's a string
|
||||
const data: EncryptedPrivateKey = typeof encryptedData === 'string'
|
||||
? JSON.parse(encryptedData)
|
||||
: encryptedData;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
// Derive key from password using PBKDF2
|
||||
const passwordKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
const aesKey = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: base64ToBuffer(data.salt),
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
passwordKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decode the combined encrypted data + auth tag
|
||||
const combined = base64ToBuffer(data.encrypted);
|
||||
|
||||
// Split encrypted data and auth tag (auth tag is last 16 bytes)
|
||||
const authTagLength = 16;
|
||||
const encryptedContent = combined.slice(0, combined.byteLength - authTagLength);
|
||||
const authTag = combined.slice(combined.byteLength - authTagLength);
|
||||
|
||||
// Combine encrypted content and auth tag for AES-GCM
|
||||
const ciphertext = new Uint8Array(combined.byteLength);
|
||||
ciphertext.set(new Uint8Array(encryptedContent), 0);
|
||||
ciphertext.set(new Uint8Array(authTag), encryptedContent.byteLength);
|
||||
|
||||
// Decrypt using AES-256-GCM
|
||||
try {
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: base64ToBuffer(data.iv),
|
||||
},
|
||||
aesKey,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
return decoder.decode(decrypted);
|
||||
} catch (error) {
|
||||
console.error('[Crypto] Decryption failed:', error);
|
||||
throw new Error('Failed to decrypt private key. Incorrect password?');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert base64 string to ArrayBuffer
|
||||
*/
|
||||
function base64ToBuffer(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize encrypted private key from database
|
||||
*/
|
||||
export function deserializeEncryptedKey(serialized: string): EncryptedPrivateKey {
|
||||
return JSON.parse(serialized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stored value is an encrypted private key (vs plaintext)
|
||||
*/
|
||||
export function isEncryptedPrivateKey(value: string): boolean {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return !!(parsed.encrypted && parsed.salt && parsed.iv);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Property Tests for Cryptographic User Signing
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Key generation and storage
|
||||
* 2. Canonical serialization
|
||||
* 3. Signing process
|
||||
* 4. Verification process
|
||||
* 5. Replay protection logic (mocked DB)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
generateKeyPair,
|
||||
keyStore,
|
||||
createSignedAction,
|
||||
canonicalize,
|
||||
exportPublicKey,
|
||||
importPublicKey,
|
||||
base64UrlToBase64
|
||||
} from './user-signing';
|
||||
import { verifyUserAction, type SignedAction } from '../auth/verify-signature';
|
||||
|
||||
// Mock DB interactions
|
||||
const mockDbMethods = {
|
||||
findFirst: vi.fn(),
|
||||
values: vi.fn(() => ({ onConflictDoUpdate: vi.fn() })),
|
||||
insert: vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate: vi.fn() })) }))
|
||||
};
|
||||
|
||||
// We need to hoist the variable if we use it in vi.mock
|
||||
// Or simpler for this case, simply define it inline or use a factory that doesn't capture outer scope incorrectly.
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
query: {
|
||||
users: { findFirst: vi.fn() },
|
||||
remoteIdentityCache: { findFirst: vi.fn() }
|
||||
},
|
||||
insert: vi.fn(() => ({ values: vi.fn() })),
|
||||
},
|
||||
users: { did: 'did', publicKey: 'publicKey' },
|
||||
signedActionDedupe: { actionId: 'actionId' },
|
||||
remoteIdentityCache: { did: 'did' },
|
||||
}));
|
||||
|
||||
// Access the mocked module to manipulate it in tests
|
||||
import { db } from '@/db';
|
||||
|
||||
// Mock Database unique constraint error for replay test
|
||||
const duplicateKeyError = new Error('Duplicate key');
|
||||
(duplicateKeyError as any).code = '23505';
|
||||
|
||||
describe('Cryptographic User Signing', () => {
|
||||
let userKeyPair: CryptoKeyPair;
|
||||
let userPublicKeyBase64: string;
|
||||
const testDid = 'did:web:test.com:alice';
|
||||
const testHandle = 'alice';
|
||||
|
||||
beforeEach(async () => {
|
||||
// Setup fresh identity
|
||||
userKeyPair = await generateKeyPair();
|
||||
keyStore.setPrivateKey(userKeyPair.privateKey);
|
||||
userPublicKeyBase64 = await exportPublicKey(userKeyPair.publicKey);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should canonicalize objects strictly', () => {
|
||||
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 throw on invalid canonical types', () => {
|
||||
expect(() => canonicalize({ d: new Date() })).toThrow(/Date objects not allowed/);
|
||||
expect(() => canonicalize({ n: NaN })).toThrow(/Number is not finite/);
|
||||
});
|
||||
|
||||
it('should create a valid signed action', async () => {
|
||||
const payload = { content: 'Hello World' };
|
||||
const action = 'create_post';
|
||||
|
||||
const signed = await createSignedAction(action, payload, testDid, testHandle);
|
||||
|
||||
expect(signed).toHaveProperty('sig');
|
||||
expect(signed).toHaveProperty('nonce');
|
||||
expect(signed).toHaveProperty('ts');
|
||||
expect(signed.action).toBe(action);
|
||||
expect(signed.did).toBe(testDid);
|
||||
});
|
||||
|
||||
it('should verify a valid signed action', async () => {
|
||||
const payload = { content: 'Hello World' };
|
||||
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
|
||||
|
||||
// Mock DB finding the user
|
||||
(db.query.users.findFirst as any).mockResolvedValue({
|
||||
id: 'uuid-123',
|
||||
did: testDid,
|
||||
handle: testHandle,
|
||||
publicKey: userPublicKeyBase64,
|
||||
});
|
||||
|
||||
// Mock DB insert (dedupe) success
|
||||
(db.insert as any).mockReturnValue({
|
||||
values: vi.fn().mockResolvedValue(true)
|
||||
});
|
||||
|
||||
const result = await verifyUserAction(signed);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.user).toBeDefined();
|
||||
// Verify dedupe insert was called
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid signature', async () => {
|
||||
const payload = { content: 'Hello World' };
|
||||
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
|
||||
|
||||
// Tamper with data
|
||||
signed.data.content = 'Hacked';
|
||||
|
||||
(db.query.users.findFirst as any).mockResolvedValue({
|
||||
id: 'uuid-123',
|
||||
did: testDid,
|
||||
handle: testHandle,
|
||||
publicKey: userPublicKeyBase64,
|
||||
});
|
||||
|
||||
const result = await verifyUserAction(signed);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('INVALID_SIGNATURE');
|
||||
});
|
||||
|
||||
it('should reject replay attacks via DB constraint', async () => {
|
||||
const payload = { content: 'Replay Me' };
|
||||
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
|
||||
|
||||
(db.query.users.findFirst as any).mockResolvedValue({
|
||||
id: 'uuid-123',
|
||||
did: testDid,
|
||||
handle: testHandle,
|
||||
publicKey: userPublicKeyBase64,
|
||||
});
|
||||
|
||||
// Mock Duplicate Key Error
|
||||
const duplicateKeyError = new Error('Duplicate key');
|
||||
(duplicateKeyError as any).code = '23505';
|
||||
|
||||
// Second attempt fails with unique violation
|
||||
(db.insert as any).mockReturnValue({
|
||||
values: vi.fn().mockRejectedValue(duplicateKeyError)
|
||||
});
|
||||
|
||||
// Verify failure path
|
||||
const result = await verifyUserAction(signed);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('REPLAYED_NONCE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* User Signing Tests
|
||||
*
|
||||
* Tests for user-level cryptographic signing functionality
|
||||
* Validates: Requirements US-1.2, US-1.4, US-6.3, US-6.4
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
getUserPrivateKey,
|
||||
setUserPrivateKey,
|
||||
clearUserPrivateKey,
|
||||
hasUserPrivateKey
|
||||
} from './user-signing';
|
||||
|
||||
// Mock localStorage for Node environment
|
||||
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(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('setUserPrivateKey and getUserPrivateKey', () => {
|
||||
it('should store and retrieve private key from localStorage', () => {
|
||||
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
|
||||
|
||||
// Store the key
|
||||
setUserPrivateKey(testKey);
|
||||
|
||||
// Retrieve the key
|
||||
const retrievedKey = getUserPrivateKey();
|
||||
|
||||
// Verify it matches
|
||||
expect(retrievedKey).toBe(testKey);
|
||||
});
|
||||
|
||||
it('should return null when no key is stored', () => {
|
||||
const retrievedKey = getUserPrivateKey();
|
||||
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', () => {
|
||||
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', () => {
|
||||
expect(hasUserPrivateKey()).toBe(false);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(hasUserPrivateKey()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage key name', () => {
|
||||
it('should use the correct localStorage key', () => {
|
||||
const testKey = '-----BEGIN PRIVATE KEY-----\ntest-key-content\n-----END PRIVATE KEY-----';
|
||||
setUserPrivateKey(testKey);
|
||||
|
||||
// Verify the key is stored with the correct name
|
||||
const storedValue = localStorage.getItem('synapsis_user_private_key');
|
||||
expect(storedValue).toBe(testKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* User-Level Cryptographic Signing
|
||||
*
|
||||
* Strict Implementation Rules:
|
||||
* - ECDSA P-256 (ES256) ONLY. No RSA.
|
||||
* - Private keys NEVER in localStorage (decrypted).
|
||||
* - Keys stored in memory only (InMemoryKeyStore).
|
||||
* - Canonicalization: JSON with sorted keys, no floats, no dates.
|
||||
* - Nonce: 16+ bytes random base64url.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// ============================================
|
||||
// KEY STORAGE (In-Memory Only)
|
||||
// ============================================
|
||||
|
||||
export interface KeyStore {
|
||||
setPrivateKey(key: CryptoKey): void;
|
||||
getPrivateKey(): CryptoKey | null;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
class InMemoryKeyStore implements KeyStore {
|
||||
private static instance: InMemoryKeyStore;
|
||||
private privateKey: CryptoKey | null = null;
|
||||
|
||||
private constructor() { }
|
||||
|
||||
static getInstance(): InMemoryKeyStore {
|
||||
if (!InMemoryKeyStore.instance) {
|
||||
InMemoryKeyStore.instance = new InMemoryKeyStore();
|
||||
}
|
||||
return InMemoryKeyStore.instance;
|
||||
}
|
||||
|
||||
setPrivateKey(key: CryptoKey): void {
|
||||
if (key.type !== 'private' || key.algorithm.name !== 'ECDSA') {
|
||||
throw new Error('Invalid key type: Must be ECDSA private key');
|
||||
}
|
||||
this.privateKey = key;
|
||||
}
|
||||
|
||||
getPrivateKey(): CryptoKey | null {
|
||||
return this.privateKey;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.privateKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const keyStore = InMemoryKeyStore.getInstance();
|
||||
|
||||
export function hasUserPrivateKey(): boolean {
|
||||
return keyStore.getPrivateKey() !== null;
|
||||
}
|
||||
|
||||
export function clearUserPrivateKey(): void {
|
||||
keyStore.clear();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRYPTO HELPERS (WebCrypto / Node)
|
||||
// ============================================
|
||||
|
||||
// Detect environment for Crypto
|
||||
const cryptoSubtle = typeof window !== 'undefined'
|
||||
? window.crypto.subtle // Browser
|
||||
: (globalThis.crypto as any)?.subtle || require('crypto').webcrypto?.subtle; // Node
|
||||
|
||||
if (!cryptoSubtle) {
|
||||
throw new Error('WebCrypto is not supported in this environment');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new ECDSA P-256 KeyPair
|
||||
* Non-extractable private key by default (good practice),
|
||||
* but we need to export it to encrypt with password.
|
||||
*/
|
||||
export async function generateKeyPair(): Promise<CryptoKeyPair> {
|
||||
return await cryptoSubtle.generateKey(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
},
|
||||
true, // extractable (needed for export/encyrption)
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Private Key to PKCS8 (for encryption/storage)
|
||||
*/
|
||||
export async function exportPrivateKey(key: CryptoKey): Promise<ArrayBuffer> {
|
||||
return await cryptoSubtle.exportKey('pkcs8', key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import Private Key from PKCS8 (after decryption)
|
||||
*/
|
||||
export async function importPrivateKey(keyData: ArrayBuffer | Uint8Array): Promise<CryptoKey> {
|
||||
return await cryptoSubtle.importKey(
|
||||
'pkcs8',
|
||||
keyData,
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
},
|
||||
false, // not exportable once imported in memory
|
||||
['sign']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Public Key to SPKI (for distribution)
|
||||
* We usually want this as PEM or JWK. Let's stick to PEM buffer for consistency
|
||||
* with existing storage, or simpler: just keep SPKI buffer and base64 it.
|
||||
*/
|
||||
export async function exportPublicKey(key: CryptoKey): Promise<string> {
|
||||
const exported = await cryptoSubtle.exportKey('spki', key);
|
||||
return arrayBufferToBase64(exported);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import Public Key from SPKI Base64 (for verification)
|
||||
*/
|
||||
export async function importPublicKey(base64Key: string): Promise<CryptoKey> {
|
||||
const binary = base64ToArrayBuffer(base64Key);
|
||||
return await cryptoSubtle.importKey(
|
||||
'spki',
|
||||
binary,
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
},
|
||||
true,
|
||||
['verify']
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SIGNING & CANONICALIZATION
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Strict Canonicalization for Signing
|
||||
* - Request strict sorted keys
|
||||
* - No Dates, Maps, Sets, Functions
|
||||
* - No NaN, Infinity
|
||||
*/
|
||||
export function canonicalize(obj: any): string {
|
||||
if (obj === undefined) return ''; // Should not happen for valid inputs
|
||||
if (obj === null) return 'null';
|
||||
|
||||
if (typeof obj === 'number') {
|
||||
if (!Number.isFinite(obj)) {
|
||||
throw new Error('De-serialization failed: Number is not finite');
|
||||
}
|
||||
return obj.toString();
|
||||
}
|
||||
|
||||
if (typeof obj === 'boolean') {
|
||||
return obj.toString();
|
||||
}
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
const items = obj.map(item => canonicalize(item)).join(',');
|
||||
return `[${items}]`;
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
// Reject forbidden types
|
||||
if (obj instanceof Date) throw new Error('Serialization failed: Date objects not allowed');
|
||||
if (obj instanceof RegExp) throw new Error('Serialization failed: RegExp objects not allowed');
|
||||
|
||||
const keys = Object.keys(obj).sort();
|
||||
const pairs = keys.map(key => {
|
||||
const val = canonicalize(obj[key]);
|
||||
return `${JSON.stringify(key)}:${val}`;
|
||||
});
|
||||
return `{${pairs.join(',')}}`;
|
||||
}
|
||||
|
||||
throw new Error(`Serialization failed: Unsupported type ${typeof obj}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Signed Action
|
||||
* @returns { SignedAction } Includes ts, nonce, and strict signature
|
||||
*/
|
||||
export async function createSignedAction(
|
||||
action: string,
|
||||
data: any,
|
||||
userDid: string,
|
||||
userHandle: string
|
||||
): Promise<{
|
||||
action: string;
|
||||
data: any;
|
||||
did: string;
|
||||
handle: string;
|
||||
ts: number;
|
||||
nonce: string;
|
||||
sig: string;
|
||||
}> {
|
||||
const privateKey = keyStore.getPrivateKey();
|
||||
if (!privateKey) {
|
||||
throw new Error('User private key not available. Please log in (unlock identity).');
|
||||
}
|
||||
|
||||
const ts = Date.now();
|
||||
// 16 bytes entropy for nonce
|
||||
const nonceBytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonce = arrayBufferToBase64Url(nonceBytes);
|
||||
|
||||
// Payload strictly ordered for signing
|
||||
// We construct the object that matches the canonical structure EXACTLY
|
||||
const payloadToSign = {
|
||||
action,
|
||||
data,
|
||||
did: userDid,
|
||||
handle: userHandle,
|
||||
nonce,
|
||||
ts
|
||||
};
|
||||
|
||||
const canonicalString = canonicalize(payloadToSign);
|
||||
const encoder = new TextEncoder();
|
||||
const dataBytes = encoder.encode(canonicalString);
|
||||
|
||||
const signatureBuffer = await cryptoSubtle.sign(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
hash: { name: 'SHA-256' },
|
||||
},
|
||||
privateKey,
|
||||
dataBytes
|
||||
);
|
||||
|
||||
const sig = arrayBufferToBase64Url(signatureBuffer);
|
||||
|
||||
return {
|
||||
...payloadToSign,
|
||||
sig
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// UTILS
|
||||
// ============================================
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = 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;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64Url(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)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert Base64URL to Base64 (standard) for importing keys if needed
|
||||
*/
|
||||
export function base64UrlToBase64(base64Url: string): string {
|
||||
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4) {
|
||||
base64 += '=';
|
||||
}
|
||||
return base64;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* User Identity Hook
|
||||
*
|
||||
* Manages the user's cryptographic identity using in-memory storage.
|
||||
* strict: NO localStorage for decrypted keys.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
|
||||
import {
|
||||
keyStore,
|
||||
importPrivateKey,
|
||||
generateKeyPair,
|
||||
exportPrivateKey,
|
||||
exportPublicKey,
|
||||
base64UrlToBase64
|
||||
} from '@/lib/crypto/user-signing';
|
||||
|
||||
export interface UserIdentity {
|
||||
did: string;
|
||||
handle: string;
|
||||
publicKey: string;
|
||||
isUnlocked: boolean;
|
||||
}
|
||||
|
||||
export function useUserIdentity() {
|
||||
const [identity, setIdentity] = useState<UserIdentity | null>(null);
|
||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||
|
||||
// Check status on mount / updates
|
||||
useEffect(() => {
|
||||
const hasKey = !!keyStore.getPrivateKey();
|
||||
setIsUnlocked(hasKey);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Initialize identity from user data & password
|
||||
*/
|
||||
const initializeIdentity = async (userData: {
|
||||
did: string;
|
||||
handle: string;
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string;
|
||||
}, password?: string) => {
|
||||
|
||||
// If password provided, attempt unlock
|
||||
if (password) {
|
||||
await unlockIdentity(userData.privateKeyEncrypted, password);
|
||||
} else {
|
||||
// Just set public identity info if locked
|
||||
setIdentity({
|
||||
did: userData.did,
|
||||
handle: userData.handle,
|
||||
publicKey: userData.publicKey,
|
||||
isUnlocked: !!keyStore.getPrivateKey()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unlock the identity with a password
|
||||
*/
|
||||
const unlockIdentity = async (privateKeyEncrypted: string, password: string) => {
|
||||
try {
|
||||
// 1. Decrypt the PEM/String from server (which is actually a base64 encoded PKCS8 export usually?)
|
||||
// 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);
|
||||
|
||||
// Clean up if it's PEM to get Base64
|
||||
let privateKeyBase64 = privateKeyPemOrBase64;
|
||||
if (privateKeyBase64.includes('-----BEGIN')) {
|
||||
privateKeyBase64 = privateKeyBase64
|
||||
.replace(/-----BEGIN [A-Z ]+-----/, '')
|
||||
.replace(/-----END [A-Z ]+-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
}
|
||||
|
||||
// 2. Import into CryptoKey
|
||||
// We need ArrayBuffer
|
||||
const binaryDer = Buffer.from(privateKeyBase64, 'base64');
|
||||
const cryptoKey = await importPrivateKey(binaryDer); // This is P-256 specific now
|
||||
|
||||
// 3. Store in Memory
|
||||
keyStore.setPrivateKey(cryptoKey);
|
||||
|
||||
// 4. Update State
|
||||
setIdentity(prev => prev ? { ...prev, isUnlocked: true } : null); // We need the other data...
|
||||
setIsUnlocked(true);
|
||||
|
||||
// If we didn't have identity wrapper set yet, we might need it.
|
||||
// Usually initializeIdentity handles both.
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Identity] Failed to unlock identity:', error);
|
||||
throw new Error('Failed to unlock identity. Incorrect password?');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lock the identity
|
||||
*/
|
||||
const lockIdentity = () => {
|
||||
keyStore.clear();
|
||||
setIsUnlocked(false);
|
||||
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the identity (logout)
|
||||
*/
|
||||
const clearIdentity = () => {
|
||||
keyStore.clear();
|
||||
setIdentity(null);
|
||||
setIsUnlocked(false);
|
||||
};
|
||||
|
||||
return {
|
||||
identity,
|
||||
isUnlocked,
|
||||
initializeIdentity,
|
||||
unlockIdentity,
|
||||
lockIdentity,
|
||||
clearIdentity,
|
||||
};
|
||||
}
|
||||
@@ -58,9 +58,12 @@ export interface SwarmLikePayload {
|
||||
actorDisplayName: string;
|
||||
actorAvatarUrl?: string;
|
||||
actorNodeDomain: string;
|
||||
actorDid: string; // User's DID
|
||||
actorPublicKey: string; // User's public key for verification
|
||||
interactionId: string;
|
||||
timestamp: string;
|
||||
};
|
||||
userSignature: string; // User's cryptographic signature
|
||||
}
|
||||
|
||||
export interface SwarmUnlikePayload {
|
||||
@@ -249,7 +252,7 @@ export async function deliverSwarmMention(
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic interaction delivery
|
||||
* Generic interaction delivery with cryptographic signature
|
||||
*/
|
||||
async function deliverSwarmInteraction(
|
||||
targetDomain: string,
|
||||
@@ -265,6 +268,13 @@ async function deliverSwarmInteraction(
|
||||
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
|
||||
// SECURITY: Sign the payload with the node's private key
|
||||
const { signPayload, getNodePrivateKey } = await import('./signature');
|
||||
const privateKey = await getNodePrivateKey();
|
||||
|
||||
const signature = signPayload(payload, privateKey);
|
||||
const signedPayload = { ...payload as object, signature };
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
|
||||
@@ -274,7 +284,7 @@ async function deliverSwarmInteraction(
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(signedPayload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Node Keypair Management
|
||||
*
|
||||
* Each Synapsis node has its own RSA keypair for signing swarm interactions.
|
||||
* The private key is encrypted and stored in the database.
|
||||
* The public key is exposed via /api/node for verification.
|
||||
*/
|
||||
|
||||
import { db, nodes } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
/**
|
||||
* Encrypt the node private key using AUTH_SECRET
|
||||
*/
|
||||
function encryptPrivateKey(privateKey: string): string {
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('AUTH_SECRET not configured');
|
||||
}
|
||||
|
||||
// Derive a key from AUTH_SECRET
|
||||
const key = crypto.scryptSync(secret, 'node-key-salt', 32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(privateKey, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Return iv:authTag:encrypted
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the node private key using AUTH_SECRET
|
||||
*/
|
||||
function decryptPrivateKey(encryptedData: string): string {
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('AUTH_SECRET not configured');
|
||||
}
|
||||
|
||||
const [ivHex, authTagHex, encrypted] = encryptedData.split(':');
|
||||
const key = crypto.scryptSync(secret, 'node-key-salt', 32);
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate the node's keypair
|
||||
* Returns the private key (decrypted) and public key
|
||||
*/
|
||||
export async function getNodeKeypair(): Promise<{ privateKey: string; publicKey: string }> {
|
||||
if (!db) {
|
||||
throw new Error('Database not available');
|
||||
}
|
||||
|
||||
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
// Try to get existing node
|
||||
let node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, domain),
|
||||
});
|
||||
|
||||
// If node doesn't exist, create it
|
||||
if (!node) {
|
||||
const { publicKey, privateKey } = await generateKeyPair();
|
||||
const encryptedPrivateKey = encryptPrivateKey(privateKey);
|
||||
|
||||
const [newNode] = await db.insert(nodes).values({
|
||||
domain,
|
||||
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
|
||||
description: process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A swarm social network node',
|
||||
publicKey,
|
||||
privateKeyEncrypted: encryptedPrivateKey,
|
||||
}).returning();
|
||||
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// If node exists but has no keys, generate them
|
||||
if (!node.publicKey || !node.privateKeyEncrypted) {
|
||||
const { publicKey, privateKey } = await generateKeyPair();
|
||||
const encryptedPrivateKey = encryptPrivateKey(privateKey);
|
||||
|
||||
await db.update(nodes)
|
||||
.set({
|
||||
publicKey,
|
||||
privateKeyEncrypted: encryptedPrivateKey,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(nodes.id, node.id));
|
||||
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// Decrypt and return existing keys
|
||||
const privateKey = decryptPrivateKey(node.privateKeyEncrypted);
|
||||
return { privateKey, publicKey: node.publicKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get just the node's public key (for exposing via API)
|
||||
*/
|
||||
export async function getNodePublicKey(): Promise<string | null> {
|
||||
if (!db) return null;
|
||||
|
||||
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const node = await db.query.nodes.findFirst({
|
||||
where: eq(nodes.domain, domain),
|
||||
});
|
||||
|
||||
if (!node?.publicKey) {
|
||||
// Generate keys if they don't exist
|
||||
const { publicKey } = await getNodeKeypair();
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
return node.publicKey;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Swarm Signature Verification
|
||||
*
|
||||
* Cryptographic signatures for all swarm interactions to prevent forgery.
|
||||
* Each node signs requests with their private key, and recipients verify
|
||||
* using the sender's public key.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Sign a payload with the node's private key
|
||||
*/
|
||||
export function signPayload(payload: any, privateKey: string): string {
|
||||
const canonicalPayload = JSON.stringify(payload, Object.keys(payload).sort());
|
||||
const sign = crypto.createSign('SHA256');
|
||||
sign.update(canonicalPayload);
|
||||
sign.end();
|
||||
return sign.sign(privateKey, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signature using the sender's public key
|
||||
*/
|
||||
export function verifySignature(payload: any, signature: string, publicKey: string): boolean {
|
||||
try {
|
||||
const canonicalPayload = JSON.stringify(payload, Object.keys(payload).sort());
|
||||
const verify = crypto.createVerify('SHA256');
|
||||
verify.update(canonicalPayload);
|
||||
verify.end();
|
||||
return verify.verify(publicKey, signature, 'base64');
|
||||
} catch (error) {
|
||||
console.error('[Signature] Verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache a node's public key
|
||||
*/
|
||||
export async function getNodePublicKey(domain: string): Promise<string | null> {
|
||||
try {
|
||||
// Check if we have a cached node info
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const response = await fetch(`${protocol}://${domain}/api/node`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[Signature] Failed to fetch node info from ${domain}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.publicKey || null;
|
||||
} catch (error) {
|
||||
console.error(`[Signature] Error fetching public key from ${domain}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a swarm request signature
|
||||
*
|
||||
* @param payload - The request payload (without signature field)
|
||||
* @param signature - The signature to verify
|
||||
* @param senderDomain - The domain of the sender node
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export async function verifySwarmRequest(
|
||||
payload: any,
|
||||
signature: string,
|
||||
senderDomain: string
|
||||
): Promise<boolean> {
|
||||
// Get the sender node's public key
|
||||
const publicKey = await getNodePublicKey(senderDomain);
|
||||
|
||||
if (!publicKey) {
|
||||
console.error(`[Signature] Could not get public key for ${senderDomain}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
return verifySignature(payload, signature, publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a user interaction signature
|
||||
*
|
||||
* For user-specific interactions (like, follow, etc), we verify using
|
||||
* the user's public key, not the node's.
|
||||
*
|
||||
* @param payload - The request payload (without signature field)
|
||||
* @param signature - The signature to verify
|
||||
* @param userHandle - The full handle of the user (handle@domain)
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export async function verifyUserInteraction(
|
||||
payload: any,
|
||||
signature: string,
|
||||
userHandle: string,
|
||||
userDomain: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Try to get cached user
|
||||
const fullHandle = `${userHandle}@${userDomain}`;
|
||||
let user = await db?.query.users.findFirst({
|
||||
where: eq(users.handle, fullHandle),
|
||||
});
|
||||
|
||||
let publicKey: string | null = null;
|
||||
|
||||
if (user?.publicKey && user.publicKey.startsWith('-----BEGIN')) {
|
||||
publicKey = user.publicKey;
|
||||
} else {
|
||||
// Fetch from remote node
|
||||
const protocol = userDomain.includes('localhost') ? 'http' : 'https';
|
||||
const response = await fetch(`${protocol}://${userDomain}/api/users/${userHandle}`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[Signature] Failed to fetch user ${userHandle}@${userDomain}: ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
publicKey = userData.user?.publicKey || userData.publicKey;
|
||||
|
||||
// Cache the user if we don't have them
|
||||
if (!user && publicKey && db) {
|
||||
await db.insert(users).values({
|
||||
did: userData.user?.did || `did:swarm:${userDomain}:${userHandle}`,
|
||||
handle: fullHandle,
|
||||
displayName: userData.user?.displayName || userHandle,
|
||||
avatarUrl: userData.user?.avatarUrl,
|
||||
publicKey,
|
||||
}).onConflictDoNothing();
|
||||
} else if (user && publicKey && db) {
|
||||
// Update cached user's public key
|
||||
await db.update(users)
|
||||
.set({ publicKey })
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicKey) {
|
||||
console.error(`[Signature] No public key found for ${fullHandle}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
return verifySignature(payload, signature, publicKey);
|
||||
} catch (error) {
|
||||
console.error(`[Signature] Error verifying user interaction:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node's private key
|
||||
*/
|
||||
export async function getNodePrivateKey(): Promise<string> {
|
||||
const { getNodeKeypair } = await import('./node-keys');
|
||||
const { privateKey } = await getNodeKeypair();
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed payload for sending to another node
|
||||
*/
|
||||
export async function createSignedPayload(payload: any): Promise<{ payload: any; signature: string }> {
|
||||
const privateKey = await getNodePrivateKey();
|
||||
const signature = signPayload(payload, privateKey);
|
||||
return { payload, signature };
|
||||
}
|
||||
Reference in New Issue
Block a user