feat: user-owned S3-compatible storage with credential verification
This commit is contained in:
+62
-9
@@ -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([{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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']
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user