feat: Implement end-to-end encrypted chat with new API routes, crypto utilities, and UI components.
This commit is contained in:
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+34
-15
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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,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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user