/** * AWS Signature Version 4 for S3 requests — zero external deps, built on * `node:crypto` + `fetch`. Matches the framework's zero-dep ethos (like * `@wrnexus/ai`) and works with any S3-compatible service (AWS, Cloudflare R2, * Backblaze B2, MinIO, DigitalOcean Spaces). * * Reference: docs.aws.amazon.com/general/latest/gr/sigv4_signing.html */ import { createHash, createHmac } from "node:crypto"; /** Hex-encoded SHA-256 of a payload. */ export function sha256Hex(data: Uint8Array | string): string { return createHash("sha256").update(data).digest("hex"); } function hmac(key: Uint8Array | string, data: string): Buffer { return createHmac("sha256", key as Buffer) .update(data, "utf8") .digest(); } /** `2024-01-02T03:04:05.678Z` → `20240102T030405Z`. */ function amzDate(d: Date): string { return d .toISOString() .replace(/[.]\d{3}/, "") .replace(/[:-]/g, ""); } /** * Percent-encode an S3 object key for the request path. Every character except * the RFC 3986 unreserved set is encoded; `/` between segments is preserved. */ export function encodeKey(key: string): string { return key .split("/") .map((seg) => encodeURIComponent(seg).replace( /[!*'()]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(), ), ) .join("/"); } export interface SignInput { method: string; host: string; /** Canonical URI — already `%`-encoded, begins with `/`. */ path: string; region: string; accessKeyId: string; secretAccessKey: string; /** Hex SHA-256 of the body, or `"UNSIGNED-PAYLOAD"`. */ payloadHash: string; /** Extra headers to sign (e.g. `content-type`). `host`/`x-amz-*` are added here. */ headers?: Record; date: Date; service?: string; } /** * Compute the signed header set for an S3 request. Returns the headers to send * (lowercased names, including `authorization`, `host`, `x-amz-date`, * `x-amz-content-sha256`). */ export function signS3(input: SignInput): Record { const service = input.service ?? "s3"; const now = amzDate(input.date); const stamp = now.slice(0, 8); // Normalize headers to lowercase names + trimmed values; add the required ones. const headers: Record = {}; for (const [k, v] of Object.entries(input.headers ?? {})) { headers[k.toLowerCase()] = String(v).trim(); } headers["host"] = input.host; headers["x-amz-content-sha256"] = input.payloadHash; headers["x-amz-date"] = now; const names = Object.keys(headers).sort(); const canonicalHeaders = names.map((n) => `${n}:${headers[n]}\n`).join(""); const signedHeaders = names.join(";"); const canonicalRequest = [ input.method.toUpperCase(), input.path, "", // canonical query string (none for our object operations) canonicalHeaders, signedHeaders, input.payloadHash, ].join("\n"); const scope = `${stamp}/${input.region}/${service}/aws4_request`; const stringToSign = ["AWS4-HMAC-SHA256", now, scope, sha256Hex(canonicalRequest)].join("\n"); const kDate = hmac("AWS4" + input.secretAccessKey, stamp); const kRegion = hmac(kDate, input.region); const kService = hmac(kRegion, service); const kSigning = hmac(kService, "aws4_request"); const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex"); headers["authorization"] = `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, ` + `SignedHeaders=${signedHeaders}, Signature=${signature}`; return headers; }