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
+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;
}
}