wulf-pulse/lib/services/b2/client.ts
lorentz 8630fd5151 feat(16-02): add EML_OBJECT_KEY_REGEX + parameterize B2 key validation (D-05)
- New EML_OBJECT_KEY_REGEX enforces phishing/<id>/<id>.eml, rejects traversal
- presignDownload/presignUpload/downloadToBuffer take optional keyRegex,
  defaulting to OBJECT_KEY_REGEX so existing LogLift call sites are unchanged
- OBJECT_KEY_REGEX itself left untouched (skill-doc rule)
2026-07-15 10:23:19 -04:00

224 lines
6.9 KiB
TypeScript

/**
* 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$/;
/**
* Object-key shape for raw `.eml` evidence uploads (Phase 16 / D-05):
* `phishing/{reportId}/{attachmentId}.eml`. This is a SEPARATE regex from
* OBJECT_KEY_REGEX — per the B2 evidence skill doc, never loosen the
* existing LogLift guard to accommodate a new shape. Path-traversal safe:
* each segment is restricted to `[A-Za-z0-9_-]+`, so `..` cannot appear.
*/
export const EML_OBJECT_KEY_REGEX =
/^phishing\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.eml$/;
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<string, string> = {
'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(),
keyRegex: RegExp = OBJECT_KEY_REGEX
): string {
if (!keyRegex.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
return presign({ method: 'GET', objectKey, expiresInSeconds, config: cfg });
}
export function presignUpload(
objectKey: string,
expiresInSeconds = 1800,
cfg: B2Config = getB2Config(),
keyRegex: RegExp = OBJECT_KEY_REGEX
): string {
if (!keyRegex.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(),
keyRegex: RegExp = OBJECT_KEY_REGEX
): Promise<Buffer> {
const url = presignDownload(objectKey, 600, cfg, keyRegex);
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,
};