Add encrypted key support for chat and nodes
Introduces encrypted private key storage for nodes and users, updates chat message schema to support sender-side encryption, and adds supporting libraries and tests for cryptographic signing and identity unlock flows. Includes new database migrations, API route updates, and React components for identity unlock prompts.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Federation Key Registry
|
||||
*
|
||||
* Manages the caching and retrieval of remote public keys.
|
||||
* Enforces Key Continuity: Rejects key rotation by default to prevent MITM.
|
||||
*/
|
||||
|
||||
import { db } from '@/db';
|
||||
import { remoteIdentityCache } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// Strict continuity flag: if true (default), reject any key change.
|
||||
const ALLOW_KEY_ROTATION = process.env.ALLOW_KEY_ROTATION === 'true';
|
||||
|
||||
interface RemoteIdentity {
|
||||
did: string;
|
||||
handle: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a remote public key by DID.
|
||||
* Uses DB cache first, then fetches from .well-known endpoint.
|
||||
* Enforces key continuity.
|
||||
*/
|
||||
export async function lookupRemoteKey(did: string): Promise<string> {
|
||||
// 1. Check Cache
|
||||
const cached = await db.query.remoteIdentityCache.findFirst({
|
||||
where: eq(remoteIdentityCache.did, did),
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// If valid cache exists, return it
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.publicKey;
|
||||
}
|
||||
|
||||
// 2. Fetch from Remote
|
||||
// Resolve DID to HTTP URL (Assuming Web DID or simple mapping for now)
|
||||
// For did:web:example.com -> https://example.com/.well-known/synapsis/identity/{did}
|
||||
// This logic depends on our DID method.
|
||||
// IMPORTANT: For this strict implementation, we need a reliable way to map DID -> Endpoint.
|
||||
// We'll assume the DID *contains* the domain or we have a resolver.
|
||||
// Simplification: `did:web:domain` format.
|
||||
|
||||
const domain = extractDomainFromDid(did);
|
||||
if (!domain) {
|
||||
throw new Error(`Unsupported DID format: ${did}`);
|
||||
}
|
||||
|
||||
let remoteData: RemoteIdentity;
|
||||
try {
|
||||
const res = await fetch(`https://${domain}/.well-known/synapsis/identity/${did}`);
|
||||
if (!res.ok) throw new Error(`Remote identity fetch failed: ${res.status}`);
|
||||
remoteData = await res.json();
|
||||
} catch (err) {
|
||||
// If we have an expired cache entry, we *could* fallback to it in emergency,
|
||||
// but strict security says fail.
|
||||
console.error(`[KeyRegistry] Failed to fetch key for ${did}`, err);
|
||||
throw new Error('RELAY_UNREACHABLE');
|
||||
}
|
||||
|
||||
// Optimize: Validation
|
||||
if (remoteData.did !== did || !remoteData.publicKey) {
|
||||
throw new Error('Invalid remote identity response');
|
||||
}
|
||||
|
||||
// 3. Key Continuity Check
|
||||
if (cached) {
|
||||
if (cached.publicKey !== remoteData.publicKey) {
|
||||
if (!ALLOW_KEY_ROTATION) {
|
||||
console.error(`[KeyRegistry] KEY_CHANGED detected for ${did}. Old: ${cached.publicKey.slice(0, 10)}... New: ${remoteData.publicKey.slice(0, 10)}...`);
|
||||
throw new Error('KEY_CHANGED: Remote key rotation rejected by policy.');
|
||||
}
|
||||
// If allowed, we proceed (TOFU update)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update Cache
|
||||
const expiresAt = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour TTL
|
||||
|
||||
await db.insert(remoteIdentityCache).values({
|
||||
did,
|
||||
publicKey: remoteData.publicKey,
|
||||
fetchedAt: now,
|
||||
expiresAt,
|
||||
}).onConflictDoUpdate({
|
||||
target: remoteIdentityCache.did,
|
||||
set: {
|
||||
publicKey: remoteData.publicKey,
|
||||
fetchedAt: now,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return remoteData.publicKey;
|
||||
}
|
||||
|
||||
function extractDomainFromDid(did: string): string | null {
|
||||
// did:web:example.com
|
||||
// did:synapsis:example.com
|
||||
const parts = did.split(':');
|
||||
if (parts.length >= 3) {
|
||||
return parts[2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user