/** * Mock encrypted long-term object store + share grants. * @module store/blobs */ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; import { getStore, persist } from './db.js'; const ALGO = 'aes-256-gcm'; function ensure() { const store = getStore(); store.blobs ??= {}; store.shares ??= {}; store.trees ??= {}; return store; } /** * @param {Buffer} plaintext * @param {Buffer} key 32 bytes */ function encrypt(plaintext, key) { const iv = randomBytes(12); const cipher = createCipheriv(ALGO, key, iv); const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); const tag = cipher.getAuthTag(); return { iv: iv.toString('base64'), tag: tag.toString('base64'), ciphertext: ciphertext.toString('base64') }; } /** * @param {{ iv: string, tag: string, ciphertext: string }} enc * @param {Buffer} key */ export function decryptBlob(enc, key) { const decipher = createDecipheriv(ALGO, key, Buffer.from(enc.iv, 'base64')); decipher.setAuthTag(Buffer.from(enc.tag, 'base64')); return Buffer.concat([ decipher.update(Buffer.from(enc.ciphertext, 'base64')), decipher.final(), ]); } /** * @param {object} params * @param {string} params.tenantId * @param {string} [params.sha256] * @param {string} [params.filename] * @param {Buffer} params.bytes * @param {object} [params.publicMetadata] * @param {object} [params.privateMetadata] */ export function putBlob({ tenantId, sha256, filename, bytes, publicMetadata = {}, privateMetadata = {} }) { const store = ensure(); const id = randomUUID(); const key = randomBytes(32); const enc = encrypt(bytes, key); store.blobs[id] = { id, tenantId, sha256: sha256 ?? null, filename: filename ?? 'object.bin', size: bytes.length, enc, dek: key.toString('base64'), publicMetadata, privateMetadata, createdAt: new Date().toISOString(), }; persist(); return { id, sha256: store.blobs[id].sha256, size: bytes.length, filename: store.blobs[id].filename }; } export function getBlobRecord(id) { return ensure().blobs[id] ?? null; } /** * Decrypt for owner tenant only. * @param {string} id * @param {string} tenantId */ export function readBlobPlaintext(id, tenantId) { const rec = getBlobRecord(id); if (!rec) return null; if (rec.tenantId !== tenantId) { const err = new Error('Forbidden'); err.code = 'FORBIDDEN'; throw err; } return { ...rec, bytes: decryptBlob(rec.enc, Buffer.from(rec.dek, 'base64')), }; } /** * @param {{ blobId: string, fromTenantId: string, label?: string }} params */ export function createShare({ blobId, fromTenantId, label }) { const rec = getBlobRecord(blobId); if (!rec || rec.tenantId !== fromTenantId) return null; const token = `shr_${randomBytes(16).toString('hex')}`; const store = ensure(); store.shares[token] = { token, kind: 'file', blobId, fromTenantId, label: label ?? rec.filename, createdAt: new Date().toISOString(), }; persist(); return { token, blobId, label: store.shares[token].label }; } /** * Redeem a share token: ciphertext + wrapped key for the recipient. * @param {string} token */ export function redeemShare(token) { const store = ensure(); const share = store.shares[token]; if (!share) return null; if (share.kind === 'tree') { const tree = store.trees[share.treeId]; if (!tree) return null; return { token, kind: 'tree', prefix: tree.prefix, files: tree.entries.map((e) => { const rec = store.blobs[e.blobId]; return rec ? { path: e.path, blobId: rec.id, filename: rec.filename, size: rec.size, sha256: rec.sha256, enc: rec.enc, dek: rec.dek, } : { path: e.path, missing: true }; }), note: 'Mock tree share: each file includes dek. Production must wrap per-file deks to recipient pubkey.', }; } const rec = store.blobs[share.blobId]; if (!rec) return null; return { token, kind: 'file', blobId: rec.id, filename: rec.filename, size: rec.size, sha256: rec.sha256, publicMetadata: rec.publicMetadata ?? {}, enc: rec.enc, dek: rec.dek, note: 'Mock share: dek is included so the recipient can decrypt. Production must wrap dek to recipient pubkey.', }; } /** * @param {{ tenantId: string, prefix: string, files: Array<{ path: string, bytes: Buffer, sha256?: string }> }} params */ export function putTree({ tenantId, prefix, files }) { const store = ensure(); const id = randomUUID(); const entries = []; for (const file of files) { const stored = putBlob({ tenantId, sha256: file.sha256, filename: file.path.split('/').pop(), bytes: file.bytes, }); entries.push({ path: file.path, blobId: stored.id }); } store.trees[id] = { id, tenantId, prefix: prefix || '/', entries, createdAt: new Date().toISOString() }; persist(); return { id, prefix: store.trees[id].prefix, fileCount: entries.length }; } export function getTree(id) { return ensure().trees[id] ?? null; } /** * @param {{ treeId: string, fromTenantId: string, label?: string }} params */ export function createTreeShare({ treeId, fromTenantId, label }) { const tree = getTree(treeId); if (!tree || tree.tenantId !== fromTenantId) return null; const token = `shr_${randomBytes(16).toString('hex')}`; const store = ensure(); store.shares[token] = { token, kind: 'tree', treeId, fromTenantId, label: label ?? tree.prefix, createdAt: new Date().toISOString(), }; persist(); return { token, kind: 'tree', treeId, label: store.shares[token].label, fileCount: tree.entries.length }; }