diff --git a/scripts/inspect-key.ts b/scripts/inspect-key.ts new file mode 100644 index 0000000..4bfc9ed --- /dev/null +++ b/scripts/inspect-key.ts @@ -0,0 +1,50 @@ + +import 'dotenv/config'; +import { db } from '@/db'; +import { handleRegistry } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +const TARGET_DID = 'did:synapsis:75aea1b8630142f59e3cd893ec1d88e5'; // The one that failed + +async function main() { + console.log('--- Inspecting Remote Key ---'); + + const entry = await db.query.handleRegistry.findFirst({ + where: eq(handleRegistry.did, TARGET_DID), + }); + + if (!entry) { + console.log('Registry entry not found!'); + return; + } + + console.log(`Registry: ${entry.handle} @ ${entry.nodeDomain}`); + + // Try keys endpoint + const keysUrl = `https://${entry.nodeDomain}/api/chat/keys?did=${encodeURIComponent(TARGET_DID)}`; + console.log('Fetching:', keysUrl); + + try { + const res = await fetch(keysUrl); + if (res.ok) { + const data = await res.json(); + console.log('Key Data:', data); + + const key = data.publicKey; + console.log('Key:', key); + console.log('Length:', key.length); + + // Checks + const isBase64 = /^[A-Za-z0-9+/]*={0,2}$/.test(key); + const isHex = /^[0-9a-fA-F]+$/.test(key); + console.log('Is Base64-ish:', isBase64); + console.log('Is Hex-ish:', isHex); + } else { + console.log('Fetch failed:', res.status, await res.text()); + } + } catch (e) { + console.error(e); + } +} + +main().catch(console.error).then(() => process.exit(0)); diff --git a/scripts/test-crypto.js b/scripts/test-crypto.js new file mode 100644 index 0000000..c5f6f9a --- /dev/null +++ b/scripts/test-crypto.js @@ -0,0 +1,34 @@ + +const sodium = require('libsodium-wrappers-sumo'); + +const BAD_KEY_B64 = "z+X63kLk1GoV0gJvPtqNisWuZ3XmlTbfMh9gW0xqcmg="; + +async function main() { + console.log('--- Initializing Sodium ---'); + await sodium.ready; + console.log('Sodium Ready. Variants:', sodium.base64_variants); + + const variants = [ + { name: 'Default (undefined)', val: undefined }, + { name: 'ORIGINAL', val: sodium.base64_variants.ORIGINAL }, + { name: 'ORIGINAL_NO_PADDING', val: sodium.base64_variants.ORIGINAL_NO_PADDING }, + { name: 'URLSAFE', val: sodium.base64_variants.URLSAFE }, + { name: 'URLSAFE_NO_PADDING', val: sodium.base64_variants.URLSAFE_NO_PADDING }, + ]; + + for (const v of variants) { + process.stdout.write(`Testing: ${v.name}... `); + try { + // If checking NO_PADDING, manually strip padding for fairness + let keyStr = BAD_KEY_B64; + if (v.name.includes('NO_PADDING')) keyStr = keyStr.replace(/=/g, ''); + + const buf = sodium.from_base64(keyStr, v.val); + console.log(`SUCCESS! Length: ${buf.length}`); + } catch (e) { + console.log(`FAILED.`); + } + } +} + +main(); diff --git a/src/app/api/chat/keys/route.ts b/src/app/api/chat/keys/route.ts index f4217b3..b252cf3 100644 --- a/src/app/api/chat/keys/route.ts +++ b/src/app/api/chat/keys/route.ts @@ -3,7 +3,6 @@ import { db } from '@/db'; import { chatDeviceBundles, handleRegistry, remoteIdentityCache } from '@/db/schema'; import { requireAuth } from '@/lib/auth'; import { eq, and, gt } from 'drizzle-orm'; -import { updateRegistryFromProfile } from '@/lib/swarm/healing'; /** * GET /api/chat/keys?did= @@ -116,9 +115,6 @@ export async function GET(request: NextRequest) { if (chatKey) { console.log('[Chat Keys GET] Found key in Swarm Profile'); - // HEALING: Update the registry because we found the user at this nodeDomain! - await updateRegistryFromProfile(handleEntry.handle, did, nodeDomain); - // Cache it await db.insert(remoteIdentityCache).values({ did: did, diff --git a/src/lib/crypto/sodium-chat.ts b/src/lib/crypto/sodium-chat.ts index df84672..329f0b7 100644 --- a/src/lib/crypto/sodium-chat.ts +++ b/src/lib/crypto/sodium-chat.ts @@ -26,10 +26,10 @@ export async function initSodium() { function openDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); - + request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); - + request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains(STORE_NAME)) { @@ -47,15 +47,47 @@ export async function generateChatKeyPair(): Promise<{ privateKey: string; // base64 }> { await initSodium(); - + const keyPair = sodium.crypto_box_keypair(); - + return { publicKey: sodium.to_base64(keyPair.publicKey), privateKey: sodium.to_base64(keyPair.privateKey), }; } +// Helper to robustly decode Base64 regardless of variant (Original vs URLSafe) +function tryDecodeBase64(str: string): Uint8Array { + const variants = [ + // Standard Base64 (likely for Remote keys with +/) + (sodium.base64_variants && sodium.base64_variants.ORIGINAL), + // URL-Safe Base64 (likely for Local keys) + (sodium.base64_variants && sodium.base64_variants.URLSAFE), + // No Padding variants + (sodium.base64_variants && sodium.base64_variants.ORIGINAL_NO_PADDING), + (sodium.base64_variants && sodium.base64_variants.URLSAFE_NO_PADDING), + // Fallback/Default + undefined + ]; + + // Filter out undefined variants (if enum missing) and create unique set + const validVariants = [...new Set(variants.filter(v => v !== undefined))]; + // Add undefined at the end as fallback if not present + if (!validVariants.includes(undefined)) validVariants.push(undefined); + + let lastError; + for (const v of validVariants) { + try { + return v !== undefined + ? sodium.from_base64(str, v) + : sodium.from_base64(str); + } catch (e) { + lastError = e; + } + } + throw lastError || new Error('Failed to decode Base64 with any variant'); +} + /** * Encrypt a message for a recipient */ @@ -68,26 +100,43 @@ export async function encryptMessage( nonce: string; // base64 }> { await initSodium(); - - const messageBytes = sodium.from_string(message); - const recipientPubKey = sodium.from_base64(recipientPublicKey); - const senderPrivKey = sodium.from_base64(senderPrivateKey); - - // Generate random nonce - const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES); - - // Encrypt - const ciphertext = sodium.crypto_box_easy( - messageBytes, - nonce, - recipientPubKey, - senderPrivKey - ); - - return { - ciphertext: sodium.to_base64(ciphertext), - nonce: sodium.to_base64(nonce), - }; + + try { + const messageBytes = sodium.from_string(message); + + // keys may be dirty or differ in variant + const cleanRecipientKey = recipientPublicKey.trim(); + const cleanSenderKey = senderPrivateKey.trim(); + + // Robust decode for both keys independently + // This solves the issue where Local Key is URLSAFE but Remote Key is ORIGINAL + const recipientPubKey = tryDecodeBase64(cleanRecipientKey); + const senderPrivKey = tryDecodeBase64(cleanSenderKey); + + // Generate random nonce + const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES); + + // Encrypt + const ciphertext = sodium.crypto_box_easy( + messageBytes, + nonce, + recipientPubKey, + senderPrivKey + ); + + return { + ciphertext: sodium.to_base64(ciphertext), + nonce: sodium.to_base64(nonce), + }; + } catch (err) { + console.error('[Sodium-Chat] Encryption failed:', err); + console.error('Keys Debug:', { + recipientLen: recipientPublicKey.length, + senderLen: senderPrivateKey.length, + recipientStart: recipientPublicKey.substring(0, 5) + }); + throw err; + } } /** @@ -100,12 +149,12 @@ export async function decryptMessage( recipientPrivateKey: string // base64 ): Promise { await initSodium(); - - const ciphertextBytes = sodium.from_base64(ciphertext); - const nonceBytes = sodium.from_base64(nonce); - const senderPubKey = sodium.from_base64(senderPublicKey); - const recipientPrivKey = sodium.from_base64(recipientPrivateKey); - + + const ciphertextBytes = tryDecodeBase64(ciphertext); + const nonceBytes = tryDecodeBase64(nonce); + const senderPubKey = tryDecodeBase64(senderPublicKey); + const recipientPrivKey = tryDecodeBase64(recipientPrivateKey); + // Decrypt const decrypted = sodium.crypto_box_open_easy( ciphertextBytes, @@ -113,7 +162,7 @@ export async function decryptMessage( senderPubKey, recipientPrivKey ); - + return sodium.to_string(decrypted); } @@ -121,40 +170,40 @@ export async function decryptMessage( * Store keys in IndexedDB (encrypted with storage key from memory) */ export async function storeKeys( - userId: string, - publicKey: string, - privateKey: string, + userId: string, + publicKey: string, + privateKey: string, storageKey: Uint8Array ): Promise { await initSodium(); - + // Generate random nonce const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); - + // Encrypt private key with storage key const privateKeyBytes = sodium.from_string(privateKey); const ciphertext = sodium.crypto_secretbox_easy(privateKeyBytes, nonce, storageKey); - + // Combine nonce + ciphertext const combined = new Uint8Array(nonce.length + ciphertext.length); combined.set(nonce, 0); combined.set(ciphertext, nonce.length); - + // Store in IndexedDB const db = await openDB(); const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); - + await new Promise((resolve, reject) => { const request = store.put({ publicKey, encryptedPrivateKey: sodium.to_base64(combined) }, userId); - + request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); - + db.close(); } @@ -162,42 +211,70 @@ export async function storeKeys( * Retrieve keys from IndexedDB (decrypt with storage key from memory) */ export async function getStoredKeys( - userId: string, + userId: string, storageKey: Uint8Array ): Promise<{ publicKey: string; privateKey: string } | null> { await initSodium(); - + try { const db = await openDB(); const tx = db.transaction(STORE_NAME, 'readonly'); const store = tx.objectStore(STORE_NAME); - + const data = await new Promise((resolve, reject) => { const request = store.get(userId); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); - + db.close(); - + if (!data) { console.log('[Sodium] No stored keys found in IndexedDB for user:', userId); return null; } - + console.log('[Sodium] Found stored keys in IndexedDB, attempting to decrypt...'); - + const { publicKey, encryptedPrivateKey } = data; - + // Extract nonce and ciphertext const combined = sodium.from_base64(encryptedPrivateKey); const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES); const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES); - + // Decrypt with storage key const decrypted = sodium.crypto_secretbox_open_easy(ciphertext, nonce, storageKey); - const privateKey = sodium.to_string(decrypted); - + let privateKey = sodium.to_string(decrypted); + + // Validate that the decrypted key is actually valid Base64 + try { + tryDecodeBase64(privateKey); + } catch (e) { + console.warn('[Sodium] Private key appears invalid, attempting repair...'); + + // Attempt 1: Trim whitespace + let repaired = privateKey.trim(); + + // Attempt 2: Remove quotes (common JSON artifact) + repaired = repaired.replace(/['"]/g, ''); + + // Attempt 3: Remove newlines + repaired = repaired.replace(/[\n\r]/g, ''); + + try { + tryDecodeBase64(repaired); + console.log('[Sodium] Private key REPAIRED successfully!'); + privateKey = repaired; + } catch (finalErr) { + console.error('[Sodium] Decrypted private key is IRREPARABLE! Key store corrupted.'); + // We have to return null here as last resort, but we tried everything. + // Log the length/characteristics to help debug if this happens + console.error('Bad Key CharCodes:', privateKey.split('').map(c => c.charCodeAt(0)).slice(0, 10)); + return null; + } + } + console.log('[Sodium] Successfully decrypted stored keys'); return { publicKey, privateKey }; } catch (error) { @@ -214,13 +291,13 @@ export async function clearStoredKeys(userId: string): Promise { const db = await openDB(); const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); - + await new Promise((resolve, reject) => { const request = store.delete(userId); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); - + db.close(); } catch (error) { console.error('[Sodium] Failed to clear keys:', error); diff --git a/src/lib/federation/handles.ts b/src/lib/federation/handles.ts index cd5d683..0628064 100644 --- a/src/lib/federation/handles.ts +++ b/src/lib/federation/handles.ts @@ -11,11 +11,7 @@ export type HandleEntry = { export const normalizeHandle = (handle: string) => handle.toLowerCase().replace(/^@/, '').trim(); -// [Modified] Added sourceDomain parameter -export async function upsertHandleEntries( - entries: HandleEntry[], - sourceDomain?: string -) { +export async function upsertHandleEntries(entries: HandleEntry[]) { if (!db) { return { added: 0, updated: 0 }; } @@ -49,22 +45,7 @@ export async function upsertHandleEntries( continue; } - // PROPAGATION FIX: - // 1. If the update comes from the node that OWNS the handle (sourceDomain == entry.nodeDomain), - // we treat it as authoritative. - // 2. We allow updates if the timestamp is newer OR equal (to handle clock skew). - // 3. We allow updates if the authoritative source is correcting a mismatch (e.g. we thought it was distinct, they say it's them). - - const isAuthoritative = sourceDomain && (sourceDomain === entry.nodeDomain); - const isNewerOrEqual = incomingUpdatedAt.getTime() >= (existing.updatedAt?.getTime() || 0); - - // If authoritative, we accept it even if timestamps are identical (recovery) - // If not authoritative, only accept strictly newer - const shouldUpdate = isAuthoritative - ? isNewerOrEqual || (existing.nodeDomain !== entry.nodeDomain) // Auto-correct wrong domain - : incomingUpdatedAt > (existing.updatedAt || new Date(0)); - - if (shouldUpdate) { + if (!existing.updatedAt || incomingUpdatedAt > existing.updatedAt) { await db.update(handleRegistry) .set({ did: entry.did, diff --git a/src/lib/swarm/gossip.ts b/src/lib/swarm/gossip.ts index 3ede9a3..23723bc 100644 --- a/src/lib/swarm/gossip.ts +++ b/src/lib/swarm/gossip.ts @@ -91,8 +91,7 @@ export async function processGossip( // Process incoming handles let handlesResult = { added: 0, updated: 0 }; if (payload.handles && payload.handles.length > 0) { - // PASS SENDER: This allows us to trust "authoritative" updates from the node itself - handlesResult = await upsertHandleEntries(payload.handles, payload.sender); + handlesResult = await upsertHandleEntries(payload.handles); } // Build our response with nodes/handles to share back @@ -164,8 +163,7 @@ export async function gossipToNode( let handlesResult = { added: 0, updated: 0 }; if (gossipResponse.handles && gossipResponse.handles.length > 0) { - // PASS TARGET: If we gossiped TO them, and they replied, treat their reply as authoritative - handlesResult = await upsertHandleEntries(gossipResponse.handles, targetDomain); + handlesResult = await upsertHandleEntries(gossipResponse.handles); } await markNodeSuccess(targetDomain); diff --git a/src/lib/swarm/healing.ts b/src/lib/swarm/healing.ts deleted file mode 100644 index 6eb4342..0000000 --- a/src/lib/swarm/healing.ts +++ /dev/null @@ -1,65 +0,0 @@ - -import { db, handleRegistry, users } from '@/db'; -import { eq } from 'drizzle-orm'; -import { gossipToNode } from './gossip'; -import { fetchSwarmUserProfile, fetchSwarmPost } from './interactions'; -import { upsertHandleEntries } from '@/lib/federation/handles'; - -/** - * Attempt to "heal" a connection to a user by finding their correct node - * and syncing with it. - * - * @param did - The DID of the user we are trying to reach - * @param knownDomain - The last known domain (that failed) - */ -export async function healNodeConnection(did: string, knownDomain?: string): Promise { - console.log(`[Swarm Healing] Attempting to heal connection for DID: ${did} (Last known: ${knownDomain})`); - - // 1. Try to find logic to recover the domain - // If we have a handle locally, we can try to resolve it via other means? - // Actually, if we have the DID, we might be able to query a "seed node" if we had one. - // For now, let's assume we might have the wrong domain in the registry, - // OR the registry is correct but we just haven't gossiped recently. - - // If we have a known domain (that failed 404), maybe we should try to GOSSIP with it? - // Sometimes a node knows about itself but we haven't synced it. - if (knownDomain) { - console.log(`[Swarm Healing] Triggering forced gossip with ${knownDomain}`); - try { - const result = await gossipToNode(knownDomain); - if (result.success && result.handlesReceived > 0) { - console.log(`[Swarm Healing] Gossip successful, handles updated.`); - // Check if our specific user was updated? - // We trust upsertHandleEntries handled it if authoritative. - return true; - } - } catch (e) { - console.error(`[Swarm Healing] Gossip to ${knownDomain} failed:`, e); - } - } - - // 2. Fallback: If we know the user's handle but NOT the domain (or domain failed completely) - // We can try to guess or use a fallback mechanism. - - // Let's try to look up the user in our local "Users" table to see if we have a clue? - // Or assume the formatting of the DID might give a hint? (Did is meaningless usually). - - return false; -} - -/** - * "Soft" Heal: We successfully fetched a user profile from a URL, - * but our registry might be out of date. Update it immediately. - */ -export async function updateRegistryFromProfile(handle: string, did: string, nodeDomain: string) { - if (!handle || !did || !nodeDomain) return; - - console.log(`[Swarm Healing] Updating registry from valid profile fetch: ${handle}@${nodeDomain}`); - - await upsertHandleEntries([{ - handle, - did, - nodeDomain, - updatedAt: new Date().toISOString() - }], nodeDomain); -}