Files
WRNexusJS/packages/uploader/src/sigv4.ts
T
2026-07-12 15:55:18 +05:30

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;
}