diff --git a/src/common/utils/crypto.spec.ts b/src/common/utils/crypto.spec.ts new file mode 100644 index 0000000..952bd8a --- /dev/null +++ b/src/common/utils/crypto.spec.ts @@ -0,0 +1,27 @@ +import { encryptBuffer, decryptBuffer } from './crypto'; + +describe('Document Cryptographic Round-Trip Integrity Matrix', () => { + beforeAll(() => { + // Set mock local environment key variables for isolation context + process.env.TRADEFLOW_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + }); + + it('should successfully encrypt and decrypt raw buffers natively in system memory without layout data leakage', () => { + const rawSecretMessage = 'TradeFlow Confidential Real World Asset (RWA) KYC Data Payload.'; + const bufferData = Buffer.from(rawSecretMessage, 'utf-8'); + + // 1. Run server-side memory encryption + const encrypted = encryptBuffer(bufferData); + + expect(encrypted.iv).toBeDefined(); + expect(encrypted.authTag).toBeDefined(); + expect(encrypted.ciphertext.toString('utf-8')).not.toBe(rawSecretMessage); // Data is safely obfuscated + + // 2. Run matching decryption + const decryptedBuffer = decryptBuffer(encrypted.ciphertext, encrypted.iv, encrypted.authTag); + const decryptedMessage = decryptedBuffer.toString('utf-8'); + + // 3. Assert full data fidelity recovery + expect(decryptedMessage).toBe(rawSecretMessage); + }); +}); \ No newline at end of file diff --git a/src/common/utils/crypto.ts b/src/common/utils/crypto.ts new file mode 100644 index 0000000..3c15d95 --- /dev/null +++ b/src/common/utils/crypto.ts @@ -0,0 +1,50 @@ +import * as nodeCrypto from 'crypto'; // Changed to namespace import to bypass Jest resolver stubs + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; + +// Strict environment key validation guardrail, checked once at module load. +const MASTER_KEY_HEX = process.env.TRADEFLOW_ENCRYPTION_KEY; +if (!MASTER_KEY_HEX || Buffer.from(MASTER_KEY_HEX, 'hex').length !== 32) { + throw new Error('CRITICAL: TRADEFLOW_ENCRYPTION_KEY must be a valid 32-byte hex string.'); +} +const MASTER_KEY = Buffer.from(MASTER_KEY_HEX, 'hex'); + +export interface EncryptedArtifact { + ciphertext: Buffer; + iv: string; + authTag: string; +} + +/** + * Encrypts a Buffer using AES-256-GCM. + * @param buffer The data to encrypt. + * @returns An object containing the ciphertext, IV, and authentication tag. + */ +export function encryptBuffer(buffer: Buffer): EncryptedArtifact { + const iv = nodeCrypto.randomBytes(IV_LENGTH); + const cipher = nodeCrypto.createCipheriv(ALGORITHM, MASTER_KEY, iv); + const ciphertext = Buffer.concat([cipher.update(buffer), cipher.final()]); + const authTag = cipher.getAuthTag(); + return { + ciphertext, + iv: iv.toString('hex'), + authTag: authTag.toString('hex'), + }; +} +/** + * Decrypts a Buffer using AES-256-GCM. + * @param ciphertext The encrypted data. + * @param ivHex The Initialization Vector in hex format. + * @param authTagHex The authentication tag in hex format. + * @returns The decrypted data as a Buffer. + */ +export function decryptBuffer(ciphertext: Buffer, ivHex: string, authTagHex: string): Buffer { + const decipher = nodeCrypto.createDecipheriv( + ALGORITHM, + MASTER_KEY, + Buffer.from(ivHex, 'hex'), + ); + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} \ No newline at end of file diff --git a/src/controllers/documents.controller.ts b/src/controllers/documents.controller.ts new file mode 100644 index 0000000..ed9fb15 --- /dev/null +++ b/src/controllers/documents.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Post, Get, Param, UseInterceptors, UploadedFile, Res, BadRequestException, NotFoundException } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import { encryptBuffer, decryptBuffer } from '../common/utils/crypto'; +import axios from 'axios'; + +@Controller('api/v1/documents') +export class DocumentsController { + // Replace this with your actual Prisma service injection if available + private prisma = { + documentRegistry: { + create: async (args: any) => true, + findUnique: async (args: any) => ({ + iv: 'mock-iv', + authTag: 'mock-tag', + mimeType: 'application/pdf', + name: 'invoice.pdf' + }) + } + }; + + @Post('upload') + @UseInterceptors(FileInterceptor('document', { + limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit + fileFilter: (req, file, callback) => { + if (!['application/pdf', 'image/jpeg', 'image/png'].includes(file.mimetype)) { + return callback(new BadRequestException('Invalid format. Allowed: PDF, JPG, PNG.'), false); + } + callback(null, true); + }, + })) + async uploadDocument(@UploadedFile() file: Express.Multer.File) { + if (!file) throw new BadRequestException('No file uploaded.'); + + // Encrypt raw buffer directly in system memory + const { ciphertext, iv, authTag } = encryptBuffer(file.buffer); + + // Pin encrypted binary payload to Pinata API + const formData = new FormData(); + const blob = new Blob([ciphertext], { type: 'application/octet-stream' }); + formData.append('file', blob, file.originalname); + + const ipfsRes = await axios.post('https://api.pinata.cloud/pinning/pinFileToIPFS', formData, { + headers: { Authorization: `Bearer ${process.env.PINATA_JWT}` }, + }); + + const cid = ipfsRes.data.IpfsHash; + + // Persist layout pointers to DB + await this.prisma.documentRegistry.create({ + data: { cid, iv, authTag, mimeType: file.mimetype, name: file.originalname }, + }); + + return { success: true, cid }; + } + + @Get(':cid') + async getDocument(@Param('cid') cid: string, @Res() res: Response) { + const record = await this.prisma.documentRegistry.findUnique({ where: { cid } }); + if (!record) throw new NotFoundException('Document mapping not found.'); + + const gatewayRes = await axios.get(`https://gateway.pinata.cloud/ipfs/${cid}`, { + responseType: 'arraybuffer', + }); + + const decrypted = decryptBuffer(Buffer.from(gatewayRes.data), record.iv, record.authTag); + + res.setHeader('Content-Type', record.mimeType); + res.setHeader('Content-Disposition', `inline; filename="${record.name}"`); + return res.send(decrypted); + } +} \ No newline at end of file