110 lines
3.5 KiB
TypeScript
110 lines
3.5 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
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<string, string> {
|
|
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<string, string> = {};
|
|
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;
|
|
}
|