Refactor handle registry updates and improve key decoding
Removed 'healing' logic and authoritative update propagation from handle registry and gossip code, simplifying handle upserts to only update on strictly newer timestamps. Enhanced sodium-chat key handling with robust base64 decoding and key repair logic. Added scripts for key inspection and crypto testing. Deleted unused healing module.
This commit is contained in:
@@ -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));
|
||||
@@ -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();
|
||||
@@ -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=<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,
|
||||
|
||||
@@ -56,6 +56,38 @@ export async function generateChatKeyPair(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
*/
|
||||
@@ -69,25 +101,42 @@ export async function encryptMessage(
|
||||
}> {
|
||||
await initSodium();
|
||||
|
||||
const messageBytes = sodium.from_string(message);
|
||||
const recipientPubKey = sodium.from_base64(recipientPublicKey);
|
||||
const senderPrivKey = sodium.from_base64(senderPrivateKey);
|
||||
try {
|
||||
const messageBytes = sodium.from_string(message);
|
||||
|
||||
// Generate random nonce
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
||||
// keys may be dirty or differ in variant
|
||||
const cleanRecipientKey = recipientPublicKey.trim();
|
||||
const cleanSenderKey = senderPrivateKey.trim();
|
||||
|
||||
// Encrypt
|
||||
const ciphertext = sodium.crypto_box_easy(
|
||||
messageBytes,
|
||||
nonce,
|
||||
recipientPubKey,
|
||||
senderPrivKey
|
||||
);
|
||||
// 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);
|
||||
|
||||
return {
|
||||
ciphertext: sodium.to_base64(ciphertext),
|
||||
nonce: sodium.to_base64(nonce),
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,10 +150,10 @@ export async function decryptMessage(
|
||||
): Promise<string> {
|
||||
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(
|
||||
@@ -196,7 +245,35 @@ export async function getStoredKeys(
|
||||
|
||||
// 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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user