Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 13 additions & 11 deletions src/lib/adapters/b2/package-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { AwsClient } from "aws4fetch";
import {
PACKAGE_LIMITS,
sha256
Expand All @@ -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;
Expand Down Expand Up @@ -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"
Expand All @@ -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");
Expand Down
147 changes: 147 additions & 0 deletions src/lib/adapters/b2/sigv4.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<ArrayBuffer> {
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<Request> {
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 });
}
144 changes: 144 additions & 0 deletions test/services/extensions/v2/sigv4.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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 { credential, signedHeaders, signature } = authParts(request);
expect(signedHeaders).toBe(
"cache-control;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-meta-sha256"
);
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 () => {
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);
});
});
Loading