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:
Christopher
2026-01-28 07:16:15 -08:00
parent 36f653d500
commit f678f1f87e
7 changed files with 219 additions and 148 deletions
+50
View File
@@ -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));
+34
View File
@@ -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();
-4
View File
@@ -3,7 +3,6 @@ import { db } from '@/db';
import { chatDeviceBundles, handleRegistry, remoteIdentityCache } from '@/db/schema'; import { chatDeviceBundles, handleRegistry, remoteIdentityCache } from '@/db/schema';
import { requireAuth } from '@/lib/auth'; import { requireAuth } from '@/lib/auth';
import { eq, and, gt } from 'drizzle-orm'; import { eq, and, gt } from 'drizzle-orm';
import { updateRegistryFromProfile } from '@/lib/swarm/healing';
/** /**
* GET /api/chat/keys?did=<did> * GET /api/chat/keys?did=<did>
@@ -116,9 +115,6 @@ export async function GET(request: NextRequest) {
if (chatKey) { if (chatKey) {
console.log('[Chat Keys GET] Found key in Swarm Profile'); 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 // Cache it
await db.insert(remoteIdentityCache).values({ await db.insert(remoteIdentityCache).values({
did: did, did: did,
+98 -21
View File
@@ -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 * Encrypt a message for a recipient
*/ */
@@ -69,25 +101,42 @@ export async function encryptMessage(
}> { }> {
await initSodium(); await initSodium();
const messageBytes = sodium.from_string(message); try {
const recipientPubKey = sodium.from_base64(recipientPublicKey); const messageBytes = sodium.from_string(message);
const senderPrivKey = sodium.from_base64(senderPrivateKey);
// Generate random nonce // keys may be dirty or differ in variant
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES); const cleanRecipientKey = recipientPublicKey.trim();
const cleanSenderKey = senderPrivateKey.trim();
// Encrypt // Robust decode for both keys independently
const ciphertext = sodium.crypto_box_easy( // This solves the issue where Local Key is URLSAFE but Remote Key is ORIGINAL
messageBytes, const recipientPubKey = tryDecodeBase64(cleanRecipientKey);
nonce, const senderPrivKey = tryDecodeBase64(cleanSenderKey);
recipientPubKey,
senderPrivKey
);
return { // Generate random nonce
ciphertext: sodium.to_base64(ciphertext), const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
nonce: sodium.to_base64(nonce),
}; // 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> { ): Promise<string> {
await initSodium(); await initSodium();
const ciphertextBytes = sodium.from_base64(ciphertext); const ciphertextBytes = tryDecodeBase64(ciphertext);
const nonceBytes = sodium.from_base64(nonce); const nonceBytes = tryDecodeBase64(nonce);
const senderPubKey = sodium.from_base64(senderPublicKey); const senderPubKey = tryDecodeBase64(senderPublicKey);
const recipientPrivKey = sodium.from_base64(recipientPrivateKey); const recipientPrivKey = tryDecodeBase64(recipientPrivateKey);
// Decrypt // Decrypt
const decrypted = sodium.crypto_box_open_easy( const decrypted = sodium.crypto_box_open_easy(
@@ -196,7 +245,35 @@ export async function getStoredKeys(
// Decrypt with storage key // Decrypt with storage key
const decrypted = sodium.crypto_secretbox_open_easy(ciphertext, nonce, storageKey); 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'); console.log('[Sodium] Successfully decrypted stored keys');
return { publicKey, privateKey }; return { publicKey, privateKey };
+2 -21
View File
@@ -11,11 +11,7 @@ export type HandleEntry = {
export const normalizeHandle = (handle: string) => export const normalizeHandle = (handle: string) =>
handle.toLowerCase().replace(/^@/, '').trim(); handle.toLowerCase().replace(/^@/, '').trim();
// [Modified] Added sourceDomain parameter export async function upsertHandleEntries(entries: HandleEntry[]) {
export async function upsertHandleEntries(
entries: HandleEntry[],
sourceDomain?: string
) {
if (!db) { if (!db) {
return { added: 0, updated: 0 }; return { added: 0, updated: 0 };
} }
@@ -49,22 +45,7 @@ export async function upsertHandleEntries(
continue; continue;
} }
// PROPAGATION FIX: if (!existing.updatedAt || incomingUpdatedAt > existing.updatedAt) {
// 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) {
await db.update(handleRegistry) await db.update(handleRegistry)
.set({ .set({
did: entry.did, did: entry.did,
+2 -4
View File
@@ -91,8 +91,7 @@ export async function processGossip(
// Process incoming handles // Process incoming handles
let handlesResult = { added: 0, updated: 0 }; let handlesResult = { added: 0, updated: 0 };
if (payload.handles && payload.handles.length > 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);
handlesResult = await upsertHandleEntries(payload.handles, payload.sender);
} }
// Build our response with nodes/handles to share back // Build our response with nodes/handles to share back
@@ -164,8 +163,7 @@ export async function gossipToNode(
let handlesResult = { added: 0, updated: 0 }; let handlesResult = { added: 0, updated: 0 };
if (gossipResponse.handles && gossipResponse.handles.length > 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);
handlesResult = await upsertHandleEntries(gossipResponse.handles, targetDomain);
} }
await markNodeSuccess(targetDomain); await markNodeSuccess(targetDomain);
-65
View File
@@ -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);
}