feat: user-owned S3-compatible storage with credential verification

This commit is contained in:
Christomatt
2026-02-01 02:51:52 +01:00
parent a8252a1809
commit f55dec9a60
38 changed files with 1845 additions and 902 deletions
+62 -9
View File
@@ -8,9 +8,10 @@ import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import { generateKeyPair } from '@/lib/crypto/keys';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { base58btc } from 'multiformats/bases/base58';
import { cookies } from 'next/headers';
import { upsertHandleEntries } from '@/lib/federation/handles';
import { generateAndUploadAvatar } from '@/lib/auth/avatar';
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
const SESSION_COOKIE_NAME = 'synapsis_session';
const SESSION_EXPIRY_DAYS = 30;
@@ -31,10 +32,22 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
/**
* Generate a DID for a new user
* Uses did:key format (W3C standard) - the DID contains the public key itself
*/
export function generateDID(): string {
// Using a simple did:key-like format for now
// In production, this would be more sophisticated
export function generateDID(publicKey: string): string {
// Encode the SPKI public key in base58btc (multibase)
const publicKeyBytes = Buffer.from(publicKey, 'base64');
const encoded = base58btc.encode(new Uint8Array(publicKeyBytes));
// Create did:key - the 'z' prefix indicates base58btc encoding
return `did:key:${encoded}`;
}
/**
* Generate legacy DID format (for backward compatibility)
* @deprecated Use generateDID() instead
*/
export function generateLegacyDID(): string {
return `did:synapsis:${uuid().replace(/-/g, '')}`;
}
@@ -123,7 +136,13 @@ export async function registerUser(
handle: string,
email: string,
password: string,
displayName?: string
displayName?: string,
storageProvider?: string,
storageEndpoint?: string | null,
storageRegion?: string,
storageBucket?: string,
storageAccessKey?: string,
storageSecretKey?: string
): Promise<typeof users.$inferSelect> {
// Validate handle format
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handle)) {
@@ -148,21 +167,49 @@ export async function registerUser(
throw new Error('Email is already registered');
}
// Validate S3 storage credentials (required for new users)
if (!storageProvider) {
throw new Error('Storage provider is required.');
}
if (!storageRegion || storageRegion.length < 2) {
throw new Error('Storage region is required (e.g., us-east-1, auto).');
}
if (!storageBucket || storageBucket.length < 3) {
throw new Error('Storage bucket name is required.');
}
if (!storageAccessKey || storageAccessKey.length < 10) {
throw new Error('Storage access key is required.');
}
if (!storageSecretKey || storageSecretKey.length < 10) {
throw new Error('Storage secret key is required.');
}
// Generate cryptographic keys
const { publicKey, privateKey } = await generateKeyPair();
// Encrypt the private key with user's password before storing
const encryptedPrivateKey = encryptPrivateKey(privateKey, password);
// Create the user
const did = generateDID();
// Create the user with did:key format (public key encoded in DID)
const did = generateDID(publicKey);
const passwordHash = await hashPassword(password);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Generate avatar using full handle (@user@domain) for global uniqueness
// Generate avatar and upload to user's S3 storage
const fullHandle = `${handle.toLowerCase()}@${nodeDomain}`;
const avatarUrl = await generateAndUploadAvatar(fullHandle);
const avatarUrl = await generateAndUploadAvatarToUserStorage(
fullHandle,
storageEndpoint || null,
storageRegion,
storageBucket,
storageAccessKey,
storageSecretKey
);
// Encrypt the storage credentials with user's password
const encryptedAccessKey = encryptPrivateKey(storageAccessKey, password);
const encryptedSecretKey = encryptPrivateKey(storageSecretKey, password);
const [user] = await db.insert(users).values({
did,
@@ -173,6 +220,12 @@ export async function registerUser(
avatarUrl,
publicKey,
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
storageProvider,
storageEndpoint: storageEndpoint || null,
storageRegion,
storageBucket,
storageAccessKeyEncrypted: serializeEncryptedKey(encryptedAccessKey),
storageSecretKeyEncrypted: serializeEncryptedKey(encryptedSecretKey),
}).returning();
await upsertHandleEntries([{
+12 -5
View File
@@ -14,6 +14,7 @@ import { eq } from 'drizzle-orm';
import { canonicalize, importPublicKey, base64UrlToBase64 } from '@/lib/crypto/user-signing';
// Note: user-signing helpers are isomorphic (work in Node via webcrypto polyfill/availability)
import crypto from 'crypto';
import { isRateLimited } from '@/lib/rate-limit';
// Use Node's webcrypto for server-side if not global
const cryptoSubtle = globalThis.crypto?.subtle || require('crypto').webcrypto.subtle;
@@ -77,7 +78,13 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
const { sig, ...payload } = signedAction;
// 1. FRESHNESS CHECK (Fail fast before DB/Crypto)
// 1. RATE LIMIT CHECK (Fail fast before heavy operations)
// 5 requests per minute per DID
if (isRateLimited(payload.did, 5, 60 * 1000)) {
return { valid: false, error: 'RATE_LIMITED' };
}
// 2. FRESHNESS CHECK (Fail fast before DB/Crypto)
const now = Date.now();
const diff = Math.abs(now - payload.ts);
// Allow 5 minutes clock skew
@@ -87,7 +94,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
return { valid: false, error: 'INVALID_TIMESTAMP: Request too old or in future' };
}
// 2. FETCH USER & KEY
// 3. FETCH USER & KEY
const user = await db.query.users.findFirst({
where: eq(users.did, payload.did),
});
@@ -100,18 +107,18 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
return { valid: false, error: 'Handle mismatch' };
}
// 3. CRYPTOGRAPHIC VERIFICATION
// 4. CRYPTOGRAPHIC VERIFICATION
const isValid = await verifyActionSignature(signedAction, user.publicKey);
if (!isValid) {
return { valid: false, error: 'INVALID_SIGNATURE' };
}
// 4. ACTION ID HASH COMPUTATION
// 5. ACTION ID HASH COMPUTATION
const canonicalString = canonicalize(payload);
const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
// 5. REPLAY PROTECTION (DB)
// 6. REPLAY PROTECTION (DB)
try {
await db.insert(signedActionDedupe).values({
actionId: actionIdHash,
+26 -1
View File
@@ -11,6 +11,8 @@
*/
import { db, bots, users, botContentSources, botContentItems, botMentions, botActivityLogs, botRateLimits, follows } from '@/db';
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
import { eq, and, count } from 'drizzle-orm';
import { generateKeyPair } from '@/lib/crypto/keys';
import {
@@ -333,6 +335,15 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
throw new BotHandleTakenError(config.handle);
}
// Get owner to access their S3 storage for bot avatar
const owner = await db.query.users.findFirst({
where: eq(users.id, ownerId),
});
if (!owner) {
throw new BotValidationError('Owner user not found');
}
// Generate cryptographic keys for the bot's user account
const { publicKey, privateKey } = await generateKeyPair();
@@ -344,13 +355,27 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const botDid = `did:web:${nodeDomain}:users:${config.handle.toLowerCase()}`;
// Generate bot avatar using owner's S3 storage if no avatar provided
let botAvatarUrl = config.avatarUrl || null;
if (!botAvatarUrl && owner.storageAccessKeyEncrypted && owner.storageSecretKeyEncrypted && owner.storageBucket) {
try {
// We need the owner's password to decrypt S3 credentials
// Since we don't have the password here, we'll leave avatar null
// The frontend should handle avatar generation with the user's password
// Or we can generate a default avatar URL pattern
console.log('[BotManager] Bot avatar will need to be set via API with user password');
} catch (err) {
console.error('[BotManager] Failed to generate bot avatar:', err);
}
}
// Create the bot's user account first
const [botUser] = await db.insert(users).values({
did: botDid,
handle: config.handle.toLowerCase(),
displayName: config.name,
bio: config.bio || null,
avatarUrl: config.avatarUrl || null,
avatarUrl: botAvatarUrl,
headerUrl: config.headerUrl || null,
publicKey,
privateKeyEncrypted: serializeEncryptedData(encryptedPrivateKey),
+3 -3
View File
@@ -120,7 +120,7 @@ export async function deriveSessionKey(password: string): Promise<CryptoKey> {
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true, // extractable so we can store it
['wrapKey', 'unwrapKey']
['encrypt', 'decrypt']
);
}
@@ -131,7 +131,7 @@ async function generateSessionKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['wrapKey', 'unwrapKey']
['encrypt', 'decrypt']
);
}
@@ -147,7 +147,7 @@ async function importSessionKey(keyData: string): Promise<CryptoKey> {
buffer,
{ name: 'AES-GCM', length: 256 },
false, // not extractable after import
['wrapKey', 'unwrapKey']
['encrypt', 'decrypt']
);
}
+138
View File
@@ -0,0 +1,138 @@
/**
* In-memory rate limiting for signed actions
*
* Features:
* - Sliding window approach (5 requests per minute per DID by default)
* - Automatic cleanup to prevent memory leaks
* - No external dependencies (Redis-free)
*/
// Map to store timestamps per DID: did -> array of request timestamps
const rateLimits = new Map<string, number[]>();
// Configuration for cleanup
const CLEANUP_INTERVAL_MS = 60 * 1000; // Run cleanup every minute
const MAX_IDLE_DID_MS = 10 * 60 * 1000; // Remove DIDs with no recent activity after 10 minutes
/**
* Check if a DID is rate limited
* @param did - The DID to check
* @param maxRequests - Maximum requests allowed in the window (default: 5)
* @param windowMs - Time window in milliseconds (default: 60000 = 1 minute)
* @returns true if rate limited, false otherwise
*/
export function isRateLimited(did: string, maxRequests = 5, windowMs = 60000): boolean {
const now = Date.now();
const timestamps = rateLimits.get(did) || [];
// Filter to only keep timestamps within the window (sliding window)
const recent = timestamps.filter(ts => now - ts < windowMs);
// Check if limit exceeded
if (recent.length >= maxRequests) {
return true;
}
// Add current timestamp and update the map
recent.push(now);
rateLimits.set(did, recent);
return false;
}
/**
* Get current rate limit status for a DID
* Useful for logging/debugging
*/
export function getRateLimitStatus(did: string, maxRequests = 5, windowMs = 60000): {
limited: boolean;
remaining: number;
resetInMs: number;
current: number;
} {
const now = Date.now();
const timestamps = rateLimits.get(did) || [];
const recent = timestamps.filter(ts => now - ts < windowMs);
const limited = recent.length >= maxRequests;
const remaining = Math.max(0, maxRequests - recent.length);
// Calculate when the oldest request will expire
const oldestInWindow = recent.length > 0 ? Math.min(...recent) : now;
const resetInMs = limited ? (oldestInWindow + windowMs - now) : 0;
return {
limited,
remaining,
resetInMs,
current: recent.length
};
}
/**
* Cleanup old entries to prevent memory leaks
* Removes:
* 1. Timestamps outside the rate limit window for each DID
* 2. DIDs with no recent activity (idle for MAX_IDLE_DID_MS)
*/
export function cleanupRateLimits(windowMs = 60000): {
didsRemoved: number;
timestampsRemoved: number;
} {
const now = Date.now();
let didsRemoved = 0;
let timestampsRemoved = 0;
for (const [did, timestamps] of rateLimits.entries()) {
// Filter to keep only recent timestamps
const recent = timestamps.filter(ts => {
const isRecent = now - ts < windowMs;
if (!isRecent) timestampsRemoved++;
return isRecent;
});
if (recent.length === 0) {
// Remove DID entirely if no recent activity
// Also check if DID has been idle for too long
const lastActivity = timestamps.length > 0 ? Math.max(...timestamps) : 0;
if (now - lastActivity > MAX_IDLE_DID_MS) {
rateLimits.delete(did);
didsRemoved++;
} else {
rateLimits.set(did, recent);
}
} else {
rateLimits.set(did, recent);
}
}
return { didsRemoved, timestampsRemoved };
}
/**
* Get current size of rate limit store (for monitoring)
*/
export function getRateLimitStoreSize(): {
didCount: number;
totalTimestamps: number;
} {
let totalTimestamps = 0;
for (const timestamps of rateLimits.values()) {
totalTimestamps += timestamps.length;
}
return {
didCount: rateLimits.size,
totalTimestamps
};
}
// Start periodic cleanup to prevent memory leaks
setInterval(() => {
const result = cleanupRateLimits();
if (result.didsRemoved > 0 || result.timestampsRemoved > 0) {
console.log(`[RateLimit] Cleanup: removed ${result.didsRemoved} DIDs, ${result.timestampsRemoved} old timestamps`);
}
}, CLEANUP_INTERVAL_MS);
console.log('[RateLimit] Initialized with sliding window rate limiting');
+134
View File
@@ -0,0 +1,134 @@
/**
* User-Owned Storage (IPFS/Pinata) Utilities
*
* Handles uploads to Pinata using user's own API keys
*/
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
export type StorageProvider = 'pinata';
interface StorageUploadResult {
cid: string;
url: string;
gatewayUrl: string;
}
/**
* Upload a file to user's Pinata account
*/
export async function uploadToPinata(
file: Buffer,
apiKey: string,
filename: string
): Promise<StorageUploadResult> {
const formData = new FormData();
// Create Blob from Buffer - using ArrayBuffer view
const blob = new Blob([file as unknown as BlobPart]);
formData.append('file', blob, filename);
const response = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Pinata upload failed: ${error}`);
}
const data = await response.json();
const cid = data.IpfsHash;
if (!cid) {
throw new Error('No CID returned from Pinata');
}
return {
cid,
url: `ipfs://${cid}`,
gatewayUrl: `https://gateway.pinata.cloud/ipfs/${cid}`,
};
}
/**
* Upload file using user's configured storage provider
*/
export async function uploadToUserStorage(
file: Buffer,
filename: string,
provider: StorageProvider,
encryptedApiKey: string,
password: string
): Promise<StorageUploadResult> {
// Decrypt the storage API key
const decryptedKey = decryptPrivateKey(
deserializeEncryptedKey(encryptedApiKey),
password
);
switch (provider) {
case 'pinata':
return uploadToPinata(file, decryptedKey, filename);
default:
throw new Error(`Unknown storage provider: ${provider}`);
}
}
/**
* Generate and upload avatar to user's Pinata storage
*/
export async function generateAndUploadAvatarToUserStorage(
handle: string,
apiKey: string
): Promise<string | null> {
try {
// 1. Fetch the avatar from DiceBear
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${handle}`;
const response = await fetch(dicebearUrl);
if (!response.ok) {
console.error(`Failed to fetch avatar from DiceBear: ${response.statusText}`);
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 2. Upload to user's Pinata
const result = await uploadToPinata(buffer, apiKey, `${handle}-avatar.svg`);
return result.url; // ipfs://cid format
} catch (error) {
console.error('Error generating/uploading avatar:', error);
return null;
}
}
/**
* Get public gateway URL for an IPFS CID
*/
export function getIPFSGatewayUrl(cid: string, preferredGateway?: string): string {
// Use preferred gateway or fallback to public ones
const gateway = preferredGateway || 'https://ipfs.io/ipfs';
return `${gateway}/${cid}`;
}
/**
* Extract CID from ipfs:// URL or return the CID
*/
export function extractCID(urlOrCid: string): string {
if (urlOrCid.startsWith('ipfs://')) {
return urlOrCid.replace('ipfs://', '');
}
// Handle gateway URLs
const match = urlOrCid.match(/\/ipfs\/(Qm[a-zA-Z0-9]+|bafy[a-zA-Z0-9]+)/);
if (match) {
return match[1];
}
return urlOrCid;
}
+214
View File
@@ -0,0 +1,214 @@
/**
* User-Owned S3-Compatible Storage Utilities
*
* Supports AWS S3, Cloudflare R2, Backblaze B2, Wasabi, MinIO, etc.
*/
import { S3Client, PutObjectCommand, HeadBucketCommand } from '@aws-sdk/client-s3';
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
export type StorageProvider = 's3' | 'r2' | 'b2' | 'wasabi' | 'minio' | 'other';
interface S3Credentials {
endpoint?: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
bucket: string;
}
interface StorageUploadResult {
url: string;
key: string;
}
/**
* Decrypt S3 credentials from encrypted storage
*/
function decryptS3Credentials(
encryptedAccessKey: string,
encryptedSecretKey: string,
password: string
): { accessKeyId: string; secretAccessKey: string } {
const accessKeyId = decryptPrivateKey(
deserializeEncryptedKey(encryptedAccessKey),
password
);
const secretAccessKey = decryptPrivateKey(
deserializeEncryptedKey(encryptedSecretKey),
password
);
return { accessKeyId, secretAccessKey };
}
/**
* Create S3 client from credentials
*/
function createS3Client(creds: S3Credentials): S3Client {
return new S3Client({
region: creds.region,
endpoint: creds.endpoint,
credentials: {
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
},
forcePathStyle: !!creds.endpoint, // Needed for non-AWS S3-compatible services
});
}
/**
* Upload a file to user's S3-compatible storage
*/
export async function uploadToUserStorage(
file: Buffer,
filename: string,
mimeType: string,
provider: StorageProvider,
endpoint: string | null,
region: string,
bucket: string,
encryptedAccessKey: string,
encryptedSecretKey: string,
password: string
): Promise<StorageUploadResult> {
// Decrypt credentials
const { accessKeyId, secretAccessKey } = decryptS3Credentials(
encryptedAccessKey,
encryptedSecretKey,
password
);
// Create S3 client
const s3 = createS3Client({
endpoint: endpoint || undefined,
region,
accessKeyId,
secretAccessKey,
bucket,
});
// Upload file
const key = `synapsis/${filename}`;
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: file,
ContentType: mimeType,
}));
// Construct URL based on provider
let url: string;
if (endpoint) {
// Custom endpoint (R2, MinIO, etc)
url = `${endpoint}/${bucket}/${key}`;
} else {
// AWS S3 standard URL
url = `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
}
return { url, key };
}
/**
* Test S3 credentials by attempting to head the bucket
*/
export async function testS3Credentials(
endpoint: string | null,
region: string,
bucket: string,
accessKeyId: string,
secretAccessKey: string
): Promise<{ success: boolean; error?: string }> {
try {
const s3 = createS3Client({
endpoint: endpoint || undefined,
region,
accessKeyId,
secretAccessKey,
bucket,
});
// Try to check if bucket exists/is accessible
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
return { success: true };
} catch (error: any) {
console.error('[S3 Test] Credential test failed:', error);
// Parse common errors
if (error.name === 'Forbidden' || error.name === '403') {
return { success: false, error: 'Access denied. Check your Access Key and Secret Key.' };
}
if (error.name === 'NotFound' || error.name === '404') {
return { success: false, error: `Bucket "${bucket}" not found. Check the bucket name.` };
}
if (error.name === 'NoSuchBucket') {
return { success: false, error: `Bucket "${bucket}" does not exist.` };
}
if (error.name === 'InvalidAccessKeyId') {
return { success: false, error: 'Invalid Access Key ID.' };
}
if (error.name === 'SignatureDoesNotMatch') {
return { success: false, error: 'Invalid Secret Access Key.' };
}
if (error.name === 'NetworkingError' || error.name === 'ECONNREFUSED') {
return { success: false, error: 'Cannot connect to endpoint. Check your endpoint URL.' };
}
return { success: false, error: error.message || 'Failed to connect to storage. Please check your credentials.' };
}
}
/**
* Generate and upload avatar to user's S3 storage
*/
export async function generateAndUploadAvatarToUserStorage(
handle: string,
endpoint: string | null,
region: string,
bucket: string,
accessKey: string,
secretKey: string
): Promise<string | null> {
try {
// 1. Fetch the avatar from DiceBear
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${handle}`;
const response = await fetch(dicebearUrl);
if (!response.ok) {
console.error(`Failed to fetch avatar from DiceBear: ${response.statusText}`);
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 2. Upload to user's S3
const s3 = createS3Client({
endpoint: endpoint || undefined,
region,
accessKeyId: accessKey,
secretAccessKey: secretKey,
bucket,
});
const key = `synapsis/avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.svg`;
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: 'image/svg+xml',
}));
// 3. Return URL
if (endpoint) {
return `${endpoint}/${bucket}/${key}`;
}
return `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
} catch (error) {
console.error('Error generating/uploading avatar:', error);
return null;
}
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Identity Cache for TOFU (Trust on First Use) protection
*
* Caches remote user identities to detect key changes.
* First fetch is trusted (TOFU), subsequent fetches validate against cache.
*/
import { db, remoteIdentityCache } from '@/db';
import { eq } from 'drizzle-orm';
interface IdentityCacheEntry {
did: string;
publicKey: string;
fetchedAt: Date;
expiresAt: Date;
}
/**
* Get cached identity for a DID
*/
export async function getCachedIdentity(did: string): Promise<IdentityCacheEntry | null> {
const cached = await db.query.remoteIdentityCache.findFirst({
where: eq(remoteIdentityCache.did, did),
});
return cached || null;
}
/**
* Cache a remote user's identity
*/
export async function cacheIdentity(
did: string,
_handle: string,
_nodeDomain: string,
publicKey: string,
_displayName: string | null = null,
_avatarUrl: string | null = null
): Promise<void> {
const now = new Date();
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
await db.insert(remoteIdentityCache)
.values({
did,
publicKey,
fetchedAt: now,
expiresAt,
})
.onConflictDoUpdate({
target: remoteIdentityCache.did,
set: {
publicKey,
fetchedAt: now,
expiresAt,
},
});
}
/**
* Validate key continuity - check if public key matches cached value
* Returns: { valid: boolean, isFirstUse: boolean, keyChanged?: boolean }
*/
export async function validateKeyContinuity(
did: string,
publicKey: string
): Promise<{ valid: boolean; isFirstUse: boolean; keyChanged?: boolean; oldKey?: string }> {
const cached = await getCachedIdentity(did);
if (!cached) {
// First time seeing this DID - TOFU moment
return { valid: true, isFirstUse: true };
}
if (cached.publicKey === publicKey) {
// Key matches cached value - all good
return { valid: true, isFirstUse: false, keyChanged: false };
}
// Key has changed from cached value
return {
valid: true, // Still accept but warn (configurable)
isFirstUse: false,
keyChanged: true,
oldKey: cached.publicKey
};
}
/**
* Log a security event for key changes
*/
export function logKeyChange(
did: string,
handle: string,
nodeDomain: string,
oldKey: string,
newKey: string
): void {
// Log a prominent security warning
console.error('╔══════════════════════════════════════════════════════════════════════════════╗');
console.error('║ 🚨 SECURITY WARNING: REMOTE PUBLIC KEY CHANGED 🚨 ║');
console.error('╠══════════════════════════════════════════════════════════════════════════════╣');
console.error(`║ DID: ${did.padEnd(74)}`);
console.error(`║ Handle: ${handle.padEnd(71)}`);
console.error(`║ Node: ${nodeDomain.padEnd(73)}`);
console.error('║ ║');
console.error('║ This could indicate: ║');
console.error('║ • MITM attack on your connection to the remote node ║');
console.error('║ • Compromised remote node serving fake keys ║');
console.error('║ • Legitimate key rotation by the user ║');
console.error('║ ║');
console.error('║ Verify out-of-band if possible. ║');
console.error('╚══════════════════════════════════════════════════════════════════════════════╝');
}
/**
* Securely fetch and cache a remote user's public key with TOFU validation
*/
export async function fetchAndCacheRemoteKey(
did: string,
handle: string,
nodeDomain: string,
fetchPublicKey: () => Promise<string | null>
): Promise<{ publicKey: string | null; fromCache: boolean; keyChanged: boolean }> {
// Check cache first
const cached = await getCachedIdentity(did);
if (cached) {
// We have a cached key - return it but also refresh in background
// This ensures we detect changes without blocking
fetchPublicKey().then(async (freshKey) => {
if (freshKey && freshKey !== cached.publicKey) {
// Key changed! Log warning
logKeyChange(did, handle, nodeDomain, cached.publicKey, freshKey);
// Update cache with new key (configurable: could reject instead)
await cacheIdentity(did, handle, nodeDomain, freshKey, null, null);
}
}).catch(err => {
// Background refresh failed - log but don't fail
console.error(`[IdentityCache] Background refresh failed for ${did}:`, err);
});
return {
publicKey: cached.publicKey,
fromCache: true,
keyChanged: false
};
}
// No cached key - fetch it
const publicKey = await fetchPublicKey();
if (!publicKey) {
return { publicKey: null, fromCache: false, keyChanged: false };
}
// Cache the key (TOFU moment)
await cacheIdentity(did, handle, nodeDomain, publicKey, null, null);
return {
publicKey,
fromCache: false,
keyChanged: false
};
}
/**
* Handle key change policy
* Returns true if the key change should be accepted
*/
export function shouldAcceptKeyChange(
did: string,
handle: string,
nodeDomain: string,
oldKey: string,
newKey: string
): boolean {
const policy = process.env.KEY_CHANGE_POLICY || 'warn';
switch (policy) {
case 'strict':
// Reject changed keys
console.error(`[IdentityCache] REJECTING key change for ${did} (strict mode)`);
return false;
case 'allow':
// Silently accept
return true;
case 'warn':
default:
// Accept but warn (already logged)
return true;
}
}