diff --git a/deno.lock b/deno.lock index 41ddea45..98ba2f7d 100644 --- a/deno.lock +++ b/deno.lock @@ -1806,7 +1806,7 @@ }, "packages/prometheus": { "dependencies": [ - "jsr:@zuke/core@^1.66.0" + "jsr:@zuke/core@^1.76.0" ] }, "packages/qr": { diff --git a/packages/prometheus/deno.json b/packages/prometheus/deno.json index e2aac855..33797675 100644 --- a/packages/prometheus/deno.json +++ b/packages/prometheus/deno.json @@ -7,7 +7,7 @@ ".": "./mod.ts" }, "imports": { - "@zuke/core": "jsr:@zuke/core@^1.66.0" + "@zuke/core": "jsr:@zuke/core@^1.76.0" }, "publish": { "exclude": [ diff --git a/packages/prometheus/src/aws.ts b/packages/prometheus/src/aws.ts index 233af6cc..33674286 100644 --- a/packages/prometheus/src/aws.ts +++ b/packages/prometheus/src/aws.ts @@ -35,7 +35,7 @@ import { AWS_PROMETHEUS_SERVICE, type PrometheusAwsSettings, } from "./aws_settings.ts"; -import { type AwsCredentials, signSigV4 } from "./aws_sigv4.ts"; +import { type AwsCredentials, signSigV4 } from "@zuke/core"; import type { PrometheusCredentials, PrometheusCredentialsContext, diff --git a/packages/prometheus/src/aws_profile.ts b/packages/prometheus/src/aws_profile.ts index 4f946730..828c2ef4 100644 --- a/packages/prometheus/src/aws_profile.ts +++ b/packages/prometheus/src/aws_profile.ts @@ -19,7 +19,7 @@ import { envValue } from "./env.ts"; import { messageOf } from "./message.ts"; import type { PrometheusCredentialsContext } from "./credentials.ts"; -import type { AwsCredentials } from "./aws_sigv4.ts"; +import type { AwsCredentials } from "@zuke/core"; /** Profile keys that source credentials in a way this reader does not. */ const UNSUPPORTED_KEYS = [ diff --git a/packages/prometheus/src/aws_remote.ts b/packages/prometheus/src/aws_remote.ts index 0513d26d..ca99b8fd 100644 --- a/packages/prometheus/src/aws_remote.ts +++ b/packages/prometheus/src/aws_remote.ts @@ -23,7 +23,7 @@ import { type PlaintextAllowance, readAuthFile, } from "./auth_http.ts"; -import type { AwsCredentials } from "./aws_sigv4.ts"; +import type { AwsCredentials } from "@zuke/core"; import type { PrometheusCredentialsContext } from "./credentials.ts"; import { own } from "./shape.ts"; import type { Expiring } from "./token_cache.ts"; diff --git a/packages/prometheus/src/aws_sigv4.ts b/packages/prometheus/src/aws_sigv4.ts deleted file mode 100644 index e4f082d5..00000000 --- a/packages/prometheus/src/aws_sigv4.ts +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) 2026 the Zuke contributors -// SPDX-License-Identifier: MIT - -/** - * AWS Signature Version 4, over the exact request the transport sends, with - * WebCrypto's HMAC-SHA256 — the signing half of the AWS credential source. - * - * Built step by step as the IAM user guide's "Create a signed AWS API - * request" describes it — the path handled as botocore's `SigV4Auth` does - * for a non-S3 service — and checked against the AWS SigV4 test suite: - * - * 1. the canonical request — method, canonical URI (the path as sent, - * normalised — dot segments and empty segments removed — and then - * percent-encoded again, so `%20` on the wire is `%2520` here, as every - * service but S3 expects), canonical query (each name and value decoded - * and re-encoded, sorted by name then value), canonical - * headers (lower-case names, trimmed values, sorted), the signed-header - * list, and the hex SHA-256 of the body; - * 2. the string to sign — `AWS4-HMAC-SHA256`, the `x-amz-date`, the - * credential scope `///aws4_request`, and the hex - * SHA-256 of the canonical request; - * 3. the signing key — HMAC chained over `AWS4`, the date, the region, - * the service and `aws4_request`; - * 4. the `Authorization` header carrying the hex HMAC of the string to sign. - * - * Every header the request already carries is signed, together with `host`, - * `x-amz-date` and — for temporary credentials — `x-amz-security-token`. - * - * @module - */ - -import { sha256Hex } from "@zuke/core"; -import type { PrometheusOutgoingRequest } from "./credentials.ts"; -import { uriEncode } from "./uri.ts"; - -/** An AWS access key, with the session token temporary credentials carry. */ -export interface AwsCredentials { - /** The access key id (`AKIA…` long-term, `ASIA…` temporary). */ - readonly accessKeyId: string; - /** The secret access key. Never sent; only used to derive the signing key. */ - readonly secretAccessKey: string; - /** The session token of temporary credentials. */ - readonly sessionToken?: string; -} - -/** What signing produced: the intermediate strings and the headers to add. */ -interface SigV4Signature { - /** The canonical request (step 1). */ - readonly canonicalRequest: string; - /** The string to sign (step 2). */ - readonly stringToSign: string; - /** The headers to add: `x-amz-date`, `x-amz-security-token`, `authorization`. */ - readonly headers: Readonly>; -} - -/** The signing algorithm's name, as the header and string to sign spell it. */ -const ALGORITHM = "AWS4-HMAC-SHA256"; - -/** `bytes` as lower-case hex. */ -function hex(bytes: ArrayBuffer): string { - return Array.from( - new Uint8Array(bytes), - (b) => b.toString(16).padStart(2, "0"), - ).join(""); -} - -/** HMAC-SHA256 of `data` under `key`. */ -async function hmac( - key: Uint8Array | ArrayBuffer, - data: string, -): Promise { - const cryptoKey = await crypto.subtle.importKey( - "raw", - key, - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - return await crypto.subtle.sign( - "HMAC", - cryptoKey, - new TextEncoder().encode(data), - ); -} - -/** A percent-encoded query component, decoded — or an error saying so. */ -function decode(component: string): string { - try { - return decodeURIComponent(component); - } catch { - throw new Error("aws: the URL's query is not valid percent-encoding"); - } -} - -/** - * The canonical URI: the path exactly as sent (already percent-encoded), - * normalised, then each segment percent-encoded once more — the double - * encoding SigV4 requires of every service except S3. The URL parser has - * already resolved dot segments (`.`, `..`, and their `%2E` spellings), so - * normalising is dropping empty segments (`//`). A trailing `/` is kept. - */ -function canonicalUri(url: URL): string { - const path = url.pathname; - const segments = path.split("/").filter((segment) => segment !== "") - .map(uriEncode); - const last = path.endsWith("/") && segments.length > 0 ? "/" : ""; - return `/${segments.join("/")}${last}`; -} - -/** - * The canonical query string: each parameter's name and value decoded (a - * `+` read as a space, as Prometheus reads it), RFC 3986 encoded, and sorted - * by name, then value. - */ -function canonicalQuery(url: URL): string { - const pairs = url.search.slice(1).split("&").filter((pair) => pair !== "") - .map((pair): [string, string] => { - const at = pair.indexOf("="); - const [name, value] = at === -1 - ? [pair, ""] - : [pair.slice(0, at), pair.slice(at + 1)]; - const read = (part: string) => - uriEncode(decode(part.replace(/\+/g, " "))); - return [read(name), read(value)]; - }); - // Code-point order on the encoded strings, as AWS sorts. Two identical - // pairs may land either way round: they encode the same. - pairs.sort(([an, av], [bn, bv]) => - an === bn ? (av < bv ? -1 : 1) : (an < bn ? -1 : 1) - ); - return pairs.map(([name, value]) => `${name}=${value}`).join("&"); -} - -/** `20150830T123600Z` for an epoch-ms instant. */ -function amzDate(now: number): string { - return new Date(now).toISOString().replace(/[-:]/g, "").replace( - /\.\d{3}/, - "", - ); -} - -/** - * Sign `request` for `service` in `region` at the instant `now` (epoch ms). - * Pure apart from WebCrypto: the same inputs give the same signature. - */ -export async function signSigV4( - request: PrometheusOutgoingRequest, - credentials: AwsCredentials, - region: string, - service: string, - now: number, -): Promise { - const url = new URL(request.url); - const date = amzDate(now); - const day = date.slice(0, 8); - const added: Record = { "x-amz-date": date }; - if (credentials.sessionToken !== undefined) { - added["x-amz-security-token"] = credentials.sessionToken; - } - const headers = new Map(); - for ( - const [name, value] of [ - ...Object.entries(request.headers), - ["host", url.host], - ...Object.entries(added), - ] - ) { - headers.set(name.toLowerCase(), value.trim().replace(/\s+/g, " ")); - } - const names = [...headers.keys()].sort(); - const signedHeaders = names.join(";"); - const canonicalRequest = [ - request.method, - canonicalUri(url), - canonicalQuery(url), - names.map((name) => `${name}:${headers.get(name)}\n`).join(""), - signedHeaders, - await sha256Hex(request.body), - ].join("\n"); - const scope = `${day}/${region}/${service}/aws4_request`; - const stringToSign = [ - ALGORITHM, - date, - scope, - await sha256Hex(canonicalRequest), - ].join("\n"); - const kDate = await hmac( - new TextEncoder().encode(`AWS4${credentials.secretAccessKey}`), - day, - ); - const kRegion = await hmac(kDate, region); - const kService = await hmac(kRegion, service); - const kSigning = await hmac(kService, "aws4_request"); - const signature = hex(await hmac(kSigning, stringToSign)); - return { - canonicalRequest, - stringToSign, - headers: { - ...added, - authorization: `${ALGORITHM} Credential=${credentials.accessKeyId}/` + - `${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, - }, - }; -} diff --git a/packages/prometheus/src/google.ts b/packages/prometheus/src/google.ts index 8e1c85ea..ea50f691 100644 --- a/packages/prometheus/src/google.ts +++ b/packages/prometheus/src/google.ts @@ -36,7 +36,7 @@ import { externalAccountToken } from "./google_external.ts"; import type { PrometheusGoogleSettings } from "./google_settings.ts"; import { isRecord, own, readString } from "./shape.ts"; import { type Expiring, TokenCache } from "./token_cache.ts"; -import { uriEncode } from "./uri.ts"; +import { uriEncode } from "@zuke/core"; /** Google's OAuth 2.0 token endpoint, when a file names none. */ const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; diff --git a/packages/prometheus/src/transport.ts b/packages/prometheus/src/transport.ts index 94a83eb7..7ba21c18 100644 --- a/packages/prometheus/src/transport.ts +++ b/packages/prometheus/src/transport.ts @@ -34,7 +34,7 @@ import { messageOf } from "./message.ts"; import { refusedRedirect, scrub as scrubText } from "./scrub.ts"; import { isRecord, own, readStringArray } from "./shape.ts"; import type { PrometheusHttpMethod, PrometheusResponse } from "./types.ts"; -import { uriEncode } from "./uri.ts"; +import { uriEncode } from "@zuke/core"; /** One call: the method, the endpoint path, and its parameters in order. */ export interface PrometheusCall { diff --git a/packages/prometheus/src/uri.ts b/packages/prometheus/src/uri.ts deleted file mode 100644 index 33ac8354..00000000 --- a/packages/prometheus/src/uri.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2026 the Zuke contributors -// SPDX-License-Identifier: MIT - -/** - * The one percent-encoder for what this package puts in a URL: RFC 3986 - * strict, so every byte but the unreserved `A–Z a–z 0–9 - . _ ~` is escaped, - * and a space is `%20`, never the form encoding's `+`. - * - * The transport encodes a `GET` call's parameters with it, and the AWS SigV4 - * signer encodes the canonical query with it. Using the same encoder for both - * is what makes the URL that is sent byte-identical to the URL that was - * signed: a `+` on the wire that one side reads as a space and the other as a - * plus is a signature that fails for some queries and not others. - * - * @module - */ - -/** `value` percent-encoded per RFC 3986 (unreserved characters kept). */ -export function uriEncode(value: string): string { - // A lone surrogate cannot be UTF-8 encoded; replace it as URLSearchParams - // would rather than throw a URIError over a stray character in a query. - return encodeURIComponent(value.toWellFormed()).replace( - /[!'()*]/g, - (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, - ); -} diff --git a/packages/prometheus/tests/aws_sigv4_test.ts b/packages/prometheus/tests/aws_sigv4_test.ts deleted file mode 100644 index 42a676f2..00000000 --- a/packages/prometheus/tests/aws_sigv4_test.ts +++ /dev/null @@ -1,267 +0,0 @@ -// Copyright (c) 2026 the Zuke contributors -// SPDX-License-Identifier: MIT - -/** - * Unit: the SigV4 signer against the AWS Signature Version 4 test suite. - * - * The vectors below are copied verbatim from the suite as published in - * `awslabs/aws-c-auth` (`tests/aws-signing-test-suite/v4//`): the - * request, the expected canonical request, string to sign and signature, - * signed at 2015-08-30T12:36:00Z in `us-east-1` for the service `service` - * with the suite's documented example key (`AKIDEXAMPLE` — AWS's published - * placeholder, not a credential). The `post-x-www-form-urlencoded` case signs - * its body hash header, which the suite's `sign_body` option adds. - * - * The suite's `get-utf8` and `get-space-normalized` cases are left out: their - * request lines carry a raw, unencoded path (`/ሴ`, `/example space/`) that no - * HTTP client sends, and encode it once. A real request's path is already - * percent-encoded, and SigV4 for every service but S3 encodes it again - * (botocore's `SigV4Auth`); that rule is tested on its own below. - */ - -import { assertEquals, assertRejects } from "../../core/tests/_assert.ts"; -import { sha256Hex } from "@zuke/core"; -import { signSigV4 } from "../src/aws_sigv4.ts"; - -/** The suite's example credentials. */ -const KEY = { - accessKeyId: "AKIDEXAMPLE", - secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", -}; - -/** The suite's signing instant. */ -const NOW = Date.parse("2015-08-30T12:36:00Z"); - -/** One test-suite case. */ -interface Vector { - name: string; - method: string; - target: string; - headers: Record; - body: string; - signBody: boolean; - token: string | null; - canonical: string; - stringToSign: string; - signature: string; -} - -// cspell:disable -- the suite's hex digests -/** The suite's cases, verbatim. */ -const VECTORS: Vector[] = [ - { - "name": "get-vanilla", - "method": "GET", - "target": "/", - "headers": {}, - "body": "", - "signBody": false, - "token": null, - "canonical": - "GET\n/\n\nhost:example.amazonaws.com\nx-amz-date:20150830T123600Z\n\nhost;x-amz-date\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "stringToSign": - "AWS4-HMAC-SHA256\n20150830T123600Z\n20150830/us-east-1/service/aws4_request\nbb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63", - "signature": - "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31", - }, - { - "name": "get-vanilla-query-order-key-case", - "method": "GET", - "target": "/?Param2=value2&Param1=value1", - "headers": {}, - "body": "", - "signBody": false, - "token": null, - "canonical": - "GET\n/\nParam1=value1&Param2=value2\nhost:example.amazonaws.com\nx-amz-date:20150830T123600Z\n\nhost;x-amz-date\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "stringToSign": - "AWS4-HMAC-SHA256\n20150830T123600Z\n20150830/us-east-1/service/aws4_request\n816cd5b414d056048ba4f7c5386d6e0533120fb1fcfa93762cf0fc39e2cf19e0", - "signature": - "b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500", - }, - { - "name": "get-vanilla-query-unreserved", - "method": "GET", - "target": - "/?-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz=-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", - "headers": {}, - "body": "", - "signBody": false, - "token": null, - "canonical": - "GET\n/\n-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz=-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\nhost:example.amazonaws.com\nx-amz-date:20150830T123600Z\n\nhost;x-amz-date\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "stringToSign": - "AWS4-HMAC-SHA256\n20150830T123600Z\n20150830/us-east-1/service/aws4_request\nc30d4703d9f799439be92736156d47ccfb2d879ddf56f5befa6d1d6aab979177", - "signature": - "9c3e54bfcdf0b19771a7f523ee5669cdf59bc7cc0884027167c21bb143a40197", - }, - { - "name": "get-vanilla-with-session-token", - "method": "GET", - "target": "/", - "headers": {}, - "body": "", - "signBody": false, - "token": "6e86291e8372ff2a2260956d9b8aae1d763fbf315fa00fa31553b73ebf194267", // gitleaks:allow — the suite's published example - "canonical": - "GET\n/\n\nhost:example.amazonaws.com\nx-amz-date:20150830T123600Z\nx-amz-security-token:6e86291e8372ff2a2260956d9b8aae1d763fbf315fa00fa31553b73ebf194267\n\nhost;x-amz-date;x-amz-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", // gitleaks:allow — the suite's published example - "stringToSign": - "AWS4-HMAC-SHA256\n20150830T123600Z\n20150830/us-east-1/service/aws4_request\n067b36aa60031588cea4a4cde1f21215227a047690c72247f1d70b32fbbfad2b", - "signature": - "07ec1639c89043aa0e3e2de82b96708f198cceab042d4a97044c66dd9f74e7f8", - }, - { - "name": "post-vanilla-query", - "method": "POST", - "target": "/?Param1=value1", - "headers": {}, - "body": "", - "signBody": false, - "token": null, - "canonical": - "POST\n/\nParam1=value1\nhost:example.amazonaws.com\nx-amz-date:20150830T123600Z\n\nhost;x-amz-date\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "stringToSign": - "AWS4-HMAC-SHA256\n20150830T123600Z\n20150830/us-east-1/service/aws4_request\n9d659678c1756bb3113e2ce898845a0a79dbbc57b740555917687f1b3340fbbd", - "signature": - "28038455d6de14eafc1f9222cf5aa6f1a96197d7deb8263271d420d138af7f11", - }, - { - "name": "post-x-www-form-urlencoded", - "method": "POST", - "target": "/", - "headers": { - "content-type": "application/x-www-form-urlencoded", - "content-length": "13", - }, - "body": "Param1=value1", - "signBody": true, - "token": null, - "canonical": - "POST\n/\n\ncontent-length:13\ncontent-type:application/x-www-form-urlencoded\nhost:example.amazonaws.com\nx-amz-content-sha256:9095672bbd1f56dfc5b65f3e153adc8731a4a654192329106275f4c7b24d0b6e\nx-amz-date:20150830T123600Z\n\ncontent-length;content-type;host;x-amz-content-sha256;x-amz-date\n9095672bbd1f56dfc5b65f3e153adc8731a4a654192329106275f4c7b24d0b6e", - "stringToSign": - "AWS4-HMAC-SHA256\n20150830T123600Z\n20150830/us-east-1/service/aws4_request\nb1edd1d03544c25390e32085d55b57acc9a3961bb59415ff86c45c3d89d16cfb", - "signature": - "d3875051da38690788ef43de4db0d8f280229d82040bfac253562e56c3f20e0b", - }, -]; -// cspell:enable - -/** The request URL for a suite target: the raw path, encoded as a client would. */ -function urlOf(target: string): string { - const url = new URL(`https://example.amazonaws.com${target}`); - return url.href; -} - -for (const vector of VECTORS) { - Deno.test(`SigV4 test suite: ${vector.name}`, async () => { - const body = new TextEncoder().encode(vector.body); - const headers = vector.signBody - ? { ...vector.headers, "x-amz-content-sha256": await sha256Hex(body) } - : vector.headers; - const signed = await signSigV4( - { - method: vector.method === "POST" ? "POST" : "GET", - url: urlOf(vector.target), - headers, - body, - }, - vector.token === null ? KEY : { ...KEY, sessionToken: vector.token }, - "us-east-1", - "service", - NOW, - ); - assertEquals(signed.canonicalRequest, vector.canonical); - assertEquals(signed.stringToSign, vector.stringToSign); - const names = vector.canonical.split("\n").at(-2); - assertEquals( - signed.headers.authorization, - `AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/` + - `aws4_request, SignedHeaders=${names}, Signature=${vector.signature}`, - ); - assertEquals(signed.headers["x-amz-date"], "20150830T123600Z"); - assertEquals( - signed.headers["x-amz-security-token"], - vector.token === null ? undefined : vector.token, - ); - }); -} - -Deno.test("SigV4 canonicalises a + as a space, repeated names by value, and %20 the same", async () => { - const sign = (url: string) => - signSigV4( - { method: "GET", url, headers: {}, body: new Uint8Array(0) }, - KEY, - "us-east-1", - "aps", - NOW, - ); - const plus = await sign("https://h.example/q?b=2&a=x+y&a=w&a=z&flag"); - assertEquals( - plus.canonicalRequest.split("\n")[2], - "a=w&a=x%20y&a=z&b=2&flag=", - ); - const encoded = await sign("https://h.example/q?flag=&a=z&b=2&a=x%20y&a=w"); - assertEquals(encoded.headers.authorization, plus.headers.authorization); -}); - -Deno.test("SigV4 trims and collapses header values and signs the port in host", async () => { - const signed = await signSigV4( - { - method: "GET", - url: "https://h.example:8443/", - headers: { "x-custom": " a b " }, - body: new Uint8Array(0), - }, - KEY, - "us-east-1", - "aps", - NOW, - ); - const lines = signed.canonicalRequest.split("\n"); - assertEquals(lines.slice(3, 6), [ - "host:h.example:8443", - "x-amz-date:20150830T123600Z", - "x-custom:a b", - ]); -}); - -Deno.test("SigV4 double-encodes the wire path and drops empty and dot segments", async () => { - const cases: Array<[string, string]> = [ - ["https://h.example/example%20space/", "/example%2520space/"], - ["https://h.example/\u1234", "/%25E1%2588%25B4"], - ["https://h.example/a//b/./c/%2E%2E/d", "/a/b/d"], - ["https://h.example/ws-1/api/v1/query", "/ws-1/api/v1/query"], - ["https://h.example//", "/"], - ["https://h.example/%zz", "/%25zz"], - ]; - for (const [url, canonical] of cases) { - const signed = await signSigV4( - { method: "GET", url, headers: {}, body: new Uint8Array(0) }, - KEY, - "us-east-1", - "aps", - NOW, - ); - assertEquals(signed.canonicalRequest.split("\n")[1], canonical, url); - } -}); - -Deno.test("SigV4 refuses a query that is not valid percent-encoding", async () => { - await assertRejects( - () => - signSigV4( - { - method: "GET", - url: "https://h.example/?a=%zz", - headers: {}, - body: new Uint8Array(0), - }, - KEY, - "us-east-1", - "aps", - NOW, - ), - Error, - "the URL's query is not valid percent-encoding", - ); -}); diff --git a/packages/prometheus/tests/aws_test.ts b/packages/prometheus/tests/aws_test.ts index 96859055..fcc281c8 100644 --- a/packages/prometheus/tests/aws_test.ts +++ b/packages/prometheus/tests/aws_test.ts @@ -24,7 +24,7 @@ import { PrometheusTasks, } from "../mod.ts"; import { sigv4Credentials } from "../src/aws.ts"; -import { signSigV4 } from "../src/aws_sigv4.ts"; +import { signSigV4 } from "@zuke/core"; import { type Clock, context,