From 9d4188c7b10a8a326f38f71c82b153a503e4ba35 Mon Sep 17 00:00:00 2001 From: Braiden Maggia Date: Mon, 29 Apr 2024 15:57:21 -0500 Subject: [PATCH] Adding SignatureV4a implementation --- .changeset/tall-pens-yell.md | 5 + packages/signature-v4/package.json | 3 +- packages/signature-v4/src/SignatureV4.ts | 197 +++--------------- packages/signature-v4/src/SignatureV4Base.ts | 170 +++++++++++++++ .../signature-v4/src/SignatureV4a.spec.ts | 41 ++++ packages/signature-v4/src/SignatureV4a.ts | 137 ++++++++++++ packages/signature-v4/src/constants.ts | 6 + .../src/credentialDerivation.spec.ts | Bin 2760 -> 6068 bytes .../signature-v4/src/credentialDerivation.ts | 167 ++++++++++++++- 9 files changed, 554 insertions(+), 172 deletions(-) create mode 100644 .changeset/tall-pens-yell.md create mode 100644 packages/signature-v4/src/SignatureV4Base.ts create mode 100644 packages/signature-v4/src/SignatureV4a.spec.ts create mode 100644 packages/signature-v4/src/SignatureV4a.ts diff --git a/.changeset/tall-pens-yell.md b/.changeset/tall-pens-yell.md new file mode 100644 index 00000000000..2217793f52b --- /dev/null +++ b/.changeset/tall-pens-yell.md @@ -0,0 +1,5 @@ +--- +"@smithy/signature-v4": minor +--- + +Adding Signature V4a implementation diff --git a/packages/signature-v4/package.json b/packages/signature-v4/package.json index 1b6074a10bf..5a9ba4548ee 100644 --- a/packages/signature-v4/package.json +++ b/packages/signature-v4/package.json @@ -30,7 +30,8 @@ "@smithy/util-middleware": "workspace:^", "@smithy/util-uri-escape": "workspace:^", "@smithy/util-utf8": "workspace:^", - "tslib": "^2.6.2" + "tslib": "^2.6.2", + "elliptic": "^6.5.5" }, "devDependencies": { "@aws-crypto/sha256-js": "3.0.0", diff --git a/packages/signature-v4/src/SignatureV4.ts b/packages/signature-v4/src/SignatureV4.ts index 9d5765f75ce..b5b6f0d21ea 100644 --- a/packages/signature-v4/src/SignatureV4.ts +++ b/packages/signature-v4/src/SignatureV4.ts @@ -1,15 +1,10 @@ import { AwsCredentialIdentity, - ChecksumConstructor, - DateInput, EventSigner, EventSigningArguments, FormattedEvent, - HashConstructor, - HeaderBag, HttpRequest, MessageSigner, - Provider, RequestPresigner, RequestPresigningArguments, RequestSigner, @@ -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 { @@ -40,88 +33,34 @@ import { TOKEN_HEADER, TOKEN_QUERY_PARAM, } 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"; +import {createSigV4Scope, getSigV4SigningKey} from "./credentialDerivation"; -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; - - /** - * 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; - - /** - * 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; - private readonly credentialProvider: Provider; - 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({ - applyChecksum, - credentials, - region, - service, - 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); + applyChecksum, + credentials, + region, + service, + sha256, + uriEscapePath = true, + }: SignatureV4Init & SignatureV4CryptoInit) { + super({ + applyChecksum: applyChecksum, + credentials: credentials, + region: region, + service: service, + sha256: sha256, + uriEscapePath: uriEscapePath + }); } public async presign(originalRequest: HttpRequest, options: RequestPresigningArguments = {}): Promise { @@ -138,14 +77,14 @@ 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" ); } - const scope = createScope(shortDate, region, signingService ?? this.service); + const scope = createSigV4Scope(shortDate, region, signingService ?? this.service); const request = moveHeadersToQuery(prepareRequest(originalRequest), { unhoistableHeaders }); if (credentials.sessionToken) { @@ -157,7 +96,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, @@ -190,8 +129,8 @@ export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigne { signingDate = new Date(), priorSignature, signingRegion, signingService }: EventSigningArguments ): Promise { const region = signingRegion ?? (await this.regionProvider()); - const { shortDate, longDate } = formatDate(signingDate); - const scope = createScope(shortDate, region, signingService ?? this.service); + const { shortDate, longDate } = this.formatDate(signingDate); + const scope = createSigV4Scope(shortDate, region, signingService ?? this.service); const hashedPayload = await getPayloadHash({ headers: {}, body: payload } as any, this.sha256); const hash = new this.sha256(); hash.update(headers); @@ -236,7 +175,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)); @@ -257,8 +196,8 @@ 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 scope = createScope(shortDate, region, signingService ?? this.service); + const { longDate, shortDate } = this.formatDate(signingDate); + const scope = createSigV4Scope(shortDate, region, signingService ?? this.service); request.headers[AMZ_DATE_HEADER] = longDate; if (credentials.sessionToken) { @@ -281,75 +220,19 @@ 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 { - 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, canonicalRequest: string ): Promise { - 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)); @@ -362,28 +245,6 @@ ${toHex(hashedRequest)}`; shortDate: string, service?: string ): Promise { - 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"); - } + return getSigV4SigningKey(this.sha256, credentials, shortDate, region, service || this.service); } } - -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(";"); diff --git a/packages/signature-v4/src/SignatureV4Base.ts b/packages/signature-v4/src/SignatureV4Base.ts new file mode 100644 index 00000000000..aebe3e219fc --- /dev/null +++ b/packages/signature-v4/src/SignatureV4Base.ts @@ -0,0 +1,170 @@ +import { + AwsCredentialIdentity, + ChecksumConstructor, + DateInput, + HashConstructor, + HeaderBag, + HttpRequest, + Provider, +} 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 { getCanonicalQuery } from "./getCanonicalQuery"; +import { iso8601 } from "./utilDate"; + +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; + + /** + * 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; + + /** + * 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 abstract class SignatureV4Base { + protected readonly service: string; + protected readonly regionProvider: Provider; + protected readonly credentialProvider: Provider; + protected readonly sha256: ChecksumConstructor | HashConstructor; + private readonly uriEscapePath: boolean; + protected readonly applyChecksum: boolean; + + constructor({ + applyChecksum, + credentials, + region, + service, + 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); + } + + protected 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}`; + } + + protected async createStringToSign( + longDate: string, + credentialScope: string, + canonicalRequest: string, + algorithmIdentifier: string + ): Promise { + const hash = new this.sha256(); + hash.update(toUint8Array(canonicalRequest)); + const hashedRequest = await hash.digest(); + + return `${algorithmIdentifier} +${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; + } + + protected 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"); + } + } + + protected formatDate(now: DateInput): { longDate: string; shortDate: string } { + const longDate = iso8601(now).replace(/[\-:]/g, ""); + return { + longDate, + shortDate: longDate.slice(0, 8), + }; + } + + protected getCanonicalHeaderList(headers: object): string { + return Object.keys(headers).sort().join(";"); + } +} diff --git a/packages/signature-v4/src/SignatureV4a.spec.ts b/packages/signature-v4/src/SignatureV4a.spec.ts new file mode 100644 index 00000000000..23b9de706d4 --- /dev/null +++ b/packages/signature-v4/src/SignatureV4a.spec.ts @@ -0,0 +1,41 @@ +import {AwsCredentialIdentity, HttpRequest} from "@smithy/types"; +import {Sha256} from "@aws-crypto/sha256-js"; +import {SignatureV4a} from "./SignatureV4a"; + +describe('SignatureV4a', () => { + it('SignatureV4a credential check', async () => { + const creds: AwsCredentialIdentity = { + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-access-key', + sessionToken: 'test-secret' + } + + const sigV4aSigner = new SignatureV4a({ + credentials: creds, + sha256: Sha256, + region: "*", + service: "test-service", + applyChecksum: false + }) + + const request: HttpRequest = { + headers: { + }, + hostname: "test", + method: "GET", + path: "/v1.1/test", + protocol: "HTTPS" + } + + const signingDate = new Date(); + signingDate.setTime(1711493155780) + const result = await sigV4aSigner.sign(request, { + signingDate: signingDate + }); + + expect(result.headers['x-amz-date']).toEqual('20240326T224555Z'); + expect(result.headers['x-amz-security-token']).toEqual(creds.sessionToken); + expect(result.headers['x-amz-region-set']).toEqual('*'); + expect(result.headers['authorization']).toEqual('AWS4-ECDSA-P256-SHA256 Credential=test-access-key/20240326/test-service/aws4_request, SignedHeaders=x-amz-date;x-amz-region-set;x-amz-security-token, Signature=3045022100e05f26f5ca09db87269cc0abf4eb81b9e3db6f09090aa5f66da481d4fc4f98ea0220712d5752a049aa86ab727e265a33454ea8327d9291989d4ca0e6dc53d1569d69') + }); +}); diff --git a/packages/signature-v4/src/SignatureV4a.ts b/packages/signature-v4/src/SignatureV4a.ts new file mode 100644 index 00000000000..f48b6139547 --- /dev/null +++ b/packages/signature-v4/src/SignatureV4a.ts @@ -0,0 +1,137 @@ +import {toHex} from "@smithy/util-hex-encoding"; +import { + HttpRequest, + RequestSigner, RequestSigningArguments +} from "@smithy/types"; +import { + ALGORITHM_IDENTIFIER_V4A, + AMZ_DATE_HEADER, + AUTH_HEADER, + REGION_HEADER, + SHA256_HEADER, + TOKEN_HEADER +} from "./constants"; +import * as elliptic from "elliptic" +import {hasHeader} from "./headerUtil"; +import {SignatureV4Base, SignatureV4CryptoInit, SignatureV4Init} from "./SignatureV4Base"; +import {toUint8Array} from "@smithy/util-utf8"; +import {prepareRequest} from "./prepareRequest"; +import {createSigV4aScope, getSigV4aSigningKey} from "./credentialDerivation"; +import {getPayloadHash} from "./getPayloadHash"; +import {getCanonicalHeaders} from "./getCanonicalHeaders"; + +export class SignatureV4a extends SignatureV4Base implements RequestSigner { + /** + * Creates a SigV4a signer + * @param applyChecksum Apply checksum header + * @param credentials Credentials to use when signing + * @param region Region to sign for, Wildcard (*) also accepted + * @param service Service to sign for + * @param uriEscapePath Defaults to true. Used for non s3 services. + */ + constructor({ + applyChecksum, + credentials, + region, + service, + sha256, + uriEscapePath = true, + }: SignatureV4Init & SignatureV4CryptoInit) { + super ({ + applyChecksum: applyChecksum, + credentials: credentials, + region: region, + service: service, + sha256: sha256, + uriEscapePath: uriEscapePath + }); + } + + /** + * Sign a request using SigV4a + * @param toSign HttpRequest to sign + * @param options Additional options + */ + public async sign(toSign: HttpRequest, options: any): Promise { + return this.signRequest(toSign, options); + } + + /** + * Sign a SigV4a request and return its modified HttpRequest. See SigV4a wiki for implementation details + * @param requestToSign HttpRequest to sign + * @param signingDate Signing date (uses UTC now if not specified) + * @param signableHeaders Headers to include in the signing process + * @param unsignableHeaders Headers to not include in the signing process + * @param signingRegion Region to sign the request for. '*' can be used as a wildcard. Falls back to constructor value + * @param signingService Service to sign for + * @private + */ + private async signRequest( + requestToSign: HttpRequest, + { + signingDate = new Date(), + signableHeaders, + unsignableHeaders, + signingRegion, + signingService, + }: RequestSigningArguments = {} + ): Promise { + const credentials = await this.credentialProvider(); + this.validateResolvedCredentials(credentials); + const region = signingRegion ?? (await this.regionProvider()); + const request = prepareRequest(requestToSign); + const { longDate, shortDate } = this.formatDate(signingDate); + const scope = createSigV4aScope(shortDate, signingService ?? this.service); + const pKey = await getSigV4aSigningKey(this.sha256, credentials.accessKeyId, credentials.secretAccessKey); + + request.headers[AMZ_DATE_HEADER] = longDate; + if (credentials.sessionToken) { + request.headers[TOKEN_HEADER] = credentials.sessionToken; + } + + // Region can also be '*' for SigV4a + request.headers[REGION_HEADER] = region; + + const payloadHash = await getPayloadHash(request, this.sha256); + if (!hasHeader(SHA256_HEADER, request.headers) && this.applyChecksum) { + request.headers[SHA256_HEADER] = payloadHash; + } + + const canonicalHeaders = getCanonicalHeaders(request, unsignableHeaders, signableHeaders); + const canonicalRequest = this.createCanonicalRequest(request, canonicalHeaders, payloadHash); + const stringToSign = await this.createStringToSign(longDate, scope, canonicalRequest, ALGORITHM_IDENTIFIER_V4A); + + const signature = await this.GetSignature(pKey, stringToSign); + + request.headers[AUTH_HEADER] = + `${ALGORITHM_IDENTIFIER_V4A} ` + + `Credential=${credentials.accessKeyId}/${scope}, ` + + `SignedHeaders=${this.getCanonicalHeaderList(canonicalHeaders)}, ` + + `Signature=${signature}`; + + return request; + } + + /** + * + * @param privateKey Calculated private key + * @param stringToSign String to sign using private key + * @private + */ + private async GetSignature(privateKey: Uint8Array, stringToSign: string): Promise { + // Create ECDSA and get key pair + const ecdsa = new elliptic.ec('p256') + const key = ecdsa.keyFromPrivate(privateKey); + + // Format request using SHA256 + const hash = new this.sha256(); + hash.update(toUint8Array(stringToSign)); + const hashResult = await hash.digest(); + + // Finally sign using ECDSA keypair. + const signature = key.sign(hashResult, ); + + // Convert signature to DER format (ASN.1's normal singing format) + return toHex(new Uint8Array(signature.toDER())); + } +} diff --git a/packages/signature-v4/src/constants.ts b/packages/signature-v4/src/constants.ts index e66b35d6367..5ee306f6276 100644 --- a/packages/signature-v4/src/constants.ts +++ b/packages/signature-v4/src/constants.ts @@ -14,6 +14,7 @@ export const GENERATED_HEADERS = [AUTH_HEADER, AMZ_DATE_HEADER, DATE_HEADER]; export const SIGNATURE_HEADER = SIGNATURE_QUERY_PARAM.toLowerCase(); export const SHA256_HEADER = "x-amz-content-sha256"; export const TOKEN_HEADER = TOKEN_QUERY_PARAM.toLowerCase(); +export const REGION_HEADER = REGION_SET_PARAM.toLowerCase(); export const HOST_HEADER = "host"; export const ALWAYS_UNSIGNABLE_HEADERS = { @@ -51,3 +52,8 @@ export const MAX_CACHE_SIZE = 50; export const KEY_TYPE_IDENTIFIER = "aws4_request"; export const MAX_PRESIGNED_TTL = 60 * 60 * 24 * 7; + +// AWS SigV4a private signing key constants +export const ONE_AS_4_BYTES = [0x00, 0x00, 0x00, 0x01]; +export const TWOFIFTYSIX_AS_4_BYTES = [0x00, 0x00, 0x01, 0x00]; +export const N_MINUS_TWO = [0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xBC, 0xE6, 0xFA, 0xAD, 0xA7, 0x17, 0x9E, 0x84, 0xF3, 0xB9, 0xCA, 0xC2, 0xFC, 0x63, 0x25, 0x4F]; diff --git a/packages/signature-v4/src/credentialDerivation.spec.ts b/packages/signature-v4/src/credentialDerivation.spec.ts index bfeb17c73490597c1fa1c6a7f1f2e07c5c62ef71..be80e4fc19001a70ef54e20ec107ff08a730f5a7 100644 GIT binary patch literal 6068 zcmd5=ZExE)5Kh1LR~&=^QBXV4i=D*lIxI;OG#Hu=bJ~3fijJ0Oo1H9qB$dP{^51ty zQnF;LOSg5y0z?+YBk#HAUU-sNEDNdO6O8998Xm#}Ol7eE?+13L`iWdEb#b7~RR5#$ zo<9|p{K=g4moP0P=s715=NAQ~;%RFu0DiyZiT0hYniJnkQ8&tZlF!Hlyi;7> ziiCT6d;7XLMSvu7VCN6qWMKz$^VXEK^7`)1~C^20-^nIsC>~ zEoc*t7VvX)n&0I}+7Mk#%r#B?7P>?6g<%e3CrP+c=-O6h{X1Ni~ae z6DG~v1KD8pAn8)SM5Eyac;V?##nh8S>x_C_#q!e4|Z8!DM=PG(8GO$H#1VJdH*pei-q9d2TBc zcNlp%v~ki%87R+@8Fy;_})(^SC?ltQ)r&@#d?J98_BzVxTd+*=loia9qn$sXMTzHkC^Mk z`fH|>`I`w1{cS9UK9jP5kye)@OvP<#8qB(&p`xSM0xyk#y+ijy!m^B*#>2prCL`4j zOb6UXXv2DR2ve&(X_P_3kIDZL^bS=3Egp6_?@`EJZH9)MgM^^m-1wqEAx08 z)rPz2PRmIC6}#o9oabl8)z@Odm4E0~yNGHZV00*B!CSCLj?Hy z?Ae|>Gsxwn?MA%+GY33Y5`T4v!Z)7;5;aB(^O+w8dk?>2<+Qc9M*BdA8~q_{j)$%` zxQh(4QNw~SCUn44Yi3UPhB=>&#VZuKJ5r*qtKXHP{eCTv?UaN0gb~N=i1iC(n<3^|=~sEyem$b$nIG*p030dHQpu zfZ(jFjDO?8D-4c$5FUke8TBBF4toHH5#9~(8644U99BnnGzf5f7~^Gt(qM)WK8s>p z7aroXFbHbtV;sgfYvf>rdqv@ZglTPrcMuLM;c$r3k$D)SAkaFC;h5BgwDr-zpnhMO z>f+myNX6UcP}_DCrpP2mV}$K?zHIHbyCq8@zR^hcGKLQF%+U18mGf%qX&R%cHCcPM zQ=#A3yy3%MXmHjqHJg_0nq@mXJ5Nr284vpBXD`Pm{h#nfw?BS$f^iHf zCVh9~l`}7}#7Vj8G`x^vCUTa&V_8XW3LBvlSBGg!?j{cDa0%`&&Ra=U^Uy-zx7K$0 zb3fzxOwZSDt@XCtPI}iesQ$v3zCqzlj76#rkl;SB=c>y_ii;LJY;pcAb}npfYS~Zd zEO(u=(dd7{tmEvv7lVY)CQm7(#>$*#csp%wNmx_)LRQ~2>U0F^McFHeUrdEE%&gQ!9 zde-=&U$nB?kUy($UtaO=mrp3nnu+%G(#_rdir5 MF&oKgO`zKH2fbil00000 delta 144 zcmdm@e?oM^Vk3p*oYcf3=c3e<)Vz|+#2n|u = {}; const cacheQueue: Array = []; @@ -14,9 +20,18 @@ const cacheQueue: Array = []; * @param region The AWS region in which the service resides. * @param service The service to which the signed request is being sent. */ -export const createScope = (shortDate: string, region: string, service: string): string => +export const createSigV4Scope = (shortDate: string, region: string, service: string): string => `${shortDate}/${region}/${service}/${KEY_TYPE_IDENTIFIER}`; +/** + * Create a string describing the scope of credentials used to sign a request. * @param shortDate + * @param shortDate The current calendar date in the form YYYYMMDD. + * @param service The service to which the signed request is being sent. + */ +export const createSigV4aScope = (shortDate: string, service: string): string => + `${shortDate}/${service}/${KEY_TYPE_IDENTIFIER}`; + + /** * Derive a signing key from its composite parts * @@ -29,7 +44,7 @@ export const createScope = (shortDate: string, region: string, service: string): * @param service The service to which the signed request is being * sent. */ -export const getSigningKey = async ( +export const getSigV4SigningKey = async ( sha256Constructor: ChecksumConstructor | HashConstructor, credentials: AwsCredentialIdentity, shortDate: string, @@ -73,3 +88,149 @@ const hmac = ( hash.update(toUint8Array(data)); return hash.digest(); }; + +export const getSigV4aSigningKey = async ( + sha256: ChecksumConstructor | HashConstructor, + accessKey: string, + secretKey: string): Promise => +{ + let outputBufferWriter = ""; + /* + * The maximum number of iterations we will attempt to derive a valid ecc key for. The probability that this counter + * value ever gets reached is vanishingly low -- with reasonable uniformity/independence assumptions, it's + * approximately + * + * 2 ^ (-32 * 254) + */ + const maxTrials = 254; + const aws4ALength = 5; + const inputKeyLength = aws4ALength + secretKey.length; + + // Allocate array + const inputKeyBuf = inputKeyLength <= 64 ? new Uint8Array(64) : new Uint8Array(inputKeyLength); + + // Input AWS4A and secret into array + const aws4aArray = "AWS4A".split(''); + + for (let index = 0; index < aws4aArray.length; index++) { + inputKeyBuf[index] = aws4aArray[index].charCodeAt(0); + } + + const secretKeyArray = secretKey.split(''); + + for (let index = 0; index < secretKeyArray.length; index++) { + inputKeyBuf[aws4aArray.length + index] = secretKeyArray[index].charCodeAt(0); + } + + let trial = 1; + while (trial < maxTrials) + { + outputBufferWriter = buildFixedInputBuffer(outputBufferWriter, accessKey, trial); + + const secretKey = inputKeyBuf.subarray(0, inputKeyLength); + const hash = new sha256(secretKey); + + const hashVal = toUint8Array(outputBufferWriter); + hash.update(hashVal); + + const hashedOutput = await hash.digest(); + + if (isBiggerThanNMinus2(hashedOutput)) + { + trial++; + continue; + } + + return addOneToArray(hashedOutput); + } + + throw new Error("Cannot derive signing key: number of maximum trials exceeded."); +} + +/** + * Build the signing key request. Implementation copied from .NET implementation + * @param bufferInput Input string. Will append values and return as new string + * @param accessKey Access key used for signing + * @param counter Trial number + */ +export const buildFixedInputBuffer = (bufferInput: string, accessKey: string, counter: number): string => { + /* + Label = “AWS4-ECDSA-P256-SHA256” + ExternalCounter = 0x01 + This counter would be incremented by 1 if the step below fails. + Context = "AccessKeyID" || ExternalCounter + Length = “256”, 0x0100 (32-bit integer) + FixedInputString= 1 || Label || 0x00 || Context || Length + */ + + let outputBuffer = bufferInput; + + outputBuffer += ONE_AS_4_BYTES.map(value => String.fromCharCode(value)).join(''); + + outputBuffer += ALGORITHM_IDENTIFIER_V4A; + + outputBuffer += String.fromCharCode(0x00); + + outputBuffer += accessKey; + + outputBuffer += String.fromCharCode(counter); + + outputBuffer += TWOFIFTYSIX_AS_4_BYTES.map(value => String.fromCharCode(value)).join(''); + + return outputBuffer; +} + +/** + * Check if calculated value is larger than NMinus2 constant + * @param value Array in Big-Endian format + */ +export const isBiggerThanNMinus2 = (value: Uint8Array): boolean => { + // N_MINUS_TWO constant is 32 in length, hashed input is also 32 in length + // It is in Big-Endian format, significant digit first. + + for (let index = 0; index < value.length; index++) { + if (value[index] > N_MINUS_TWO[index]) { + // Value is greater than const + return true; + } + else if (value[index] < N_MINUS_TWO[index]) { + // Const is greater + return false; + } + } + + // Numbers are then same + return false; +}; + +/** + * Adds one to a big-endian number + * @param value Big-endian formatted number + */ +export const addOneToArray = (value: Uint8Array): Uint8Array => { + // Value is in Big-Endian format, significant digit first. This is why we go the opposite way when calculating + let output = new Uint8Array(32); + + // We are adding one, we can simply add this to carry + let carry = 1; + + for (let index = value.length - 1; index >= 0; index--) { + const newValueAtIndex = (value[index] + carry) % 256; + + // If the new value is less than the old, we must have eclipsed 255. We need to carry a digit + if (newValueAtIndex < value[index]) { + carry = 1; + } + else { + carry = 0; + } + + output[index] = newValueAtIndex; + } + + if (carry !== 0) { + return new Uint8Array([carry, ...output]) + } + + return output; +}