Skip to content

feat(signature-v4a): create SignatureV4a JavaScript implementation #1319

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6a62baf
Adding SignatureV4a implementation
Apr 29, 2024
bb531c7
add elliptic types, formatting
kuhe Jun 28, 2024
1739d95
lint fix
kuhe Jun 28, 2024
40ee4af
export sigv4a
kuhe Jun 28, 2024
b439733
move sigv4a to own package
kuhe Jun 28, 2024
f8705a0
formatting
kuhe Jun 28, 2024
a548c0d
build elliptic
kuhe Jun 28, 2024
7204c6b
building elliptic
kuhe Jun 28, 2024
a3544a7
formatting
kuhe Jun 28, 2024
2d3e6d1
chore(signature-v4): remove duplicate script for bundling elliptic
siddsriv Jul 9, 2024
d3a9d9d
chore(signature-v4a): formatting fix
siddsriv Jul 11, 2024
2864e36
test(signature-v4a): move testing from jest to vitest
siddsriv Apr 1, 2025
8cb68d3
Merge branch 'main' into feat/sigv4a
siddsriv Apr 1, 2025
84674f4
chore(lockfile): fix build issues
siddsriv Apr 1, 2025
df793a8
test(signature-v4a): update expectation in buildFixedInputBuffer test…
siddsriv Apr 1, 2025
fc4c0b0
chore(signature-v4): refactor to move sigv4a container here
siddsriv Apr 3, 2025
93aaa49
chore(signature-v4a): newline for CI
siddsriv Apr 3, 2025
e40b2c0
chore(signature-v4a): whitespace fix for CI
siddsriv Apr 3, 2025
11ccd49
chore(signature-v4a): deps updates and lockfile
siddsriv Apr 21, 2025
85049b5
chore(signature-v4a): constants access annotations
siddsriv Apr 21, 2025
72a5dcb
choer(signature-v4a): keep elliptic dep in signature-v4a for bundle
siddsriv Apr 21, 2025
00dc27c
chore(lockfile): ci fix
siddsriv Apr 21, 2025
7c8ed5b
chore(signature-v4a): formatting
siddsriv Apr 21, 2025
60e9f38
Merge remote-tracking branch 'origin/main' into feat/sigv4a
siddsriv Apr 21, 2025
c1499b4
chore(signature-v4): run CI
siddsriv Apr 21, 2025
cab50a2
chore(signature-v4a): readme typo fix
siddsriv Apr 22, 2025
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
5 changes: 5 additions & 0 deletions .changeset/tall-pens-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@smithy/signature-v4": minor
---

