/** * Backblaze B2 client (S3-compatible) for the LogLift evidence pipeline. * * Implements AWS Signature Version 4 presigned URLs (matches the n8n * collector's expectations) for both downloads (Pulse fetching uploaded * payloads) and uploads (Pulse handing the collector a presigned PUT * target so the script doesn't carry credentials). * * Port of the SigV4 implementation from `docs/LogLift Review.json` — * battle-tested in production via the existing n8n flow. */ import { createHash, createHmac } from 'crypto'; export interface B2Config { keyId: string; secret: string; bucket: string; region: string; /** S3-compatible endpoint, e.g. `s3.us-west-002.backblazeb2.com` (no scheme). */ endpoint: string; } /** Hard cap on bytes Pulse will read from a B2 object. */ export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25 MB /** * Object-key shape we accept from inbound webhooks. Path-traversal guard * — must be `{client_id_or_uuid}/{computer_name}/eventlogs_{timestamp}.json.gz`. */ export const OBJECT_KEY_REGEX = /^[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\/eventlogs_[0-9_]+\.json\.gz$/; export class B2NotConfiguredError extends Error { constructor() { super( 'Backblaze B2 is not configured. Set B2_KEY_ID + B2_APP_KEY (and optionally B2_BUCKET / B2_REGION / B2_ENDPOINT).' ); this.name = 'B2NotConfiguredError'; } } export class B2InvalidObjectKeyError extends Error { constructor(objectKey: string) { super(`Invalid object key shape: ${objectKey.slice(0, 200)}`); this.name = 'B2InvalidObjectKeyError'; } } export function isB2Configured(): boolean { return !!(process.env.B2_KEY_ID && process.env.B2_APP_KEY); } export function getB2Config(): B2Config { const keyId = process.env.B2_KEY_ID; const secret = process.env.B2_APP_KEY; if (!keyId || !secret) throw new B2NotConfiguredError(); return { keyId, secret, bucket: process.env.B2_BUCKET || 'wulf-audits', region: process.env.B2_REGION || 'us-west-002', endpoint: process.env.B2_ENDPOINT || 's3.us-west-002.backblazeb2.com', }; } function sign(key: Buffer | string, msg: string): Buffer { return createHmac('sha256', key).update(msg, 'utf8').digest(); } function deriveSigningKey( secret: string, dateStamp: string, region: string, service: string ): Buffer { const kDate = sign('AWS4' + secret, dateStamp); const kRegion = sign(kDate, region); const kService = sign(kRegion, service); return sign(kService, 'aws4_request'); } interface PresignParams { method: 'GET' | 'PUT'; objectKey: string; expiresInSeconds: number; config: B2Config; } function presign(params: PresignParams): string { const { method, objectKey, expiresInSeconds, config } = params; const host = config.endpoint; // We do NOT URL-encode slashes in the path itself; SigV4 wants the // literal canonical URI with the object key as-is (slashes intact). const canonicalUri = '/' + config.bucket + '/' + objectKey; const algorithm = 'AWS4-HMAC-SHA256'; const now = new Date(); const amzDate = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; const dateStamp = amzDate.slice(0, 8); const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`; const canonicalHeaders = `host:${host}\n`; const signedHeaders = 'host'; const qs: Record = { 'X-Amz-Algorithm': algorithm, 'X-Amz-Credential': encodeURIComponent(`${config.keyId}/${credentialScope}`), 'X-Amz-Date': amzDate, 'X-Amz-Expires': String(expiresInSeconds), 'X-Amz-SignedHeaders': signedHeaders, }; const canonicalQueryString = Object.keys(qs) .sort() .map((k) => `${k}=${qs[k]}`) .join('&'); const payloadHash = 'UNSIGNED-PAYLOAD'; const canonicalRequest = [ method, canonicalUri, canonicalQueryString, canonicalHeaders, signedHeaders, payloadHash, ].join('\n'); const stringToSign = [ algorithm, amzDate, credentialScope, createHash('sha256').update(canonicalRequest, 'utf8').digest('hex'), ].join('\n'); const signingKey = deriveSigningKey(config.secret, dateStamp, config.region, 's3'); const signature = createHmac('sha256', signingKey) .update(stringToSign, 'utf8') .digest('hex'); return `https://${host}${canonicalUri}?${canonicalQueryString}&X-Amz-Signature=${signature}`; } export function presignDownload( objectKey: string, expiresInSeconds = 600, cfg: B2Config = getB2Config() ): string { if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey); return presign({ method: 'GET', objectKey, expiresInSeconds, config: cfg }); } export function presignUpload( objectKey: string, expiresInSeconds = 1800, cfg: B2Config = getB2Config() ): string { if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey); return presign({ method: 'PUT', objectKey, expiresInSeconds, config: cfg }); } /** * Stream a B2 object to a Buffer. Caps at MAX_DOWNLOAD_BYTES — refuses to * read past that even if the server returns more. */ export async function downloadToBuffer( objectKey: string, cfg: B2Config = getB2Config() ): Promise { const url = presignDownload(objectKey, 600, cfg); const res = await fetch(url); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`B2 GET ${objectKey} → ${res.status} ${res.statusText}: ${text.slice(0, 300)}`); } // Best-effort content-length check before reading the body. const contentLength = res.headers.get('content-length'); if (contentLength && Number(contentLength) > MAX_DOWNLOAD_BYTES) { throw new Error( `B2 object ${objectKey} too large: ${contentLength} bytes (cap ${MAX_DOWNLOAD_BYTES})` ); } if (!res.body) { throw new Error(`B2 GET ${objectKey} returned no body`); } const reader = res.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; for (;;) { const { value, done } = await reader.read(); if (done) break; if (!value) continue; total += value.byteLength; if (total > MAX_DOWNLOAD_BYTES) { try { await reader.cancel(); } catch { /* ignore */ } throw new Error( `B2 object ${objectKey} exceeded ${MAX_DOWNLOAD_BYTES} bytes mid-stream` ); } chunks.push(value); } return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))); } // Test-only exports. export const _B2_INTERNALS = { deriveSigningKey, presign, };