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) => {
+10 -10
View File
@@ -26,7 +26,7 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setError('Please enter your password');
return;
@@ -37,7 +37,7 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
try {
await unlockIdentity(password);
// Success! Call the onUnlock callback if provided
if (onUnlock) {
onUnlock();
@@ -70,7 +70,7 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
zIndex: 99999,
padding: '16px'
}}
onClick={handleCancel}
@@ -110,10 +110,10 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
{/* Form */}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label
<label
htmlFor="unlock-password"
style={{
display: 'block',
style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
fontWeight: 500,
@@ -181,7 +181,7 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
type="submit"
disabled={isUnlocking || !password.trim()}
className="btn btn-primary"
style={{
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
@@ -211,9 +211,9 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp
</form>
{/* Info Note */}
<p style={{
fontSize: '12px',
color: 'var(--foreground-tertiary)',
<p style={{
fontSize: '12px',
color: 'var(--foreground-tertiary)',
marginTop: '16px',
marginBottom: 0,
lineHeight: 1.4
+10 -1
View File
@@ -4,9 +4,10 @@ import { usePathname } from 'next/navigation';
import { Sidebar } from './Sidebar';
import { RightSidebar } from './RightSidebar';
import { useAuth } from '@/lib/contexts/AuthContext';
import { IdentityUnlockPrompt } from './IdentityUnlockPrompt';
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const { loading } = useAuth();
const { loading, showUnlockPrompt, setShowUnlockPrompt } = useAuth();
const pathname = usePathname();
// Paths that should NOT have the app layout
@@ -56,6 +57,14 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
{children}
</main>
{!hideRightSidebar && <RightSidebar />}
{/* Global Identity Unlock Prompt */}
{showUnlockPrompt && (
<IdentityUnlockPrompt
onUnlock={() => setShowUnlockPrompt(false)}
onCancel={() => setShowUnlockPrompt(false)}
/>
)}
</div>
);
}
+18 -24
View File
@@ -11,6 +11,7 @@ import { useToast } from '@/lib/contexts/ToastContext';
import { VideoEmbed } from '@/components/VideoEmbed';
import BlurredVideo from '@/components/BlurredVideo';
import { formatFullHandle, NODE_DOMAIN } from '@/lib/utils/handle';
import { signedAPI } from '@/lib/api/signed-fetch';
// Component for link preview image that hides on error
function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
@@ -43,7 +44,7 @@ interface PostCardProps {
}
export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, parentPostAuthorId }: PostCardProps) {
const { user: currentUser } = useAuth();
const { user: currentUser, did, handle: currentUserHandle } = useAuth();
const { showToast } = useToast();
const router = useRouter();
const [liked, setLiked] = useState(post.isLiked || false);
@@ -113,14 +114,16 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
e.stopPropagation();
if (reporting) return;
const reason = window.prompt('Why are you reporting this post?');
if (!reason) return;
if (!reason || !did || !currentUserHandle) return;
setReporting(true);
try {
const res = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }),
});
const res = await signedAPI.report(
'post',
post.id,
reason,
did,
currentUserHandle
);
if (!res.ok) {
if (res.status === 401) {
showToast('Please log in to report.', 'error');
@@ -141,11 +144,10 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
e.stopPropagation();
if (deleting) return;
if (!window.confirm('Are you sure you want to delete this post? This action cannot be undone.')) return;
if (!did || !currentUserHandle) return;
setDeleting(true);
try {
const res = await fetch(`/api/posts/${post.id}`, {
method: 'DELETE',
});
const res = await signedAPI.deletePost(post.id, did, currentUserHandle);
if (res.ok) {
onDelete?.(post.id);
} else {
@@ -164,15 +166,13 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
e.stopPropagation();
setShowMenu(false);
if (!currentUser) {
if (!currentUser || !did || !currentUserHandle) {
showToast('Please log in to block users', 'error');
return;
}
try {
const res = await fetch(`/api/users/${post.author.handle}/block`, {
method: 'POST',
});
const res = await signedAPI.blockUser(post.author.handle, did, currentUserHandle);
if (res.ok) {
showToast(`Blocked @${post.author.handle}`, 'success');
onHide?.(post.id);
@@ -189,7 +189,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
e.stopPropagation();
setShowMenu(false);
if (!currentUser) {
if (!currentUser || !did || !currentUserHandle) {
showToast('Please log in to mute users', 'error');
return;
}
@@ -197,9 +197,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
// For now, muting a user is the same as blocking but with different messaging
// Could be expanded to just hide posts without breaking follows
try {
const res = await fetch(`/api/users/${post.author.handle}/block`, {
method: 'POST',
});
const res = await signedAPI.blockUser(post.author.handle, did, currentUserHandle);
if (res.ok) {
showToast(`Muted @${post.author.handle}`, 'success');
onHide?.(post.id);
@@ -216,7 +214,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
e.stopPropagation();
setShowMenu(false);
if (!currentUser) {
if (!currentUser || !did || !currentUserHandle) {
showToast('Please log in to mute nodes', 'error');
return;
}
@@ -232,11 +230,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
}
try {
const res = await fetch('/api/settings/muted-nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: nodeDomain }),
});
const res = await signedAPI.muteNode(nodeDomain, did, currentUserHandle);
if (res.ok) {
showToast(`Muted node: ${nodeDomain}`, 'success');
onHide?.(post.id);
+14 -21
View File
@@ -8,17 +8,16 @@ 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, Lock, Unlock } from 'lucide-react';
import { IdentityUnlockPrompt } from './IdentityUnlockPrompt';
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
export function Sidebar() {
const { user, isAdmin, logout, isIdentityUnlocked } = useAuth();
const { user, isAdmin, logout, isIdentityUnlocked, setShowUnlockPrompt } = 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')
@@ -190,7 +189,7 @@ 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={() => {
@@ -206,8 +205,8 @@ export function Sidebar() {
padding: '10px 12px',
marginBottom: '12px',
borderRadius: '8px',
background: isIdentityUnlocked
? 'rgba(34, 197, 94, 0.1)'
background: isIdentityUnlocked
? 'rgba(34, 197, 94, 0.1)'
: 'rgba(251, 191, 36, 0.1)',
border: isIdentityUnlocked
? '1px solid rgba(34, 197, 94, 0.2)'
@@ -232,8 +231,8 @@ export function Sidebar() {
<Lock size={16} style={{ color: 'rgb(251, 191, 36)', flexShrink: 0 }} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '13px',
<div style={{
fontSize: '13px',
fontWeight: 500,
color: isIdentityUnlocked ? 'rgb(34, 197, 94)' : 'rgb(251, 191, 36)',
overflow: 'hidden',
@@ -242,20 +241,20 @@ export function Sidebar() {
}}>
{isIdentityUnlocked ? 'Identity Unlocked' : 'Identity Locked'}
</div>
<div style={{
fontSize: '11px',
<div style={{
fontSize: '11px',
color: 'var(--foreground-tertiary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{isIdentityUnlocked
? 'You can perform actions'
{isIdentityUnlocked
? 'You can perform actions'
: 'Click to unlock'}
</div>
</div>
</div>
<button
onClick={handleLogout}
disabled={loggingOut}
@@ -273,14 +272,8 @@ export function Sidebar() {
</button>
</div>
)}
{/* Identity Unlock Prompt Modal */}
{showUnlockPrompt && (
<IdentityUnlockPrompt
onUnlock={() => setShowUnlockPrompt(false)}
onCancel={() => setShowUnlockPrompt(false)}
/>
)}
{/* Identity Unlock Prompt Modal is now handled in LayoutWrapper */}
</aside>
);
}
+97
View File
@@ -955,6 +955,103 @@ export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
}),
}));
/**
* V2.1 E2EE: Device Bundles
* Stores the identity and signed prekeys for each user device.
* Verified by the root ECDSA identity key (signature).
*/
export const chatDeviceBundles = pgTable('chat_device_bundles', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
did: text('did').notNull(),
// The device identifier (UUID generated by client)
deviceId: text('device_id').notNull(),
// X25519 Identity Key (Base64)
identityKey: text('identity_key').notNull(),
// Signed PreKey (JSON: { id, key, sig })
signedPreKey: text('signed_pre_key').notNull(),
// One-Time Keys (JSON array of { id, key }) - cached/uploaded batch
// Note: Individual keys are usually stored in a separate table for atomic consumption,
// but initial upload can be here or we strictly use chat_one_time_keys.
// We will use the separate table for consumption tracking.
// The ECDSA signature covering (did, deviceId, identityKey, signedPreKey)
// Signed by the user's Root Identity Key (P-256)
signature: text('signature').notNull(),
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('chat_bundles_user_idx').on(table.userId),
index('chat_bundles_did_idx').on(table.did),
// compound index for fast lookup of a specific device
uniqueIndex('chat_bundles_device_unique').on(table.userId, table.deviceId),
]);
export const chatDeviceBundlesRelations = relations(chatDeviceBundles, ({ one, many }) => ({
user: one(users, {
fields: [chatDeviceBundles.userId],
references: [users.id],
}),
oneTimeKeys: many(chatOneTimeKeys),
}));
/**
* V2.1 E2EE: One-Time Prekeys
* Pool of disposable keys for X3DH.
*/
export const chatOneTimeKeys = pgTable('chat_one_time_keys', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
bundleId: uuid('bundle_id').notNull().references(() => chatDeviceBundles.id, { onDelete: 'cascade' }),
keyId: integer('key_id').notNull(),
publicKey: text('public_key').notNull(), // X25519 Base64
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('chat_otk_bundle_idx').on(table.bundleId),
// Ensure (bundleId, keyId) is unique
uniqueIndex('chat_otk_unique').on(table.bundleId, table.keyId),
]);
export const chatOneTimeKeysRelations = relations(chatOneTimeKeys, ({ one }) => ({
bundle: one(chatDeviceBundles, {
fields: [chatOneTimeKeys.bundleId],
references: [chatDeviceBundles.id],
}),
}));
/**
* V2.1 E2EE: Chat Inbox
* Stores encrypted envelopes waiting for delivery to a specific device.
*/
export const chatInbox = pgTable('chat_inbox', {
id: uuid('id').primaryKey().defaultRandom(),
// Recipient info
recipientDid: text('recipient_did').notNull(),
recipientDeviceId: text('recipient_device_id'), // Null means "all devices" or "not yet routed"
// Sender info
senderDid: text('sender_did').notNull(),
// The Envelope (SignedAction format)
// Contains: { action: "chat.deliver", did, handle, ts, nonce, data: { ciphertext, header... }, sig }
envelope: text('envelope_json').notNull(),
isRead: boolean('is_read').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at').notNull(), // TTL (e.g. 30 days)
}, (table) => [
index('chat_inbox_recipient_idx').on(table.recipientDid, table.recipientDeviceId),
index('chat_inbox_created_idx').on(table.createdAt),
]);
/**
* Typing indicators for real-time chat UX.
* Short-lived records that expire after 10 seconds.
+55 -1
View File
@@ -117,6 +117,7 @@ export const signedAPI = {
mediaIds: string[],
linkPreview: any,
replyToId: string | undefined,
swarmReplyTo: any | undefined,
isNsfw: boolean,
userDid: string,
userHandle: string
@@ -124,7 +125,7 @@ export const signedAPI = {
return signedFetch(
'/api/posts',
'post',
{ content, mediaIds, linkPreview, replyToId, isNsfw },
{ content, mediaIds, linkPreview, replyToId, swarmReplyTo, isNsfw },
userDid,
userHandle
);
@@ -156,4 +157,57 @@ export const signedAPI = {
{ method: 'DELETE' }
);
},
/**
* Delete a post
*/
async deletePost(postId: string, userDid: string, userHandle: string) {
return signedFetch(
`/api/posts/${postId}`,
'delete',
{ postId },
userDid,
userHandle,
{ method: 'DELETE' }
);
},
/**
* Submit a report
*/
async report(targetType: string, targetId: string, reason: string, userDid: string, userHandle: string) {
return signedFetch(
'/api/reports',
'report',
{ targetType, targetId, reason },
userDid,
userHandle
);
},
/**
* Block a user
*/
async blockUser(handle: string, userDid: string, userHandle: string) {
return signedFetch(
`/api/users/${handle}/block`,
'block',
{ handle },
userDid,
userHandle
);
},
/**
* Mute a node
*/
async muteNode(domain: string, userDid: string, userHandle: string) {
return signedFetch(
'/api/settings/muted-nodes',
'mute_node',
{ domain },
userDid,
userHandle
);
},
};
+32 -10
View File
@@ -2,6 +2,7 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { useUserIdentity } from '@/lib/hooks/useUserIdentity';
import { useChatEncryption } from '@/lib/hooks/useChatEncryption';
export interface User {
id: string;
@@ -23,6 +24,8 @@ interface AuthContextType {
checkAdmin: () => Promise<void>;
unlockIdentity: (password: string) => Promise<void>;
logout: () => Promise<void>;
showUnlockPrompt: boolean;
setShowUnlockPrompt: (show: boolean) => void;
}
const AuthContext = createContext<AuthContextType>({
@@ -35,13 +38,15 @@ const AuthContext = createContext<AuthContextType>({
checkAdmin: async () => { },
unlockIdentity: async () => { },
logout: async () => { },
showUnlockPrompt: false,
setShowUnlockPrompt: () => { },
});
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,
@@ -61,6 +66,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
// Integrate chat encryption hook
const { ensureReady } = useChatEncryption();
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
/**
* Unlock the user's identity with their password
*/
@@ -68,8 +78,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (!user?.privateKeyEncrypted) {
throw new Error('No encrypted private key available');
}
await unlockIdentityHook(user.privateKeyEncrypted, password);
// Initialize Chat Keys (Async, don't block UI but start it)
if (user.id) {
ensureReady(password, user.id).catch(err => {
console.error('Failed to initialize chat keys:', err);
});
}
setShowUnlockPrompt(false); // Close prompt on success
};
/**
@@ -79,10 +98,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try {
// Call the logout API endpoint
await fetch('/api/auth/logout', { method: 'POST' });
// Clear the user's identity (private key from localStorage)
clearIdentity();
setShowUnlockPrompt(false);
// Clear the user state
setUser(null);
setIsAdmin(false);
@@ -100,7 +120,7 @@ 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({
@@ -110,7 +130,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
privateKeyEncrypted: data.user.privateKeyEncrypted,
});
}
if (data.user) {
await checkAdmin();
}
@@ -130,16 +150,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
return (
<AuthContext.Provider value={{
user,
isAdmin,
loading,
<AuthContext.Provider value={{
user,
isAdmin,
loading,
isIdentityUnlocked: isUnlocked,
did: identity?.did || null,
handle: identity?.handle || null,
checkAdmin,
unlockIdentity,
logout,
showUnlockPrompt,
setShowUnlockPrompt,
}}>
{children}
</AuthContext.Provider>
+190
View File
@@ -0,0 +1,190 @@
/**
* Synapsis Secure Chat Storage
*
* Manages persistence of sensitive chat keys (X25519) and ratchet state.
* Uses IndexedDB, but all values are AES-GCM encrypted using a key derived
* from the user's login password.
*/
import { hkdf, encrypt, decrypt, exportKey, importX25519PrivateKey, KeyPair, arrayBufferToBase64, base64ToArrayBuffer } from './e2ee';
const DB_NAME = 'SynapsisChat';
const DB_VERSION = 1;
const STORE_NAME = 'secure_store';
// In-memory cache of the storage key (derived from password)
let storageKey: ArrayBuffer | null = null;
let dbInstance: IDBDatabase | null = null;
// ----------------------------------------------------------------------------
// 1. Initialization
// ----------------------------------------------------------------------------
/**
* Initialize storage with user password.
* Derives a dedicated storage key.
*/
export async function unlockChatStorage(password: string, userId: string): Promise<void> {
// 1. Derive Storage Key
// We use the userId as salt to ensure uniqueness per user
const encoder = new TextEncoder();
const masterKeyMaterial = encoder.encode(password);
const salt = encoder.encode(`synapsis_chat_storage_${userId}`);
storageKey = await hkdf(
salt,
masterKeyMaterial,
encoder.encode('SynapsisChatPersistence'),
32 // 256-bit AES key
);
// 2. Open DB
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME); // Key-Value store
}
};
request.onsuccess = (event) => {
dbInstance = (event.target as IDBOpenDBRequest).result;
resolve();
};
request.onerror = () => reject('Failed to open IndexedDB');
});
}
export function isStorageUnlocked(): boolean {
return storageKey !== null && dbInstance !== null;
}
export function lockStorage() {
storageKey = null;
if (dbInstance) {
dbInstance.close();
dbInstance = null;
}
}
// ----------------------------------------------------------------------------
// 2. Safe Usage Wrappers
// ----------------------------------------------------------------------------
async function setItem(key: string, value: string): Promise<void> {
if (!dbInstance) throw new Error('Database locked');
return new Promise((resolve, reject) => {
const tx = dbInstance!.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.put(value, key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function getItem(key: string): Promise<string | undefined> {
if (!dbInstance) throw new Error('Database locked');
return new Promise((resolve, reject) => {
const tx = dbInstance!.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// ----------------------------------------------------------------------------
// 3. Encrypted Read/Write
// ----------------------------------------------------------------------------
/**
* Stores a serializable object encrypted.
*/
export async function storeEncrypted(key: string, data: any): Promise<void> {
if (!storageKey) throw new Error('Storage locked');
const json = JSON.stringify(data);
const encrypted = await encrypt(storageKey, json);
// Store as stringified JSON wrapper
const storedValue = JSON.stringify(encrypted);
await setItem(key, storedValue);
}
/**
* Retrieves and decrypts an object.
*/
export async function loadEncrypted<T>(key: string): Promise<T | null> {
if (!storageKey) throw new Error('Storage locked');
const raw = await getItem(key);
if (!raw) return null;
try {
const { ciphertext, iv } = JSON.parse(raw);
const json = await decrypt(storageKey, ciphertext, iv);
return JSON.parse(json) as T;
} catch (error) {
console.error(`Failed to decrypt key ${key}:`, error);
return null;
}
}
// ----------------------------------------------------------------------------
// 4. Specific Key Managers
// ----------------------------------------------------------------------------
interface StoredKeyPair {
pub: string; // Base64 Raw
priv: string; // Base64 PKCS8
}
export async function storeDeviceKeys(identity: KeyPair, signedPreKey: KeyPair, otks: KeyPair[]) {
const data = {
identity: {
pub: await exportKey(identity.publicKey),
priv: await exportKey(identity.privateKey)
},
signedPreKey: {
pub: await exportKey(signedPreKey.publicKey),
priv: await exportKey(signedPreKey.privateKey)
},
otks: await Promise.all(otks.map(async k => ({
pub: await exportKey(k.publicKey),
priv: await exportKey(k.privateKey)
})))
};
await storeEncrypted('device_keys', data);
}
export async function loadDeviceKeys(): Promise<{ identity: KeyPair, signedPreKey: KeyPair, otks: KeyPair[] } | null> {
const data = await loadEncrypted<any>('device_keys');
if (!data) return null;
// Hydrate keys
const identity = {
publicKey: await importX25519PublicKey(data.identity.pub),
privateKey: await importX25519PrivateKey(data.identity.priv)
};
const signedPreKey = {
publicKey: await importX25519PublicKey(data.signedPreKey.pub),
privateKey: await importX25519PrivateKey(data.signedPreKey.priv)
};
const otks = await Promise.all((data.otks as any[]).map(async k => ({
publicKey: await importX25519PublicKey(k.pub),
privateKey: await importX25519PrivateKey(k.priv)
})));
return { identity, signedPreKey, otks };
}
// Helper needed to avoid circular dep if importing from e2ee in hydrating
import { importX25519PublicKey } from './e2ee';
+235
View File
@@ -0,0 +1,235 @@
/**
* Synapsis E2EE Cryptography Core
*
* Implements:
* - X25519 for Key Agreement (ECDH)
* - AES-GCM-256 for Encryption (Standard WebCrypto replacement for ChaCha20)
* - HKDF-SHA256 for Key Derivation
*
* Note: Uses WebCrypto API available in Node 19+ and Browsers.
*/
// Universal Crypto Access
const cryptoSubtle = typeof window !== 'undefined'
? window.crypto.subtle
: (globalThis.crypto as any)?.subtle || require('crypto').webcrypto?.subtle;
if (!cryptoSubtle) {
throw new Error('WebCrypto is not available in this environment');
}
// Types
export interface KeyPair {
publicKey: CryptoKey;
privateKey: CryptoKey;
}
export interface PreKeyBundle {
id: number;
key: CryptoKey;
signature?: string; // Base64 ECDSA signature if it's a signed prekey
}
// ----------------------------------------------------------------------------
// 1. Primitives
// ----------------------------------------------------------------------------
/**
* Generate an X25519 Key Pair
*/
export async function generateX25519KeyPair(): Promise<KeyPair> {
return await cryptoSubtle.generateKey(
{
name: 'X25519',
},
true, // extractable
['deriveKey', 'deriveBits']
) as KeyPair;
}
/**
* Import an X25519 Public Key from Base64 Raw Bytes (32 bytes)
*/
export async function importX25519PublicKey(base64: string): Promise<CryptoKey> {
const binary = base64ToArrayBuffer(base64);
return await cryptoSubtle.importKey(
'raw',
binary,
{ name: 'X25519' },
true,
[]
);
}
/**
* Import an X25519 Private Key from Base64 PKCS8/Raw
* Note: WebCrypto usually exports Private Keys as PKCS8.
*/
export async function importX25519PrivateKey(base64: string): Promise<CryptoKey> {
const binary = base64ToArrayBuffer(base64);
// Try PKCS8 first (standard export)
return await cryptoSubtle.importKey(
'pkcs8',
binary,
{ name: 'X25519' },
false,
['deriveKey', 'deriveBits']
);
}
/**
* Export Key to Base64 (Raw for Public, PKCS8 for Private)
*/
export async function exportKey(key: CryptoKey): Promise<string> {
if (key.type === 'public') {
const raw = await cryptoSubtle.exportKey('raw', key);
return arrayBufferToBase64(raw);
} else {
const pkcs8 = await cryptoSubtle.exportKey('pkcs8', key);
return arrayBufferToBase64(pkcs8);
}
}
/**
* ECDH: Compute Shared Secret
*/
export async function computeSharedSecret(privateKey: CryptoKey, publicKey: CryptoKey): Promise<ArrayBuffer> {
// We derive bits directly (Commonly 32 bytes for X25519)
return await cryptoSubtle.deriveBits(
{
name: 'X25519',
public: publicKey,
},
privateKey,
256 // 32 bytes * 8
);
}
// ----------------------------------------------------------------------------
// 2. KDF (HKDF-SHA256)
// ----------------------------------------------------------------------------
/**
* HKDF Expand & Extract
*/
export async function hkdf(
salt: ArrayBuffer | Uint8Array,
ikm: ArrayBuffer | Uint8Array, // Input Key Material (Shared Secret)
info: ArrayBuffer | Uint8Array,
length: number // Bytes output
): Promise<ArrayBuffer> {
const key = await cryptoSubtle.importKey(
'raw',
ikm,
{ name: 'HKDF' },
false,
['deriveBits']
);
return await cryptoSubtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: salt,
info: info,
},
key,
length * 8
);
}
// ----------------------------------------------------------------------------
// 3. Encryption (AES-GCM)
// ----------------------------------------------------------------------------
export async function encrypt(
keyBytes: ArrayBuffer,
plaintext: string | Uint8Array,
associatedData?: Uint8Array
): Promise<{ ciphertext: string; iv: string }> {
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV
const key = await cryptoSubtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['encrypt']
);
const data = typeof plaintext === 'string'
? new TextEncoder().encode(plaintext)
: plaintext;
const encrypted = await cryptoSubtle.encrypt(
{
name: 'AES-GCM',
iv: iv,
additionalData: associatedData
},
key,
data
);
return {
ciphertext: arrayBufferToBase64(encrypted),
iv: arrayBufferToBase64(iv.buffer)
};
}
export async function decrypt(
keyBytes: ArrayBuffer,
ciphertextBase64: string,
ivBase64: string,
associatedData?: Uint8Array
): Promise<string> {
const key = await cryptoSubtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const ciphertext = base64ToArrayBuffer(ciphertextBase64);
const iv = base64ToArrayBuffer(ivBase64);
try {
const decrypted = await cryptoSubtle.decrypt(
{
name: 'AES-GCM',
iv: iv,
additionalData: associatedData
},
key,
ciphertext
);
return new TextDecoder().decode(decrypted);
} catch (e) {
throw new Error('Decryption failed');
}
}
// ----------------------------------------------------------------------------
// 4. Utils
// ----------------------------------------------------------------------------
export 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);
}
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
// Handle URL safe base64 if needed, but we assume standard
const binary = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
+330
View File
@@ -0,0 +1,330 @@
/**
* Synapsis Double Ratchet & X3DH Implementation
*
* Implements the Double Ratchet Algorithm + X3DH Key Agreement.
* Adheres to Signal specifications using the "SynapsisV2" HKDF info binding.
*/
import {
KeyPair,
computeSharedSecret,
hkdf,
encrypt as aeadEncrypt,
decrypt as aeadDecrypt,
importX25519PublicKey,
exportKey,
generateX25519KeyPair,
base64ToArrayBuffer,
arrayBufferToBase64
} from './e2ee';
// Constants
const KDF_INFO = 'SynapsisV2';
const RK_SIZE = 32; // 32 bytes for Root Key
const CK_SIZE = 32; // 32 bytes for Chain Key
const MK_SIZE = 32; // 32 bytes for Message Key
export interface RatchetState {
// DH Ratchet
dhPair: KeyPair;
remoteDhPub: CryptoKey;
rootKey: ArrayBuffer;
// Symm Ratchets
chainKeySend: ArrayBuffer;
chainKeyRecv: ArrayBuffer;
// Message Numbers
ns: number; // Send count
nr: number; // Recv count
pn: number; // Previous chain count
}
export interface Header {
dh: string; // Base64 public key
pn: number;
n: number;
}
export interface CiphertextMessage {
header: Header;
ciphertext: string;
iv: string;
}
// ----------------------------------------------------------------------------
// 1. X3DH Key Agreement
// ----------------------------------------------------------------------------
export async function x3dhSender(
aliceIdentity: KeyPair,
bobBundle: {
identityKey: CryptoKey,
signedPreKey: CryptoKey,
oneTimeKey?: CryptoKey
},
contextInfo: string // "SynapsisV2" + DIDs + DeviceIDs
): Promise<{ sk: ArrayBuffer, ephemeralKey: KeyPair }> {
// 1. Generate Ephemeral Key (EK_a)
const ephemeralKey = await generateX25519KeyPair();
// 2. DH1 = DH(IK_a, SPK_b)
const dh1 = await computeSharedSecret(aliceIdentity.privateKey, bobBundle.signedPreKey);
// 3. DH2 = DH(EK_a, IK_b)
const dh2 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.identityKey);
// 4. DH3 = DH(EK_a, SPK_b)
const dh3 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.signedPreKey);
// 5. DH4 = DH(EK_a, OPK_b) -- Optional
let dh4: ArrayBuffer | undefined;
if (bobBundle.oneTimeKey) {
dh4 = await computeSharedSecret(ephemeralKey.privateKey, bobBundle.oneTimeKey);
}
// 6. Concatenate
const km = new Uint8Array(dh1.byteLength + dh2.byteLength + dh3.byteLength + (dh4 ? dh4.byteLength : 0));
let offset = 0;
km.set(new Uint8Array(dh1), offset); offset += dh1.byteLength;
km.set(new Uint8Array(dh2), offset); offset += dh2.byteLength;
km.set(new Uint8Array(dh3), offset); offset += dh3.byteLength;
if (dh4) km.set(new Uint8Array(dh4), offset);
// 7. KDF
// Output 32 bytes for Root Key
const encoder = new TextEncoder();
return {
sk: await hkdf(new Uint8Array(32), km.buffer, encoder.encode(contextInfo), 32),
ephemeralKey
};
}
export async function x3dhReceiver(
bobIdentity: KeyPair,
bobSignedPreKey: KeyPair,
bobOneTimeKey: KeyPair | undefined, // The one used by Alice
aliceIdentityKey: CryptoKey,
aliceEphemeralKey: CryptoKey,
contextInfo: string
): Promise<ArrayBuffer> {
// 1. DH1 = DH(SPK_b, IK_a) -- Note: Order of keys in computeSharedSecret usually (private, public)
const dh1 = await computeSharedSecret(bobSignedPreKey.privateKey, aliceIdentityKey);
// 2. DH2 = DH(IK_b, EK_a)
const dh2 = await computeSharedSecret(bobIdentity.privateKey, aliceEphemeralKey);
// 3. DH3 = DH(SPK_b, EK_a)
const dh3 = await computeSharedSecret(bobSignedPreKey.privateKey, aliceEphemeralKey);
// 4. DH4 = DH(OPK_b, EK_a)
let dh4: ArrayBuffer | undefined;
if (bobOneTimeKey) {
dh4 = await computeSharedSecret(bobOneTimeKey.privateKey, aliceEphemeralKey);
}
const km = new Uint8Array(dh1.byteLength + dh2.byteLength + dh3.byteLength + (dh4 ? dh4.byteLength : 0));
let offset = 0;
km.set(new Uint8Array(dh1), offset); offset += dh1.byteLength;
km.set(new Uint8Array(dh2), offset); offset += dh2.byteLength;
km.set(new Uint8Array(dh3), offset); offset += dh3.byteLength;
if (dh4) km.set(new Uint8Array(dh4), offset);
const encoder = new TextEncoder();
return await hkdf(new Uint8Array(32), km.buffer, encoder.encode(contextInfo), 32);
}
// ----------------------------------------------------------------------------
// 2. KDF Chains (Symmetric Ratchet)
// ----------------------------------------------------------------------------
// Constants for HMAC
const ONE = new Uint8Array([0x01]);
const TWO = new Uint8Array([0x02]);
async function kdfChain(ck: ArrayBuffer): Promise<{ ck: ArrayBuffer, mk: ArrayBuffer }> {
// HMAC-SHA256(CK, 1) -> MK
// HMAC-SHA256(CK, 2) -> NextCK
// Implementing via HKDF for simplicity/consistency or WebCrypto HMAC
// Actually standard says:
// HMAC-SHA256(ck, input)
// We can use HKDF-Expand logic here or pure hmac.
// Let's use custom HKDF expand with fixed info
const mk = await hkdf(new Uint8Array(0), ck, ONE, 32);
const nextCk = await hkdf(new Uint8Array(0), ck, TWO, 32);
return { ck: nextCk, mk };
}
// ----------------------------------------------------------------------------
// 3. DHRatchet (Root Chain)
// ----------------------------------------------------------------------------
async function kdfRoot(rootKey: ArrayBuffer, dhOut: ArrayBuffer): Promise<{ rootKey: ArrayBuffer, chainKey: ArrayBuffer }> {
// HKDF(root, dh, info, 64) -> 32 root, 32 chain
const encoder = new TextEncoder();
const output = await hkdf(
rootKey,
dhOut,
encoder.encode("SynapsisRatchet"),
64
);
const bytes = new Uint8Array(output);
return {
rootKey: bytes.slice(0, 32).buffer,
chainKey: bytes.slice(32, 64).buffer
};
}
// ----------------------------------------------------------------------------
// 4. Initializers
// ----------------------------------------------------------------------------
export async function initSender(
sharedSecret: ArrayBuffer,
bobRatchetKey: CryptoKey
): Promise<RatchetState> {
const dhPair = await generateX25519KeyPair();
// Sender starts by sending a new DH ratchet.
// Root Key = sharedSecret.
// First, we need to generate a chain key for sending.
// Standard: Alice initializes with SK. Bob's ratchet key is remote.
// Alice generates `dhPair`.
// She performs a DH ratchet Step immediately?
// Protocol:
// Alice: RK = SK.
// Alice performs DH(alice_priv, bob_pub).
// Calculates RK, CK_send.
const dhOut = await computeSharedSecret(dhPair.privateKey, bobRatchetKey);
const kdf = await kdfRoot(sharedSecret, dhOut);
return {
dhPair,
remoteDhPub: bobRatchetKey,
rootKey: kdf.rootKey,
chainKeySend: kdf.chainKey,
chainKeyRecv: new Uint8Array(0).buffer, // Empty until Bob replies
ns: 0,
nr: 0,
pn: 0
};
}
export async function initReceiver(
sharedSecret: ArrayBuffer,
bobDhKeyPair: KeyPair // This is the SPK key pair used in X3DH
): Promise<RatchetState> {
// Bob: RK = SK.
// Bob has consistent state.
return {
dhPair: bobDhKeyPair,
remoteDhPub: bobDhKeyPair.publicKey, // Placeholder, will be updated on first msg
rootKey: sharedSecret,
chainKeySend: new Uint8Array(0).buffer,
chainKeyRecv: new Uint8Array(0).buffer, // Will be derived on first msg
ns: 0,
nr: 0,
pn: 0
};
}
// ----------------------------------------------------------------------------
// 5. Encrypt / Decrypt Message
// ----------------------------------------------------------------------------
export async function ratchetEncrypt(
state: RatchetState,
plaintext: string
): Promise<{
ciphertext: CiphertextMessage,
newState: RatchetState
}> {
// 1. Advance chain
const { ck: nextCk, mk } = await kdfChain(state.chainKeySend);
state.chainKeySend = nextCk;
// 2. Encrypt
const header: Header = {
dh: await exportKey(state.dhPair.publicKey),
pn: state.pn,
n: state.ns
};
const associatedData = new TextEncoder().encode(JSON.stringify(header));
const encrypted = await aeadEncrypt(mk, plaintext, associatedData);
state.ns += 1;
return {
ciphertext: {
header,
ciphertext: encrypted.ciphertext,
iv: encrypted.iv
},
newState: state
};
}
// Note: Decryption requires handling out-of-order messages and ratcheting steps.
// This is complex logic. For V2.1 baseline, we implement the core ratcheting step if header key differs.
export async function ratchetDecrypt(
state: RatchetState,
message: CiphertextMessage
): Promise<{ plaintext: string, newState: RatchetState }> {
// Check if DH ratchet step needed
// If message.header.dh != state.remoteDhPub
// Note: Comparing CryptoKeys directly is hard. We compare Base64 export.
const remoteKeyStr = await exportKey(state.remoteDhPub);
if (message.header.dh !== remoteKeyStr) {
// Ratchet Step!
const newRemoteKey = await importX25519PublicKey(message.header.dh);
// 1. DHRatchet(remote_new) -> RX step
const dhOut1 = await computeSharedSecret(state.dhPair.privateKey, newRemoteKey);
const kdf1 = await kdfRoot(state.rootKey, dhOut1);
state.rootKey = kdf1.rootKey;
state.chainKeyRecv = kdf1.chainKey;
// 2. Sender step (We generate new key)
state.pn = state.ns;
state.ns = 0;
state.nr = 0;
state.dhPair = await generateX25519KeyPair();
// 3. DHRatchet(remote_new) -> TX step
const dhOut2 = await computeSharedSecret(state.dhPair.privateKey, newRemoteKey);
const kdf2 = await kdfRoot(state.rootKey, dhOut2);
state.rootKey = kdf2.rootKey;
state.chainKeySend = kdf2.chainKey;
state.remoteDhPub = newRemoteKey;
}
// 3. Symmetric Ratchet to catch up to n
// (Skipping skipped-message buffering for now - assumes ordered delivery for V2.1 baseline)
// Advance Chain Recv to n
// Real impl buffers skipped keys.
// Warning: If n > nr, we must loop.
// For now, assuming direct sequence.
const { ck: nextCk, mk } = await kdfChain(state.chainKeyRecv);
state.chainKeyRecv = nextCk;
state.nr += 1;
// 4. Decrypt
const associatedData = new TextEncoder().encode(JSON.stringify(message.header));
const plaintext = await aeadDecrypt(mk, message.ciphertext, message.iv, associatedData);
return { plaintext, newState: state };
}
+14 -4
View File
@@ -126,7 +126,13 @@ export async function exportPublicKey(key: CryptoKey): Promise<string> {
* Import Public Key from SPKI Base64 (for verification)
*/
export async function importPublicKey(base64Key: string): Promise<CryptoKey> {
const binary = base64ToArrayBuffer(base64Key);
// Strip PEM headers and whitespace/newlines if present
const cleanKey = base64Key
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
.replace(/-----END PUBLIC KEY-----/g, '')
.replace(/[\s\n\r]/g, '');
const binary = base64ToArrayBuffer(cleanKey);
return await cryptoSubtle.importKey(
'spki',
binary,
@@ -179,10 +185,14 @@ export function canonicalize(obj: any): string {
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 pairs: string[] = [];
for (const key of keys) {
if (obj[key] === undefined) continue;
const val = canonicalize(obj[key]);
return `${JSON.stringify(key)}:${val}`;
});
pairs.push(`${JSON.stringify(key)}:${val}`);
}
return `{${pairs.join(',')}}`;
}
+241 -458
View File
@@ -1,504 +1,287 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import {
unlockChatStorage,
loadDeviceKeys,
storeDeviceKeys,
isStorageUnlocked,
storeEncrypted,
loadEncrypted
} from '@/lib/crypto/chat-storage';
import {
generateX25519KeyPair,
generateX25519KeyPair as generatePreKey,
exportKey,
importX25519PublicKey,
importX25519PrivateKey // Needed?
} from '@/lib/crypto/e2ee';
import {
initSender,
initReceiver,
ratchetEncrypt,
ratchetDecrypt,
x3dhSender,
x3dhReceiver,
RatchetState
} from '@/lib/crypto/ratchet';
import { useUserIdentity } from './useUserIdentity';
import { v4 as uuidv4 } from 'uuid';
// Storage keys
const PRIVATE_KEY_STORAGE = 'synapsis_chat_private_key';
const PUBLIC_KEY_STORAGE = 'synapsis_chat_public_key';
// Helper to check signature (we trust server for V2.1 baseline usually, but client check is better)
// import { verifyUserAction } from '...'; // Client side verification lib?
interface ChatKeys {
publicKey: string;
privateKey: string;
}
interface ServerKeyData {
chatPublicKey: string | null;
chatPrivateKeyEncrypted: string | null;
hasKeys: boolean;
}
/**
* Hook for managing E2E chat encryption
* Private keys are encrypted with user's password before server backup
*/
export function useChatEncryption() {
const [keys, setKeys] = useState<ChatKeys | null>(null);
const { signUserAction, identity } = useUserIdentity();
const [isReady, setIsReady] = useState(false);
const [isRegistering, setIsRegistering] = useState(false);
const [needsPasswordToRestore, setNeedsPasswordToRestore] = useState(false);
const [serverKeyData, setServerKeyData] = useState<ServerKeyData | null>(null);
const [status, setStatus] = useState<string>('idle');
// Check for existing keys on mount
useEffect(() => {
// Only run in browser
if (typeof window === 'undefined') {
return;
}
checkKeys();
}, []);
// Session Cache (In-Memory)
const sessionsRef = useRef<Map<string, RatchetState>>(new Map());
const checkKeys = async () => {
// First check localStorage
const publicKey = localStorage.getItem(PUBLIC_KEY_STORAGE);
const privateKey = localStorage.getItem(PRIVATE_KEY_STORAGE);
if (publicKey && privateKey) {
setKeys({ publicKey, privateKey });
setIsReady(true);
return;
}
// Check if server has encrypted backup
const ensureReady = useCallback(async (password: string, userId: string) => {
setStatus('initializing');
try {
const res = await fetch('/api/chat/keys');
if (res.ok) {
const data: ServerKeyData = await res.json();
setServerKeyData(data);
if (data.hasKeys && data.chatPrivateKeyEncrypted) {
// Keys exist on server but not locally - need password to restore
setNeedsPasswordToRestore(true);
}
if (!isStorageUnlocked()) {
await unlockChatStorage(password, userId);
}
let keys = await loadDeviceKeys();
// Check for legacy or V2 mix
// If we find V2 keys, good.
if (!keys) {
setStatus('generating_keys');
const identityKey = await generateX25519KeyPair();
const signedPreKey = await generatePreKey();
const otks = await Promise.all(Array.from({ length: 5 }).map(() => generatePreKey()));
const deviceId = uuidv4();
localStorage.setItem('synapsis_device_id', deviceId);
await storeDeviceKeys(identityKey, signedPreKey, otks);
const bundlePayload = {
deviceId,
identityKey: await exportKey(identityKey.publicKey),
signedPreKey: {
id: 1,
key: await exportKey(signedPreKey.publicKey),
},
oneTimeKeys: await Promise.all(otks.map(async (k, i) => ({
id: 100 + i,
key: await exportKey(k.publicKey)
})))
};
const signedAction = await signUserAction('chat.keys.publish', bundlePayload);
const res = await fetch('/api/chat/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedAction)
});
if (!res.ok) throw new Error('Failed to publish keys');
keys = { identity: identityKey, signedPreKey, otks };
}
// Restore session cache?
// Ideally load all sessions? Lazy load is better.
setIsReady(true);
setStatus('ready');
} catch (error) {
console.error('Failed to check server keys:', error);
console.error('Chat init failed:', error);
setStatus('error');
throw error;
}
}, [signUserAction]);
setIsReady(true);
};
const sendMessage = useCallback(async (recipientDid: string, content: string) => {
if (!isReady || !identity) throw new Error('Chat not ready');
// Restore keys from server backup using password
const restoreKeysWithPassword = useCallback(async (password: string): Promise<boolean> => {
if (!serverKeyData?.chatPrivateKeyEncrypted || !serverKeyData?.chatPublicKey) {
throw new Error('No keys to restore');
}
// 1. Fetch Recipient Bundles
const res = await fetch(`/.well-known/synapsis/chat/${recipientDid}`);
if (!res.ok) throw new Error('Recipient not found');
const bundles: any[] = await res.json();
try {
// Decrypt the private key using password
const privateKey = await decryptPrivateKeyWithPassword(
serverKeyData.chatPrivateKeyEncrypted,
password
);
const localDeviceId = localStorage.getItem('synapsis_device_id');
if (!localDeviceId) throw new Error('No local device ID');
// Store in localStorage
localStorage.setItem(PUBLIC_KEY_STORAGE, serverKeyData.chatPublicKey);
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
const localKeys = await loadDeviceKeys();
if (!localKeys) throw new Error('Keys lost');
setKeys({ publicKey: serverKeyData.chatPublicKey, privateKey });
setNeedsPasswordToRestore(false);
return true;
} catch (error) {
console.error('Failed to restore keys:', error);
return false;
}
}, [serverKeyData]);
// 2. Loop through all devices
for (const bundle of bundles) {
const sessionKey = `session:${recipientDid}:${bundle.deviceId}`;
// Generate new keys and register with server (encrypted backup)
const generateAndRegisterKeys = useCallback(async (password: string) => {
if (typeof window === 'undefined') {
throw new Error('Key generation can only be performed in the browser');
}
setIsRegistering(true);
try {
// Generate ECDH key pair using Web Crypto API
const keyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveKey']
);
let state = sessionsRef.current.get(sessionKey);
if (!state) {
state = await loadEncrypted<RatchetState>(sessionKey) || undefined;
}
const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey);
const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
let headerData: any = null;
const publicKey = bufferToBase64(publicKeyBuffer);
const privateKey = bufferToBase64(privateKeyBuffer);
if (!state) {
// X3DH Init
const remoteIdentityKey = await importX25519PublicKey(bundle.identityKey);
const remoteSignedPreKey = await importX25519PublicKey(bundle.signedPreKey.key);
const otk = bundle.oneTimeKeys[0];
const remoteOtk = otk ? await importX25519PublicKey(otk.key) : undefined;
console.log('[GenerateKeys] Generated keys:', {
publicKeyLength: publicKey.length,
privateKeyLength: privateKey.length,
publicKeyBytes: publicKeyBuffer.byteLength,
privateKeyBytes: privateKeyBuffer.byteLength
});
const { sk, ephemeralKey } = await x3dhSender(
localKeys.identity,
{ identityKey: remoteIdentityKey, signedPreKey: remoteSignedPreKey, oneTimeKey: remoteOtk },
`SynapsisV2${[identity.did, recipientDid].sort().join('')}${[localDeviceId, bundle.deviceId].sort().join('')}`
);
// Encrypt private key with password for server backup
const encryptedPrivateKey = await encryptPrivateKeyWithPassword(privateKey, password);
state = await initSender(sk, remoteSignedPreKey);
console.log('[GenerateKeys] Encrypted private key length:', encryptedPrivateKey.length);
headerData = {
ik: await exportKey(localKeys.identity.publicKey),
ek: await exportKey(ephemeralKey.publicKey),
spkId: bundle.signedPreKey.id,
opkId: otk?.id
};
}
// Register public key + encrypted private key backup with server FIRST
const response = await fetch('/api/chat/keys', {
const { ciphertext, newState } = await ratchetEncrypt(state, content);
sessionsRef.current.set(sessionKey, newState);
await storeEncrypted(sessionKey, newState);
// Payload
const payload = {
recipientDid,
recipientDeviceId: bundle.deviceId,
senderDeviceId: localDeviceId, // V2.1 Addition
ciphertext: ciphertext.ciphertext,
header: headerData ? { ...headerData, ...ciphertext.header } : ciphertext.header,
iv: ciphertext.iv
};
const fullData = {
recipientDid,
recipientDeviceId: bundle.deviceId,
ciphertext: JSON.stringify(payload)
};
const action = await signUserAction('chat.deliver', fullData);
await fetch('/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatPublicKey: publicKey,
chatPrivateKeyEncrypted: encryptedPrivateKey,
}),
body: JSON.stringify(action)
});
if (!response.ok) {
const error = await response.json();
console.error('[GenerateKeys] Server registration failed:', error);
throw new Error(error.error || 'Failed to register chat keys');
}
// Only save to localStorage AFTER server confirms
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey);
setKeys({ publicKey, privateKey });
setNeedsPasswordToRestore(false);
console.log('[GenerateKeys] Keys generated and registered successfully');
return { publicKey, privateKey };
} catch (error) {
console.error('[GenerateKeys] Failed:', error);
throw error;
} finally {
setIsRegistering(false);
}
}, []);
// Encrypt a message for a recipient
const encryptMessage = useCallback(async (
message: string,
recipientPublicKey: string
): Promise<string> => {
if (typeof window === 'undefined') {
throw new Error('Encryption can only be performed in the browser');
}
if (!keys?.privateKey) {
throw new Error('No chat keys available');
}
const myPrivateKey = await importPrivateKey(keys.privateKey);
const theirPublicKey = await importPublicKey(recipientPublicKey);
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
}, [isReady, identity, signUserAction]);
const encoder = new TextEncoder();
const messageBytes = encoder.encode(message);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
/**
* Decrypt and verify an incoming envelope
*/
const decryptMessage = useCallback(async (envelope: any) => {
if (!isReady || !identity) return '[Chat not ready]';
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
sharedKey,
messageBytes
);
// Combine iv + ciphertext
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.length);
return bufferToBase64(combined.buffer);
}, [keys]);
// Decrypt a message from a sender
const decryptMessage = useCallback(async (
encryptedMessage: string,
senderPublicKey: string
): Promise<string> => {
// Early browser check before any operations
if (typeof window === 'undefined') {
return '[Decryption only available in browser]';
}
try {
if (!keys?.privateKey) {
console.error('[Decrypt] No private key available');
return '[No decryption key available]';
}
if (!senderPublicKey) {
console.error('[Decrypt] No sender public key provided');
return '[Sender key missing]';
// 1. Check Envelope Structure
// Envelope is SignedAction.
// We assume signature verified by server/trusted for now (TODO: Client verify)
const { did: senderDid, data } = envelope;
const payloadString = data.ciphertext; // inner JSON payload
if (!payloadString) return '[Legacy Message]'; // Fail gracefully
const payload = JSON.parse(payloadString);
const { recipientDeviceId, senderDeviceId, ciphertext, header, iv } = payload;
const localDeviceId = localStorage.getItem('synapsis_device_id');
if (recipientDeviceId !== localDeviceId) return `[Message for device ${recipientDeviceId.slice(0, 4)}...]`;
// 2. Load Session
const sessionKey = `session:${senderDid}:${senderDeviceId}`;
let state = sessionsRef.current.get(sessionKey);
if (!state) {
state = await loadEncrypted<RatchetState>(sessionKey) || undefined;
}
const myPrivateKey = await importPrivateKey(keys.privateKey);
const theirPublicKey = await importPublicKey(senderPublicKey);
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
// 3. X3DH Receiver Init if needed
if (!state) {
// If it's a new session, headers MUST contain X3DH info (ik, ek, spkId, opkId)
if (!header.ik || !header.ek) return '[Invalid Init Header]';
const combined = base64ToBuffer(encryptedMessage);
const localKeys = await loadDeviceKeys();
if (!localKeys) return '[Keys locked]';
if (combined.byteLength < 12) {
throw new Error('Message too short (invalid ciphertext)');
}
// Recover keys
const senderIdentityKey = await importX25519PublicKey(header.ik);
const senderEphemeralKey = await importX25519PublicKey(header.ek);
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
// Find my used OTK
// Ideally we consume it and delete it.
// For now, load it.
// In V2.1 "chat_one_time_keys" table stores them. BUT we need private key locally.
// localKeys.otks is array.
// We find the one with id == header.opkId
// Caution: types for otks is Array<KeyPair>. ID is assumed sequential/mapped?
// In generation I assigned arbitrary IDs.
// Re-check generation: `id: 100 + i`.
// I need to map ID to private key.
// In `storeDeviceKeys` I stored them as array.
// I need to match valid key.
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
sharedKey,
ciphertext
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
console.warn('[Decrypt] Failed:', error instanceof Error ? error.message : error);
// Return a descriptive placeholder based on the error
if (error instanceof Error) {
if (error.message.includes('public key') || error.message.includes('import key')) {
return '[Incompatible encryption format]';
}
if (error.message.includes('private key')) {
return '[Invalid private key]';
}
if (error.message.includes('base64') || error.message.includes('decode')) {
return '[Corrupted message data]';
let myOtk: any = undefined;
if (header.opkId) {
// Find index? ID 100 -> index 0?
const index = header.opkId - 100;
if (index >= 0 && index < localKeys.otks.length) {
myOtk = localKeys.otks[index];
}
}
const sk = await x3dhReceiver(
localKeys.identity,
localKeys.signedPreKey,
myOtk,
senderIdentityKey,
senderEphemeralKey,
`SynapsisV2${[senderDid, identity.did].sort().join('')}${[senderDeviceId, localDeviceId].sort().join('')}`
);
state = await initReceiver(sk, localKeys.signedPreKey); // Using SPK pair as initial
}
return '[Cannot decrypt message]';
// 4. Decrypt
// Reconstruct CiphertextMessage
const msgStruct: any = {
header: header, // contains dh, pn, n
ciphertext: ciphertext,
iv: iv
};
const { plaintext, newState } = await ratchetDecrypt(state, msgStruct);
// 5. Update Session
sessionsRef.current.set(sessionKey, newState);
await storeEncrypted(sessionKey, newState);
return plaintext;
} catch (e: any) {
console.error('Decryption failed:', e);
return `[Decryption Error: ${e.message}]`;
}
}, [keys]);
// Clear keys (on logout)
const clearKeys = useCallback(() => {
localStorage.removeItem(PRIVATE_KEY_STORAGE);
localStorage.removeItem(PUBLIC_KEY_STORAGE);
setKeys(null);
}, []);
}, [isReady, identity]);
return {
keys,
isReady,
isRegistering,
hasKeys: !!keys,
needsPasswordToRestore,
generateAndRegisterKeys,
restoreKeysWithPassword,
encryptMessage,
decryptMessage,
clearKeys,
status,
ensureReady,
sendMessage,
decryptMessage
};
}
// ============================================
// Password-based encryption for private key backup
// ============================================
async function encryptPrivateKeyWithPassword(privateKey: string, password: string): Promise<string> {
if (typeof window === 'undefined') {
throw new Error('Encryption can only be performed in the browser');
}
const encoder = new TextEncoder();
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
// 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,
iterations: 100000,
hash: 'SHA-256',
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
// Encrypt the private key
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
encoder.encode(privateKey)
);
// Return as JSON with all components
return JSON.stringify({
salt: bufferToBase64(salt.buffer),
iv: bufferToBase64(iv.buffer),
ciphertext: bufferToBase64(ciphertext),
});
}
async function decryptPrivateKeyWithPassword(encryptedData: string, password: string): Promise<string> {
if (typeof window === 'undefined') {
throw new Error('Decryption can only be performed in the browser');
}
const { salt, iv, ciphertext } = JSON.parse(encryptedData);
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// Derive key from password
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(salt),
iterations: 100000,
hash: 'SHA-256',
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
// Decrypt
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBuffer(iv) },
aesKey,
base64ToBuffer(ciphertext)
);
return decoder.decode(decrypted);
}
// ============================================
// ECDH Key helpers
// ============================================
async function importPublicKey(publicKeyBase64: string): Promise<CryptoKey> {
if (typeof window === 'undefined') {
throw new Error('Crypto operations can only be performed in the browser');
}
// Validate the key format
if (!publicKeyBase64 || typeof publicKeyBase64 !== 'string') {
throw new Error('Invalid public key: must be a non-empty string');
}
try {
const keyBuffer = base64ToBuffer(publicKeyBase64);
// Try SPKI format first (standard format, typically ~91 bytes for P-256)
try {
return await window.crypto.subtle.importKey(
'spki',
keyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
} catch (spkiError) {
// Try raw format (65 bytes for uncompressed P-256 public key)
// Raw format is: 0x04 + X coordinate (32 bytes) + Y coordinate (32 bytes)
if (keyBuffer.byteLength === 65) {
try {
return await window.crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
} catch (rawError) {
// Both formats failed
}
}
// If neither worked, throw a descriptive error
throw new Error(`Cannot import key (${keyBuffer.byteLength} bytes): incompatible format`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.warn('[ImportPublicKey] Failed:', errorMsg);
throw new Error(`Failed to import public key: ${errorMsg}`);
}
}
async function importPrivateKey(privateKeyBase64: string): Promise<CryptoKey> {
if (typeof window === 'undefined') {
throw new Error('Crypto operations can only be performed in the browser');
}
const keyBuffer = base64ToBuffer(privateKeyBase64);
return window.crypto.subtle.importKey(
'pkcs8',
keyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
['deriveKey']
);
}
async function deriveSharedKey(
myPrivateKey: CryptoKey,
theirPublicKey: CryptoKey
): Promise<CryptoKey> {
if (typeof window === 'undefined') {
throw new Error('Key derivation can only be performed in the browser');
}
return window.crypto.subtle.deriveKey(
{ name: 'ECDH', public: theirPublicKey },
myPrivateKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
// ============================================
// Buffer utilities
// ============================================
function bufferToBase64(buffer: ArrayBuffer): string {
// btoa is available in both browser and Node 16+, but let's be safe
if (typeof btoa === 'undefined') {
throw new Error('Base64 encoding not available in this environment');
}
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToBuffer(base64: string): ArrayBuffer {
// Gracefully handle null/undefined
if (!base64) return new ArrayBuffer(0);
// Check for JSON (legacy format)
if (base64.trim().startsWith('{')) {
console.warn('[base64ToBuffer] Detected JSON instead of Base64, returning empty buffer');
throw new Error('Invalid message format: JSON detected');
}
// Clean the string:
// 1. Remove newlines/tabs (formatting)
// 2. Replace spaces with '+' (common URL decoding error where + becomes space)
// 3. Handle URL-safe chars (- -> +, _ -> /)
const cleaned = base64.replace(/[\n\r\t]/g, '')
.replace(/ /g, '+')
.replace(/-/g, '+')
.replace(/_/g, '/');
try {
// atob is available in both browser and Node 16+, but let's be safe
if (typeof atob === 'undefined') {
throw new Error('Base64 decoding not available in this environment');
}
const binary = atob(cleaned);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
} catch (e) {
console.error('[base64ToBuffer] Failed to decode base64:', e);
throw new Error(`Failed to decode base64: ${e instanceof Error ? e.message : String(e)}`);
}
}
+13 -1
View File
@@ -13,7 +13,8 @@ import {
generateKeyPair,
exportPrivateKey,
exportPublicKey,
base64UrlToBase64
base64UrlToBase64,
createSignedAction
} from '@/lib/crypto/user-signing';
export interface UserIdentity {
@@ -117,6 +118,16 @@ export function useUserIdentity() {
setIsUnlocked(false);
};
/**
* Sign a user action
*/
const signUserAction = async (action: string, data: any) => {
if (!identity || !isUnlocked) {
throw new Error('Identity locked');
}
return await createSignedAction(action, data, identity.did, identity.handle);
};
return {
identity,
isUnlocked,
@@ -124,5 +135,6 @@ export function useUserIdentity() {
unlockIdentity,
lockIdentity,
clearIdentity,
signUserAction
};
}