Skip to content
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

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

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
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
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
181 changes: 25 additions & 156 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,69 +35,17 @@ 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";

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

export interface SignatureV4CryptoInit {
sha256: ChecksumConstructor | HashConstructor;
}

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 @@ -115,13 +56,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 @@ -138,7 +80,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 @@ -157,7 +99,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 @@ -190,7 +132,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 @@ -236,7 +178,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 @@ -257,7 +199,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 @@ -281,75 +223,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 @@ -364,26 +255,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
Loading