Adding Signature V4a implementation
187 changes: 25 additions & 162 deletions packages/signature-v4/src/SignatureV4.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import {
AwsCredentialIdentity,
ChecksumConstructor,
DateInput,
EventSigner,
EventSigningArguments,
FormattedEvent,
HashConstructor,
HeaderBag,
HttpRequest,
MessageSigner,
Provider,
RequestPresigner,
RequestPresigningArguments,
RequestSigner,
Expand All @@ -20,8 +15,6 @@ import {
StringSigner,
} from "@smithy/types";
import { toHex } from "@smithy/util-hex-encoding";
import { normalizeProvider } from "@smithy/util-middleware";
import { escapeUri } from "@smithy/util-uri-escape";
import { toUint8Array } from "@smithy/util-utf8";

import {
Expand All @@ -42,78 +35,20 @@ import {
} from "./constants";
import { createScope, getSigningKey } from "./credentialDerivation";
import { getCanonicalHeaders } from "./getCanonicalHeaders";
import { getCanonicalQuery } from "./getCanonicalQuery";
import { getPayloadHash } from "./getPayloadHash";
import { HeaderFormatter } from "./HeaderFormatter";
import { hasHeader } from "./headerUtil";
import { moveHeadersToQuery } from "./moveHeadersToQuery";
import { prepareRequest } from "./prepareRequest";
import { iso8601 } from "./utilDate";
import { SignatureV4Base, SignatureV4CryptoInit, SignatureV4Init } from "./SignatureV4Base";

/**
* @public
*/
export interface SignatureV4Init {
/**
* The service signing name.
*/
service: string;

/**
* The region name or a function that returns a promise that will be
* resolved with the region name.
*/
region: string | Provider<string>;

/**
* The credentials with which the request should be signed or a function
* that returns a promise that will be resolved with credentials.
*/
credentials: AwsCredentialIdentity | Provider<AwsCredentialIdentity>;

/**
* A constructor function for a hash object that will calculate SHA-256 HMAC
* checksums.
*/
sha256: ChecksumConstructor | HashConstructor;

/**
* Whether to uri-escape the request URI path as part of computing the
* canonical request string. This is required for every AWS service, except
* Amazon S3, as of late 2017.
*
* @default [true]
*/
uriEscapePath?: boolean;

/**
* Whether to calculate a checksum of the request body and include it as
* either a request header (when signing) or as a query string parameter
* (when presigning). This is required for AWS Glacier and Amazon S3 and optional for
* every other AWS service as of late 2017.
*
* @default [true]
*/
applyChecksum?: boolean;
}

/**
* @public
*/
export interface SignatureV4CryptoInit {
sha256: ChecksumConstructor | HashConstructor;
}

/**
* @public
*/
export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigner, EventSigner, MessageSigner {
private readonly service: string;
private readonly regionProvider: Provider<string>;
private readonly credentialProvider: Provider<AwsCredentialIdentity>;
private readonly sha256: ChecksumConstructor | HashConstructor;
private readonly uriEscapePath: boolean;
private readonly applyChecksum: boolean;
export class SignatureV4
extends SignatureV4Base
implements RequestPresigner, RequestSigner, StringSigner, EventSigner, MessageSigner
{
private readonly headerFormatter = new HeaderFormatter();

constructor({
Expand All @@ -124,13 +59,14 @@ export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigne
sha256,
uriEscapePath = true,
}: SignatureV4Init & SignatureV4CryptoInit) {
this.service = service;
this.sha256 = sha256;
this.uriEscapePath = uriEscapePath;
// default to true if applyChecksum isn't set
this.applyChecksum = typeof applyChecksum === "boolean" ? applyChecksum : true;
this.regionProvider = normalizeProvider(region);
this.credentialProvider = normalizeProvider(credentials);
super({
applyChecksum,
credentials,
region,
service,
sha256,
uriEscapePath,
});
}

public async presign(originalRequest: HttpRequest, options: RequestPresigningArguments = {}): Promise<HttpRequest> {
Expand All @@ -148,7 +84,7 @@ export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigne
this.validateResolvedCredentials(credentials);
const region = signingRegion ?? (await this.regionProvider());

const { longDate, shortDate } = formatDate(signingDate);
const { longDate, shortDate } = this.formatDate(signingDate);
if (expiresIn > MAX_PRESIGNED_TTL) {
return Promise.reject(
"Signature version 4 presigned URLs" + " must have an expiration date less than one week in" + " the future"
Expand All @@ -167,7 +103,7 @@ export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigne
request.query[EXPIRES_QUERY_PARAM] = expiresIn.toString(10);

const canonicalHeaders = getCanonicalHeaders(request, unsignableHeaders, signableHeaders);
request.query[SIGNED_HEADERS_QUERY_PARAM] = getCanonicalHeaderList(canonicalHeaders);
request.query[SIGNED_HEADERS_QUERY_PARAM] = this.getCanonicalHeaderList(canonicalHeaders);

request.query[SIGNATURE_QUERY_PARAM] = await this.getSignature(
longDate,
Expand Down Expand Up @@ -200,7 +136,7 @@ export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigne
{ signingDate = new Date(), priorSignature, signingRegion, signingService }: EventSigningArguments
): Promise<string> {
const region = signingRegion ?? (await this.regionProvider());
const { shortDate, longDate } = formatDate(signingDate);
const { shortDate, longDate } = this.formatDate(signingDate);
const scope = createScope(shortDate, region, signingService ?? this.service);
const hashedPayload = await getPayloadHash({ headers: {}, body: payload } as any, this.sha256);
const hash = new this.sha256();
Expand Down Expand Up @@ -246,7 +182,7 @@ export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigne
const credentials = await this.credentialProvider();
this.validateResolvedCredentials(credentials);
const region = signingRegion ?? (await this.regionProvider());
const { shortDate } = formatDate(signingDate);
const { shortDate } = this.formatDate(signingDate);

const hash = new this.sha256(await this.getSigningKey(credentials, region, shortDate, signingService));
hash.update(toUint8Array(stringToSign));
Expand All @@ -267,7 +203,7 @@ export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigne
this.validateResolvedCredentials(credentials);
const region = signingRegion ?? (await this.regionProvider());
const request = prepareRequest(requestToSign);
const { longDate, shortDate } = formatDate(signingDate);
const { longDate, shortDate } = this.formatDate(signingDate);
const scope = createScope(shortDate, region, signingService ?? this.service);

request.headers[AMZ_DATE_HEADER] = longDate;
Expand All @@ -291,75 +227,24 @@ export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigne
request.headers[AUTH_HEADER] =
`${ALGORITHM_IDENTIFIER} ` +
`Credential=${credentials.accessKeyId}/${scope}, ` +
`SignedHeaders=${getCanonicalHeaderList(canonicalHeaders)}, ` +
`SignedHeaders=${this.getCanonicalHeaderList(canonicalHeaders)}, ` +
`Signature=${signature}`;

return request;
}

private createCanonicalRequest(request: HttpRequest, canonicalHeaders: HeaderBag, payloadHash: string): string {
const sortedHeaders = Object.keys(canonicalHeaders).sort();
return `${request.method}
${this.getCanonicalPath(request)}
${getCanonicalQuery(request)}
${sortedHeaders.map((name) => `${name}:${canonicalHeaders[name]}`).join("\n")}

${sortedHeaders.join(";")}
${payloadHash}`;
}

private async createStringToSign(
longDate: string,
credentialScope: string,
canonicalRequest: string
): Promise<string> {
const hash = new this.sha256();
hash.update(toUint8Array(canonicalRequest));
const hashedRequest = await hash.digest();

return `${ALGORITHM_IDENTIFIER}
${longDate}
${credentialScope}
${toHex(hashedRequest)}`;
}

private getCanonicalPath({ path }: HttpRequest): string {
if (this.uriEscapePath) {
// Non-S3 services, we normalize the path and then double URI encode it.
// Ref: "Remove Dot Segments" https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
const normalizedPathSegments = [];
for (const pathSegment of path.split("/")) {
if (pathSegment?.length === 0) continue;
if (pathSegment === ".") continue;
if (pathSegment === "..") {
normalizedPathSegments.pop();
} else {
normalizedPathSegments.push(pathSegment);
}
}
// Joining by single slashes to remove consecutive slashes.
const normalizedPath = `${path?.startsWith("/") ? "/" : ""}${normalizedPathSegments.join("/")}${
normalizedPathSegments.length > 0 && path?.endsWith("/") ? "/" : ""
}`;

// Double encode and replace non-standard characters !'()* according to RFC 3986
const doubleEncoded = escapeUri(normalizedPath);
return doubleEncoded.replace(/%2F/g, "/");
}

// For S3, we shouldn't normalize the path. For example, object name
// my-object//example//photo.user should not be normalized to
// my-object/example/photo.user
return path;
}

private async getSignature(
longDate: string,
credentialScope: string,
keyPromise: Promise<Uint8Array>,
canonicalRequest: string
): Promise<string> {
const stringToSign = await this.createStringToSign(longDate, credentialScope, canonicalRequest);
const stringToSign = await this.createStringToSign(
longDate,
credentialScope,
canonicalRequest,
ALGORITHM_IDENTIFIER
);

const hash = new this.sha256(await keyPromise);
hash.update(toUint8Array(stringToSign));
Expand All @@ -374,26 +259,4 @@ ${toHex(hashedRequest)}`;
): Promise<Uint8Array> {
return getSigningKey(this.sha256, credentials, shortDate, region, service || this.service);
}

private validateResolvedCredentials(credentials: unknown) {
if (
typeof credentials !== "object" ||
// @ts-expect-error: Property 'accessKeyId' does not exist on type 'object'.ts(2339)
typeof credentials.accessKeyId !== "string" ||
// @ts-expect-error: Property 'secretAccessKey' does not exist on type 'object'.ts(2339)
typeof credentials.secretAccessKey !== "string"
) {
throw new Error("Resolved credential object is not valid");
}
}
}

const formatDate = (now: DateInput): { longDate: string; shortDate: string } => {
const longDate = iso8601(now).replace(/[\-:]/g, "");
return {
longDate,
shortDate: longDate.slice(0, 8),
};
};

const getCanonicalHeaderList = (headers: object): string => Object.keys(headers).sort().join(";");
Loading