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 { 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,
+131 -54
View File
@@ -26,10 +26,10 @@ export async function initSodium() {
function openDB(): Promise<IDBDatabase> {
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<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(
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<void> {
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<void>((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<any>((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<void> {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
await new Promise<void>((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);
+2 -21
View File
@@ -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,
+2 -4
View File
@@ -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);
-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);
}