From 25bb85c49d01f97a6a08cf4c5217d0949f8ba854 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 10 Nov 2026 05:48:29 +0000 Subject: [PATCH 1/2] Replace aws4fetch with owned SigV4 signer --- package-lock.json | 7 - package.json | 1 - src/lib/adapters/b2/package-store.ts | 24 +-- src/lib/adapters/b2/sigv4.ts | 147 ++++++++++++++++++ .../services/extensions/v2/sigv4.node.test.ts | 125 +++++++++++++++ 5 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 src/lib/adapters/b2/sigv4.ts create mode 100644 test/services/extensions/v2/sigv4.node.test.ts diff --git a/package-lock.json b/package-lock.json index 3785a9f4..53b38743 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@scalar/hono-api-reference": "0.12.18", "@zeropress/svg-hush": "0.1.1", "@zip.js/zip.js": "2.24.0", - "aws4fetch": "1.0.20", "badge-maker": "6.0.0", "better-auth": "^1.7.14", "drizzle-orm": "0.45.4", @@ -3253,12 +3252,6 @@ "node": ">=12" } }, - "node_modules/aws4fetch": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", - "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", - "license": "MIT" - }, "node_modules/badge-maker": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/badge-maker/-/badge-maker-6.0.0.tgz", diff --git a/package.json b/package.json index 266ad83c..a7f17fe1 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "@scalar/hono-api-reference": "0.12.18", "@zeropress/svg-hush": "0.1.1", "@zip.js/zip.js": "2.24.0", - "aws4fetch": "1.0.20", "badge-maker": "6.0.0", "better-auth": "^1.7.14", "drizzle-orm": "0.45.4", diff --git a/src/lib/adapters/b2/package-store.ts b/src/lib/adapters/b2/package-store.ts index 09bf1465..12e88c4f 100644 --- a/src/lib/adapters/b2/package-store.ts +++ b/src/lib/adapters/b2/package-store.ts @@ -1,4 +1,3 @@ -import { AwsClient } from "aws4fetch"; import { PACKAGE_LIMITS, sha256 @@ -8,6 +7,7 @@ import { boundedBytes, type PackageStore } from "../../../services/extensions/v2/packages/store"; +import { signS3Request } from "./sigv4"; // Includes generated manifest and ZIP headers as well as the source contents. const MAX_STORED_BYTES = PACKAGE_LIMITS.expandedBytes + 1024 * 1024; @@ -55,13 +55,11 @@ export function b2ObjectStore( ) throw unavailable("Invalid public asset URL configuration"); } - const aws = new AwsClient({ + const credentials = { accessKeyId: get("ACCESS_KEY_ID"), secretAccessKey: get("SECRET_ACCESS_KEY"), - region: get("REGION"), - service: "s3", - retries: 0 - }); + region: get("REGION") + }; const uuid = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"; const pattern = kind === "assets" @@ -78,11 +76,15 @@ export function b2ObjectStore( async function request(url: URL, init: RequestInit) { try { return await fetch( - await aws.sign(url, { - ...init, - redirect: "manual", - signal: AbortSignal.timeout(15000) - }) + await signS3Request( + url, + { + ...init, + redirect: "manual", + signal: AbortSignal.timeout(15000) + }, + credentials + ) ); } catch { throw unavailable("Package storage request failed"); diff --git a/src/lib/adapters/b2/sigv4.ts b/src/lib/adapters/b2/sigv4.ts new file mode 100644 index 00000000..d8d0925d --- /dev/null +++ b/src/lib/adapters/b2/sigv4.ts @@ -0,0 +1,147 @@ +// Minimal AWS Signature Version 4 signer for S3-compatible storage. +// +// Covers only what the B2 package store needs: PUT/GET/HEAD/DELETE against +// path-style https URLs with an explicit payload hash. Uses `crypto.subtle` +// only, so it runs in Cloudflare Workers, Node, and Vitest without +// dependencies. +export interface S3Credentials { + accessKeyId: string; + secretAccessKey: string; + region: string; +} + +const EMPTY_SHA256 = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +function toHex(bytes: ArrayBuffer): string { + return [...new Uint8Array(bytes)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function sha256Hex(data: Uint8Array | string): Promise { + const bytes = + typeof data === "string" ? new TextEncoder().encode(data) : data; + return toHex(await crypto.subtle.digest("SHA-256", bytes)); +} + +async function hmac( + key: Uint8Array | ArrayBuffer | string, + data: string +): Promise { + const bytes = typeof key === "string" ? new TextEncoder().encode(key) : key; + const cryptoKey = await crypto.subtle.importKey( + "raw", + bytes, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + return crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data)); +} + +// encodeURIComponent leaves !'()* unescaped; SigV4 requires them escaped. +function encodeRfc3986(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ); +} + +function canonicalUri(pathname: string): string { + return pathname + .split("/") + .map((segment) => encodeRfc3986(segment)) + .join("/"); +} + +function canonicalQueryString(searchParams: URLSearchParams): string { + return [...searchParams.entries()] + .map(([name, value]) => `${encodeRfc3986(name)}=${encodeRfc3986(value)}`) + .sort() + .join("&"); +} + +function bodyBytes( + body: BodyInit | null | undefined +): Uint8Array | string | null { + if (body == null) return null; + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return body; + return null; +} + +/** Sign a request with SigV4 and return a new `Request` carrying the auth headers. */ +export async function signS3Request( + url: URL | string, + init: RequestInit, + credentials: S3Credentials, + now: Date = new Date() +): Promise { + const target = new URL(url.toString()); + const method = (init.method ?? "GET").toUpperCase(); + const headers = new Headers(init.headers); + const amzDate = now + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d+Z$/, "Z"); + const dateStamp = amzDate.slice(0, 8); + + let payloadHash = headers.get("x-amz-content-sha256"); + if (!payloadHash) { + const bytes = bodyBytes(init.body); + payloadHash = bytes == null ? EMPTY_SHA256 : await sha256Hex(bytes); + } + + headers.set("x-amz-date", amzDate); + headers.set("x-amz-content-sha256", payloadHash); + if (!headers.has("host")) headers.set("host", target.host); + + // Sign the transport headers plus any B2/S3 metadata headers the caller + // set. Other headers (user-agent, accept, ...) stay unsigned so proxies + // and runtimes can add them without breaking the signature. + const signedNames = new Set(["host", "x-amz-content-sha256", "x-amz-date"]); + headers.forEach((_value, name) => { + if ( + name.startsWith("x-amz-") || + name === "content-type" || + name === "cache-control" + ) + signedNames.add(name); + }); + const signedHeaders = [...signedNames].sort(); + const canonicalHeaders = signedHeaders + .map( + (name) => + `${name}:${headers.get(name)?.trim().replace(/\s+/g, " ") ?? ""}\n` + ) + .join(""); + + const canonicalRequest = [ + method, + canonicalUri(target.pathname), + canonicalQueryString(target.searchParams), + canonicalHeaders, + signedHeaders.join(";"), + payloadHash + ].join("\n"); + const scope = `${dateStamp}/${credentials.region}/s3/aws4_request`; + const stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + scope, + await sha256Hex(canonicalRequest) + ].join("\n"); + + let signingKey = await hmac(`AWS4${credentials.secretAccessKey}`, dateStamp); + for (const value of [credentials.region, "s3", "aws4_request"]) + signingKey = await hmac(signingKey, value); + const signature = toHex(await hmac(signingKey, stringToSign)); + + headers.set( + "Authorization", + `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, ` + + `SignedHeaders=${signedHeaders.join(";")}, Signature=${signature}` + ); + return new Request(target, { ...init, headers }); +} diff --git a/test/services/extensions/v2/sigv4.node.test.ts b/test/services/extensions/v2/sigv4.node.test.ts new file mode 100644 index 00000000..6510484e --- /dev/null +++ b/test/services/extensions/v2/sigv4.node.test.ts @@ -0,0 +1,125 @@ +import { createHash, createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { signS3Request } from "../../../../src/lib/adapters/b2/sigv4"; + +const credentials = { + accessKeyId: "test-key", + secretAccessKey: "test-secret", + region: "eu-central-003" +}; +const now = new Date("2024-01-02T03:04:05.000Z"); +const EMPTY = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +function authParts(request: Request) { + const auth = request.headers.get("Authorization") ?? ""; + const credential = auth.match(/Credential=([^,]+)/)?.[1] ?? ""; + const signedHeaders = auth.match(/SignedHeaders=([^,]+)/)?.[1] ?? ""; + const signature = auth.match(/Signature=([0-9a-f]+)/)?.[1] ?? ""; + return { auth, credential, signedHeaders, signature }; +} + +// Independent recomputation with Node crypto (not WebCrypto) to catch wiring +// bugs rather than just snapshotting our own output. +function expectedSignature( + canonicalRequest: string, + amzDate: string, + scope: string +): string { + const stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + scope, + createHash("sha256").update(canonicalRequest).digest("hex") + ].join("\n"); + const hmac = (key: string | Buffer, data: string) => + createHmac("sha256", key).update(data).digest(); + const dateKey = hmac( + `AWS4${credentials.secretAccessKey}`, + scope.split("/")[0] + ); + const regionKey = hmac(dateKey, credentials.region); + const serviceKey = hmac(regionKey, "s3"); + const signingKey = hmac(serviceKey, "aws4_request"); + return createHmac("sha256", signingKey).update(stringToSign).digest("hex"); +} + +describe("SigV4 signer", () => { + it("signs GET with the empty payload hash and sorted headers", async () => { + const request = await signS3Request( + new URL("https://s3.example.test/private-packages/local/packages/id"), + { method: "GET" }, + credentials, + now + ); + expect(request.headers.get("x-amz-date")).toBe("20240102T030405Z"); + expect(request.headers.get("x-amz-content-sha256")).toBe(EMPTY); + expect(request.headers.get("host")).toBe("s3.example.test"); + const { auth, credential, signedHeaders, signature } = authParts(request); + expect(auth.startsWith("AWS4-HMAC-SHA256 ")).toBe(true); + expect(credential).toBe("test-key/20240102/eu-central-003/s3/aws4_request"); + expect(signedHeaders).toBe("host;x-amz-content-sha256;x-amz-date"); + const canonicalRequest = [ + "GET", + "/private-packages/local/packages/id", + "", + "host:s3.example.test\n" + + `x-amz-content-sha256:${EMPTY}\n` + + "x-amz-date:20240102T030405Z\n", + signedHeaders, + EMPTY + ].join("\n"); + expect(signature).toBe( + expectedSignature( + canonicalRequest, + "20240102T030405Z", + credential.split("/").slice(1).join("/") + ) + ); + }); + + it("passes through the caller checksum and signs metadata headers on PUT", async () => { + const checksum = "a".repeat(64); + const request = await signS3Request( + new URL("https://s3.example.test/bucket/key"), + { + method: "PUT", + body: new Uint8Array([1, 2, 3]), + headers: { + "Content-Type": "application/zip", + "Cache-Control": "private, no-store", + "x-amz-content-sha256": checksum, + "x-amz-meta-sha256": checksum + } + }, + credentials, + now + ); + expect(request.headers.get("x-amz-content-sha256")).toBe(checksum); + const { signedHeaders, signature } = authParts(request); + expect(signedHeaders).toBe( + "cache-control;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-meta-sha256" + ); + expect(signature).toMatch(/^[0-9a-f]{64}$/); + }); + + it("binds the versionId query to the signature", async () => { + const signed = (versionId: string) => + signS3Request( + new URL( + `https://s3.example.test/bucket/key?versionId=${encodeURIComponent(versionId)}` + ), + { method: "DELETE" }, + credentials, + now + ); + const [first, second] = await Promise.all([ + signed("stored-version+1"), + signed("other-version") + ]); + expect(new URL(first.url).searchParams.get("versionId")).toBe( + "stored-version+1" + ); + expect(authParts(first).signature).not.toBe(authParts(second).signature); + }); +}); From 7a023a51f29947efda08c2e9ca7e74ec8ec4c2d4 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 10 Nov 2026 05:50:58 +0000 Subject: [PATCH 2/2] Verify PUT signature over metadata values in SigV4 test --- .../services/extensions/v2/sigv4.node.test.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/test/services/extensions/v2/sigv4.node.test.ts b/test/services/extensions/v2/sigv4.node.test.ts index 6510484e..701e26de 100644 --- a/test/services/extensions/v2/sigv4.node.test.ts +++ b/test/services/extensions/v2/sigv4.node.test.ts @@ -96,11 +96,30 @@ describe("SigV4 signer", () => { now ); expect(request.headers.get("x-amz-content-sha256")).toBe(checksum); - const { signedHeaders, signature } = authParts(request); + const { credential, signedHeaders, signature } = authParts(request); expect(signedHeaders).toBe( "cache-control;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-meta-sha256" ); - expect(signature).toMatch(/^[0-9a-f]{64}$/); + const canonicalRequest = [ + "PUT", + "/bucket/key", + "", + "cache-control:private, no-store\n" + + "content-type:application/zip\n" + + "host:s3.example.test\n" + + `x-amz-content-sha256:${checksum}\n` + + "x-amz-date:20240102T030405Z\n" + + `x-amz-meta-sha256:${checksum}\n`, + signedHeaders, + checksum + ].join("\n"); + expect(signature).toBe( + expectedSignature( + canonicalRequest, + "20240102T030405Z", + credential.split("/").slice(1).join("/") + ) + ); }); it("binds the versionId query to the signature", async () => {