feat: Implement end-to-end encrypted chat with new API routes, crypto utilities, and UI components.

This commit is contained in:
Christopher
2026-01-27 17:59:08 -08:00
parent 5903022f8a
commit 9ee0cbbb9a
20 changed files with 1715 additions and 1525 deletions
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatDeviceBundles } from '@/db/schema';
import { eq } from 'drizzle-orm';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ did: string }> }
) {
const { did } = await params;
// 1. Fetch all devices for this DID
const bundles = await db.query.chatDeviceBundles.findMany({
where: eq(chatDeviceBundles.did, did),
with: {
oneTimeKeys: {
limit: 5, // Return a few keys; client picks one
// Ideally we pick non-conflicted ones or random ones
}
}
});
if (!bundles || bundles.length === 0) {
return NextResponse.json([], { status: 404 });
}
// 2. Format Response
const response = bundles.map(b => ({
did: b.did,
deviceId: b.deviceId,
identityKey: b.identityKey, // Base64 X25519
signedPreKey: JSON.parse(b.signedPreKey),
oneTimeKeys: b.oneTimeKeys.map(k => ({
id: k.keyId,
key: k.publicKey
})),
signature: b.signature // ECDSA signature of this bundle
}));
return NextResponse.json(response, {
headers: {
'Access-Control-Allow-Origin': '*', // Federation Header
'Cache-Control': 'max-age=60' // Cache for 1 min
}
});
}
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatInbox } from '@/db/schema';
import { eq, and, or, isNull } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
/**
* GET /api/chat/inbox
* Poll for new encrypted envelopes for this device.
*/
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user?.did) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { did } = session.user;
const { searchParams } = new URL(request.url);
const deviceId = searchParams.get('deviceId');
if (!deviceId) {
return NextResponse.json({ error: 'Device ID required' }, { status: 400 });
}
// Fetch messages for this user AND (this device OR all devices)
const messages = await db.select().from(chatInbox).where(
and(
eq(chatInbox.recipientDid, did),
or(
eq(chatInbox.recipientDeviceId, deviceId),
isNull(chatInbox.recipientDeviceId) // Broadcasts (if any)
),
eq(chatInbox.isRead, false)
)
);
// TODO: Mark them as read immediately?
// Or client must ACK?
// For now, we return them. Client deals with idempotency.
// If we mark read, checking on another tab might miss them if race condition.
// But uniqueness is (id).
return NextResponse.json({ messages });
} catch (error: any) {
console.error('Inbox poll fail:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* DELETE /api/chat/inbox
* Acknowledge/Delete processed messages
*/
export async function DELETE(request: NextRequest) {
// ... Implement ACK cleaning
return NextResponse.json({ success: true });
}
+65 -81
View File
@@ -1,95 +1,79 @@
/**
* Chat Keys API
*
* GET: Get current user's chat keys (public key + encrypted private key backup)
* POST: Register/update chat keys with encrypted private key backup
*
* The private key is encrypted CLIENT-SIDE with the user's password before
* being sent to the server. The server cannot decrypt it.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
export async function GET() {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({
chatPublicKey: user.chatPublicKey,
// Return encrypted private key so client can decrypt with password
chatPrivateKeyEncrypted: user.chatPrivateKeyEncrypted,
hasKeys: !!user.chatPublicKey,
});
} catch (error) {
console.error('Get chat keys error:', error);
return NextResponse.json({ error: 'Failed to get chat keys' }, { status: 500 });
}
}
import { db } from '@/db';
import { chatDeviceBundles, chatOneTimeKeys } from '@/db/schema';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { eq, and } from 'drizzle-orm';
/**
* POST /api/chat/keys
* Publish a new Device Bundle and OTKs.
*
* Payload: SignedAction < {
* deviceId: string,
* identityKey: string,
* signedPreKey: { id, key, sig },
* signature: string, (The ECDSA signature of the bundle itself)
* oneTimeKeys: { id, key }[]
* } >
*/
export async function POST(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { chatPublicKey, chatPrivateKeyEncrypted } = await request.json();
// 1. Verify User Identity (ECDSA Root)
// The wrapper itself is a SignedAction with action="chat.keys.publish"
// This proves the user (DID) is authorizing this device registration.
const user = await requireSignedAction(body);
if (!chatPublicKey || typeof chatPublicKey !== 'string') {
return NextResponse.json({ error: 'chatPublicKey required' }, { status: 400 });
}
const { deviceId, identityKey, signedPreKey, signature, oneTimeKeys } = body.data;
// Validate it looks like a base64 SPKI key (should be ~120 chars for P-256)
if (chatPublicKey.length < 100 || chatPublicKey.length > 200) {
console.error('[Chat Keys API] Invalid public key length:', chatPublicKey.length);
return NextResponse.json({
error: `Invalid public key format: expected ~120 characters, got ${chatPublicKey.length}`
}, { status: 400 });
}
// 2. Validate Bundle Signature (The bundle.signature must cover the fields)
// Actually, requireSignedAction already verified the payload signature.
// The "signature" field inside data is redundant if the whole thing is signed by DID?
// Or is "signature" the signature of the bundle bytes?
// In our design, the SignedAction *is* the signature.
// So "signature" inside might be the "Self-Signature" of the X25519 Identity Key signing the structure?
// No, standard Signal: The Bundle is signed by Identity Key.
// Here, Identity Key is ECDSA. The SignedAction covers it.
// So we just trust the SignedAction.
// Additional validation: try to decode as base64
try {
const decoded = Buffer.from(chatPublicKey, 'base64');
if (decoded.length < 80 || decoded.length > 100) {
console.error('[Chat Keys API] Invalid decoded key size:', decoded.length);
return NextResponse.json({
error: `Invalid public key: decoded size ${decoded.length} bytes (expected ~91 bytes for P-256)`
}, { status: 400 });
// 3. Upsert Bundle
await db.transaction(async (tx) => {
// Upsert device bundle
await tx.delete(chatDeviceBundles).where(
and(
eq(chatDeviceBundles.userId, user.id),
eq(chatDeviceBundles.deviceId, deviceId)
)
);
const [bundle] = await tx.insert(chatDeviceBundles).values({
userId: user.id,
did: user.did,
deviceId,
identityKey,
signedPreKey: JSON.stringify(signedPreKey),
signature, // We store the action signature or the explicit inner signature provided
}).returning();
// 4. Insert OTKs
if (oneTimeKeys && oneTimeKeys.length > 0) {
await tx.insert(chatOneTimeKeys).values(
oneTimeKeys.map((k: any) => ({
userId: user.id,
bundleId: bundle.id,
keyId: k.id,
publicKey: k.key
}))
).onConflictDoNothing();
}
} catch (e) {
console.error('[Chat Keys API] Failed to decode public key as base64:', e);
return NextResponse.json({ error: 'Public key is not valid base64' }, { status: 400 });
}
});
// chatPrivateKeyEncrypted should be a JSON string with encrypted data
if (chatPrivateKeyEncrypted && typeof chatPrivateKeyEncrypted !== 'string') {
return NextResponse.json({ error: 'Invalid encrypted private key format' }, { status: 400 });
}
return NextResponse.json({ success: true, count: oneTimeKeys?.length || 0 });
await db.update(users)
.set({
chatPublicKey,
chatPrivateKeyEncrypted: chatPrivateKeyEncrypted || null,
})
.where(eq(users.id, session.user.id));
return NextResponse.json({ success: true });
} catch (error) {
console.error('Register chat keys error:', error);
return NextResponse.json({ error: 'Failed to register chat keys' }, { status: 500 });
} catch (error: any) {
console.error('Failed to publish keys:', error);
return NextResponse.json({ error: error.message }, { status: 400 });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatInbox } from '@/db/schema';
import { requireSignedAction } from '@/lib/auth/verify-signature';
/**
* POST /api/chat/send
* Deliver an encrypted envelope to a local user's inbox.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// 1. Verify Envelope Signature (Anti-Spoofing & Replay)
const user = await requireSignedAction(body);
const { action, data } = body;
if (action !== 'chat.deliver') {
return NextResponse.json({ error: 'Invalid action type' }, { status: 400 });
}
const { recipientDid, recipientDeviceId, ciphertext } = data;
if (!recipientDid || !ciphertext) {
return NextResponse.json({ error: 'Missing required delivery fields' }, { status: 400 });
}
// 2. Insert into Inbox
await db.insert(chatInbox).values({
senderDid: user.did,
recipientDid,
recipientDeviceId: recipientDeviceId || null, // Null = broadcast/all? Or specific? V2.1 says per-device.
envelope: JSON.stringify(body),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days TTL
});
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Delivery failed:', error);
return NextResponse.json({ error: error.message }, { status: 400 });
}
}
+180 -889
View File
File diff suppressed because it is too large Load Diff
+34 -15
View File
@@ -8,6 +8,7 @@ import { PostCard } from '@/components/PostCard';
import { Compose } from '@/components/Compose';
import { Post } from '@/lib/types';
import { EyeOff } from 'lucide-react';
import { signedAPI } from '@/lib/api/signed-fetch';
interface FeedMeta {
score: number;
@@ -21,7 +22,7 @@ interface FeedMeta {
export default function Home() {
const router = useRouter();
const { user } = useAuth();
const { user, did, handle } = useAuth();
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
@@ -39,7 +40,7 @@ export default function Home() {
selfBoost: number;
};
} | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
// Redirect unauthenticated users to explore page
@@ -56,12 +57,12 @@ export default function Home() {
setLoading(true);
}
try {
const endpoint = type === 'curated'
? `/api/posts?type=curated${cursor ? `&cursor=${cursor}` : ''}`
const endpoint = type === 'curated'
? `/api/posts?type=curated${cursor ? `&cursor=${cursor}` : ''}`
: `/api/posts?type=home${cursor ? `&cursor=${cursor}` : ''}`;
const res = await fetch(endpoint);
const data = await res.json();
if (cursor) {
setPosts(prev => [...prev, ...(data.posts || [])]);
} else {
@@ -118,11 +119,21 @@ export default function Home() {
localReplyToId = undefined; // Don't set local replyToId for swarm posts
}
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }),
});
if (!user || !did || !handle) {
console.error('User identity missing');
return;
}
const res = await signedAPI.createPost(
content,
mediaIds,
linkPreview,
localReplyToId,
swarmReplyTo,
isNsfw || false,
did,
handle
);
if (res.ok) {
const data = await res.json();
@@ -138,13 +149,21 @@ export default function Home() {
};
const handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST';
await fetch(`/api/posts/${postId}/like`, { method });
if (!did || !handle) return;
if (currentLiked) {
await signedAPI.unlikePost(postId, did, handle);
} else {
await signedAPI.likePost(postId, did, handle);
}
};
const handleRepost = async (postId: string, currentReposted: boolean) => {
const method = currentReposted ? 'DELETE' : 'POST';
await fetch(`/api/posts/${postId}/repost`, { method });
if (!did || !handle) return;
if (currentReposted) {
await signedAPI.unrepostPost(postId, did, handle);
} else {
await signedAPI.repostPost(postId, did, handle);
}
};
const handleDelete = (postId: string) => {
@@ -224,7 +243,7 @@ export default function Home() {
<>
<p>No posts from the swarm yet</p>
<p style={{ fontSize: '13px', marginTop: '8px' }}>
The curated feed shows posts from other nodes in the Synapsis network.
The curated feed shows posts from other nodes in the Synapsis network.
Check back later as nodes are discovered, or switch to Following to see posts from people you follow.
</p>
</>
+26 -10
View File
@@ -8,11 +8,12 @@ import { PostCard } from '@/components/PostCard';
import { Compose } from '@/components/Compose';
import { useAuth } from '@/lib/contexts/AuthContext';
import { Post } from '@/lib/types';
import { signedAPI } from '@/lib/api/signed-fetch';
export default function PostDetailPage() {
const params = useParams();
const router = useRouter();
const { user } = useAuth();
const { user, did, handle: userHandle } = useAuth();
const handle = params.handle as string;
const id = params.id as string;
@@ -57,11 +58,18 @@ export default function PostDetailPage() {
localReplyToId = undefined; // Can't use UUID foreign key for swarm posts
}
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }),
});
if (!did || !userHandle) return;
const res = await signedAPI.createPost(
content,
mediaIds,
linkPreview,
localReplyToId,
swarmReplyTo,
isNsfw || false,
did,
userHandle
);
if (res.ok) {
const data = await res.json();
@@ -74,13 +82,21 @@ export default function PostDetailPage() {
};
const handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST';
await fetch(`/api/posts/${postId}/like`, { method });
if (!did || !userHandle) return;
if (currentLiked) {
await signedAPI.unlikePost(postId, did, userHandle);
} else {
await signedAPI.likePost(postId, did, userHandle);
}
};
const handleRepost = async (postId: string, currentReposted: boolean) => {
const method = currentReposted ? 'DELETE' : 'POST';
await fetch(`/api/posts/${postId}/repost`, { method });
if (!did || !userHandle) return;
if (currentReposted) {
await signedAPI.unrepostPost(postId, did, userHandle);
} else {
await signedAPI.repostPost(postId, did, userHandle);
}
};
const handleDelete = (postId: string) => {