/** * @factory/storage — AWS Signature Version 4 for S3 (PURE). * * Implements SigV4 exactly as AWS specifies it * (docs.aws.amazon.com/general/latest/gr/sigv4_signing.html): * * 1. canonical request = METHOD \n canonicalURI \n canonicalQueryString * \n canonicalHeaders \n signedHeaders \n payloadHash * 2. stringToSign = AWS4-HMAC-SHA256 \n amzDate \n scope * \n sha256Hex(canonicalRequest) * 3. signingKey = HMAC chain: "AWS4"+secret → date → region → * service → "aws4_request" * 4. signature = hex(HMAC_SHA256(signingKey, stringToSign)) * * The signer is DETERMINISTIC given `now` (injected, never `new Date()`), * which is what makes it unit-testable without a clock and lets the API and * any future independent implementation produce byte-identical signatures. * * No runtime dependencies: node:crypto (sha256/hmac) only. */ import { createHash, createHmac } from "node:crypto"; import type { S3Config } from "./types.js"; /** SHA-256 of bytes (or a string, UTF-8) as lowercase hex. */ export function sha256Hex(data: Uint8Array | string): string { const hash = createHash("sha256"); hash.update(data); return hash.digest("hex"); } /** HMAC-SHA256 of `data` with `key`, as lowercase hex. */ export function hmacSha256(key: string | Uint8Array, data: string | Uint8Array): string { return createHmac("sha256", key).update(data).digest("hex"); } /** SHA-256 as raw 32 bytes (for the payload hash header). */ export function sha256Bytes(data: Uint8Array | string): Uint8Array { return new Uint8Array(createHash("sha256").update(data).digest()); } /** The canonical empty-body hash (AWS constant). */ export const EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; /** AWS RFC 3986 URI encoding (more strict than encodeURIComponent: `~` and * a few chars are not escaped). */ export function uriEncode(input: string, encodeSlash = true): string { let out = ""; for (const ch of input) { const hex = ch.codePointAt(0)!; // RFC 3986 unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~". These are // NEVER percent-encoded (they appear raw in `X-Amz-*` query keys, the // credential scope, signed-header lists, …). if ( (hex >= 0x30 && hex <= 0x39) || // 0-9 (hex >= 0x41 && hex <= 0x5a) || // A-Z (hex >= 0x61 && hex <= 0x7a) || // a-z ch === "-" || ch === "." || ch === "_" || ch === "~" ) { out += ch; } else if (ch === "/" && !encodeSlash) { out += "/"; } else { for (const byte of new TextEncoder().encode(ch)) { out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; } } } return out; } /** * Encode a RAW request path (leading `/`) for use as a URL path or a SigV4 * canonical URI: each segment RFC 3986-encoded, `/` separators preserved. * * THE single source of truth for "raw path → encoded path". Both the signed * canonical URI and the request URL MUST be derived from the SAME raw path * with this encoding, so the wire path is byte-identical to the path the * signature was computed over. Double-encoding a key containing spaces or * other specials (space → `%2520`) makes S3/MinIO re-canonicalize the wire * path to a different string and reject the request with * `SignatureDoesNotMatch` (the `ARL Budget 208384.xlsx` 403). */ export function encodePath(path: string): string { return ( path .split("/") .map((seg, i) => (i === 0 ? "" : uriEncode(seg, false))) .join("/") || "/" ); } /** * Canonical query string: params sorted by (encoded key, encoded value), * `key=value` joined with `&`. Both sides RFC 3986-encoded (slashes included). */ export function canonicalQuery(params: Record): string { const entries = Object.entries(params) .map(([k, v]) => [uriEncode(k), uriEncode(v)] as const) .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)); return entries.map(([k, v]) => `${k}=${v}`).join("&"); } /** ISO-8601 basic format required by SigV4: `YYYYMMDDTHHMMSSZ`. */ export function amzDate(now: Date): string { return now .toISOString() .replace(/[-:]/g, "") .replace(/\.\d{3}/, ""); } /** `YYYYMMDD` (for the credential scope). */ export function amzDateDay(now: Date): string { return amzDate(now).slice(0, 8); } /** Credential scope: `date/region/service/aws4_request`. */ export function credentialScope(now: Date, region: string, service = "s3"): string { return `${amzDateDay(now)}/${region}/${service}/aws4_request`; } export interface SignS3RequestOptions { method: string; /** Object path INCLUDING the leading `/` (e.g. `/bucket/key` or `/key`). */ path: string; /** Query params (unencoded; the signer encodes + sorts). */ query?: Record; /** Headers to sign — lower-cased keys (content-type, x-amz-date, …). */ headers: Record; /** Hex SHA-256 of the payload (EMPTY_BODY_SHA256 for zero-byte bodies). */ payloadHash: string; config: S3Config; /** Signing time — INJECTED so the signer is deterministic. */ now: Date; /** * When `true` the signer FORCES `x-amz-date` + `x-amz-content-sha256` into * the signed header set (useful when the caller does not manage * `x-amz-date` itself). Default `false`: the signer signs EXACTLY the * header set provided — `x-amz-date` must be passed in `headers` explicitly * (as {@link S3Client} does), keeping the canonical request a pure * function of its inputs. */ alwaysSignContentSha?: boolean; } export interface SignedS3Request { /** Final headers to send (input headers + x-amz-date + x-amz-content-sha256 * + Authorization). */ headers: Record; /** The final `Authorization` header value. */ authorization: string; /** Exposed for tests/auditing: the four SigV4 layers. */ canonicalRequest: string; stringToSign: string; signature: string; } /** * Sign an S3 REST request with SigV4 (V4 header signing, NOT query-string * presigning — used by {@link S3Client} for server-side operations. * Browser-usable URLs come from {@link presignPut}/{@link presignGet}). */ export function signS3Request(opts: SignS3RequestOptions): SignedS3Request { const { config, now } = opts; const method = opts.method.toUpperCase(); const query = opts.query ?? {}; // canonical URI: the RAW path, each segment encoded, `/` preserved (a // single encode — the caller passes the raw path; encoding it AGAIN here // would double-encode keys with spaces/specials and mismatch the wire path). const canonicalUri = encodePath(opts.path); const canonicalQs = canonicalQuery(query); const date = amzDate(now); // canonical headers: sorted by lower-cased name, values trimmed. // `host` is REQUIRED by S3 SigV4 (the request always carries a Host // header) and is injected when the caller did not provide one. // `x-amz-date` and `x-amz-content-sha256` are required on S3 authenticated // (header-signed) requests. They are FORCED into the signed header set only // when `alwaysSignContentSha === true`; otherwise the signer signs exactly // the header set the caller provides (the default — keeps the canonical // request a pure function of its inputs, which is what the exact-shape // and independent-recomputation tests rely on). const alwaysSign = opts.alwaysSignContentSha === true; const lowerHeaders: Record = {}; for (const [k, v] of Object.entries(opts.headers)) { const key = k.toLowerCase(); lowerHeaders[key] = v.replace(/\s+/g, " ").trim(); } if (!("host" in lowerHeaders)) { lowerHeaders["host"] = requestHost(config); } if (alwaysSign) { lowerHeaders["x-amz-date"] = date; lowerHeaders["x-amz-content-sha256"] = opts.payloadHash; } const headerNames = Object.keys(lowerHeaders).sort(); const canonicalHeaders = headerNames.map((n) => `${n}:${lowerHeaders[n]}\n`).join(""); const signedHeaders = headerNames.join(";"); const canonicalRequest = [ method, canonicalUri, canonicalQs, canonicalHeaders, signedHeaders, opts.payloadHash, ].join("\n"); const scope = credentialScope(now, config.region); const stringToSign = ["AWS4-HMAC-SHA256", amzDate(now), scope, sha256Hex(canonicalRequest)].join( "\n", ); // Signing key derivation (the HMAC chain). const kDate = createHmac("sha256", `AWS4${config.secretAccessKey}`) .update(amzDateDay(now)) .digest(); const kRegion = createHmac("sha256", kDate).update(config.region).digest(); const kService = createHmac("sha256", kRegion).update("s3").digest(); const kSigning = createHmac("sha256", kService).update("aws4_request").digest(); const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex"); const headers: Record = { ...lowerHeaders }; headers["Authorization"] = `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${scope}, ` + `SignedHeaders=${signedHeaders}, Signature=${signature}`; return { headers, authorization: headers["Authorization"]!, canonicalRequest, stringToSign, signature, }; } /** * Absolute URL for a request against the configured endpoint. `path` is the * RAW request path (leading `/`); each segment is RFC 3986-encoded once * (exactly the encoding the signers use for the canonical URI — the wire * path must be byte-identical to the signed path, see {@link encodePath}). */ export function buildUrl(config: S3Config, path: string, query?: Record): string { const origin = config.endpoint.replace(/\/+$/, ""); const qs = query && Object.keys(query).length > 0 ? `?${canonicalQuery(query)}` : ""; return `${origin}${encodePath(path)}${qs}`; } /** * Sign a request for an S3 object key, resolving path-style vs * virtual-host addressing: * * - `forcePathStyle` (MinIO): `https://host//` — the canonical * URI contains the bucket. * - otherwise (AWS): `https://.host/` — the host carries the * bucket and the canonical URI does NOT (AWS SigV4 requirement). */ /** * `path` is the RAW request path (no percent-encoding) — the signers * ({@link signS3Request} / {@link presignRequest}) encode it ONCE for the * canonical URI, and {@link buildUrl} / the virtual-host URL below encode it * for the wire. Returning it pre-encoded would make the signers double-encode * keys containing spaces/specials (space → `%2520`) and S3/MinIO would reject * the request with `SignatureDoesNotMatch`. */ export function objectRequestParts(config: S3Config, key: string): { url: string; path: string } { if (config.forcePathStyle === true) { const path = `/${config.bucket}/${key}`; return { url: buildUrl(config, path), path }; } // Virtual-host: bucket becomes a subdomain of the endpoint host. const origin = config.endpoint.replace(/\/+$/, ""); const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/?#]+)(.*)$/i.exec(origin); if (m) { const proto = m[1]!; const host = m[2]!; const rest = m[3] ?? ""; // `encodePath("/key")` is the same single-encoded form the canonical URI // uses; drop its leading `/` (the template supplies it) so the URL is // `./a%20b/c.png`, not `...//a%20b/c.png`. return { url: `${proto}${config.bucket}.${host}${rest}/${encodePath(`/${key}`).slice(1)}`, path: `/${key}` }; } // Malformed endpoint — degrade to path-style (still signed consistently). const path = `/${config.bucket}/${key}`; return { url: buildUrl(config, path), path }; } const PRE_SIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; /** * The value of the HTTP `Host` header a request to this config's objects * actually carries — the value SigV4 MUST sign (AWS SigV4: the canonical * headers always include `host`; reference: the AWS SDK / boto3 sign * `host` on EVERY S3 request, presigned or header-authenticated). * * - path-style: the endpoint authority as-is (e.g. `localhost:9000`) — * the bucket rides in the path, not the host. * - virtual-host: `.` (e.g. * `factory-files.localhost:9000`) — matching the URL the client hits. */ export function requestHost(config: S3Config): string { const origin = config.endpoint.replace(/\/+$/, ""); const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/?#]+)(.*)$/i.exec(origin); if (!m) return origin; const authority = m[2]!; if (config.forcePathStyle === true) return authority; // Virtual-host (the same rule objectRequestParts applies to the URL). return `${config.bucket}.${authority}`; } /** * Sign a request in QUERY-STRING presigned-URL form (AWS SigV4 "Presigned * Requests"). * * Unlike {@link signS3Request} (header-signed), the credentials ride in the * URL itself — `X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, * `X-Amz-Expires`, `X-Amz-SignedHeaders`, `X-Amz-Signature` — so the URL is * usable by ANY HTTP client (browsers, curl, the upload worker) WITHOUT * secret material on the client. The payload hash is `UNSIGNED-PAYLOAD` * (presigned requests never hash the body). * * The signed headers are `host` (always — the request always carries a * Host header and SigV4 requires it in the canonical set) plus whatever * `opts.headers` contains (e.g. `content-type` for a signed PUT). `x-amz-date` * is NOT a signed header in presigned form — it travels in the query string * (`X-Amz-Date`), as the AWS SDK does. The canonical query string signs * EVERY param including `X-Amz-Expires` — omitting any of them from the * signature makes S3 reject the request. */ export function presignRequest(opts: { method: string; config: S3Config; /** Object path INCLUDING the leading `/` (e.g. `/bucket/key`). */ path: string; /** Headers to sign — lower-cased keys (content-type, x-amz-date, …). */ headers: Record; /** Lifetime of the signed URL, seconds. */ expiresSec: number; /** INJECTED clock — determinism. */ now: Date; /** Extra query params to include in the signed URL (e.g. * `response-content-disposition` for downloads). */ extraQuery?: Record | undefined; }): { url: string; /** * The EXACT client headers to send — the caller-provided headers with * `host` and `x-amz-date` REMOVED (the HTTP client sets `host` from the URL; * `x-amz-date` travels in the query string). Empty for a plain GET, * `{ "content-type": … }` for a signed PUT. */ headers: Record; method: string; expiresInSec: number; } { const { config, now, expiresSec } = opts; const method = opts.method.toUpperCase(); const date = amzDate(now); const lowerHeaders: Record = {}; for (const [k, v] of Object.entries(opts.headers)) { lowerHeaders[k.toLowerCase()] = v.replace(/\s+/g, " ").trim(); } // `host` is ALWAYS signed (the request always carries a Host header); // `x-amz-date` is NOT a signed header in presigned form — it is a query // param (X-Amz-Date), exactly as the AWS SDK does it. lowerHeaders["host"] = requestHost(config); const signedHeaderNames = Object.keys(lowerHeaders).sort(); const signedHeaders = signedHeaderNames.join(";"); const query: Record = { "X-Amz-Algorithm": "AWS4-HMAC-SHA256", "X-Amz-Credential": `${config.accessKeyId}/${credentialScope(now, config.region)}`, "X-Amz-Date": date, "X-Amz-Expires": String(expiresSec), "X-Amz-SignedHeaders": signedHeaders, ...(opts.extraQuery ?? {}), }; const canonicalUri = encodePath(opts.path); const canonicalQs = canonicalQuery(query); const canonicalHeaders = signedHeaderNames.map((n) => `${n}:${lowerHeaders[n]}\n`).join(""); const canonicalRequest = [ method, canonicalUri, canonicalQs, canonicalHeaders, signedHeaders, PRE_SIGNED_PAYLOAD, ].join("\n"); const stringToSign = [ "AWS4-HMAC-SHA256", date, credentialScope(now, config.region), sha256Hex(canonicalRequest), ].join("\n"); const kDate = createHmac("sha256", `AWS4${config.secretAccessKey}`) .update(amzDateDay(now)) .digest(); const kRegion = createHmac("sha256", kDate).update(config.region).digest(); const kService = createHmac("sha256", kRegion).update("s3").digest(); const kSigning = createHmac("sha256", kService).update("aws4_request").digest(); const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex"); query["X-Amz-Signature"] = signature; // The URL carries EXACTLY the canonical query string the signature was // computed over (strict RFC 3986 — `/` → %2F in the credential scope, etc). // This mirrors the AWS SDK's presigned-URL output and is byte-identical to // what S3/MinIO re-canonicalize when verifying. const urlQuery = canonicalQuery(query); const origin = config.endpoint.replace(/\/+$/, ""); // Client headers WITHOUT `host` (the HTTP client sets it automatically // from the URL — it is a forbidden/managed header in browsers and Node) // and WITHOUT `x-amz-date` (it is a query param here). const { "x-amz-date": _omitDate, host: _omitHost, ...clientHeaders } = lowerHeaders; void _omitDate; void _omitHost; return { // The URL path is the SAME encoded form the signature was computed over // (the raw path, encoded once) — so the wire path is byte-identical to // the canonical URI and S3/MinIO accept the signature. url: `${origin}${encodePath(opts.path)}?${urlQuery}`, headers: clientHeaders, method, expiresInSec: expiresSec, }; } export interface PresignPutOptions { config: S3Config; key: string; /** Content-Type of the upload — MUST be sent by the uploader (signed). */ contentType: string; /** Lifetime of the signed URL, seconds. */ expiresSec: number; /** INJECTED clock — determinism. */ now: Date; } /** * Presign a PUT as a browser-usable QUERY-STRING signed URL (the two-phase * upload start, ARCHITECTURE.md §8 / TASK-0101 §3). * * The URL carries the whole credential set; the client sends the body with * EXACTLY the returned headers (content-type is signed, so a wrong * Content-Type fails the signature check). Payload: `UNSIGNED-PAYLOAD`. */ export function presignPut(opts: PresignPutOptions): { url: string; headers: Record; method: "PUT"; expiresInSec: number; } { const { config, key, contentType, expiresSec, now } = opts; const { path } = objectRequestParts(config, key); const signed = presignRequest({ method: "PUT", path, headers: { "content-type": contentType }, expiresSec, now, config, }); return { ...signed, method: "PUT" }; } /** * Presign a GET as a browser-usable QUERY-STRING signed URL — the * short-lived download URL (private buckets, ARCHITECTURE.md §8 / * TASK-0101 §5). No client headers required; an optional * `response-content-disposition` is passed through as a SIGNED query param * so the download filename is part of the signature (key + disposition * binding). */ export function presignGet(opts: { config: S3Config; key: string; expiresSec: number; now: Date; /** Optional signed response override (e.g. `attachment; filename="a b.png"`). */ responseContentDisposition?: string; }): { url: string; headers: Record; method: "GET"; expiresInSec: number } { const { config, key, expiresSec, now, responseContentDisposition } = opts; const { path } = objectRequestParts(config, key); const extraQuery: Record = {}; if (responseContentDisposition) { extraQuery["response-content-disposition"] = responseContentDisposition; } const signed = presignRequest({ method: "GET", path, headers: {}, expiresSec, now, config, extraQuery: Object.keys(extraQuery).length > 0 ? extraQuery : undefined, }); return { ...signed, method: "GET" }; }