diff --git a/.kokoro/continuous/node10/lint.cfg b/.kokoro/continuous/node12/lint.cfg similarity index 100% rename from .kokoro/continuous/node10/lint.cfg rename to .kokoro/continuous/node12/lint.cfg diff --git a/.kokoro/continuous/node10/samples-test.cfg b/.kokoro/continuous/node12/samples-test.cfg similarity index 100% rename from .kokoro/continuous/node10/samples-test.cfg rename to .kokoro/continuous/node12/samples-test.cfg diff --git a/.kokoro/continuous/node10/system-test.cfg b/.kokoro/continuous/node12/system-test.cfg similarity index 100% rename from .kokoro/continuous/node10/system-test.cfg rename to .kokoro/continuous/node12/system-test.cfg diff --git a/.kokoro/presubmit/node10/samples-test.cfg b/.kokoro/presubmit/node12/samples-test.cfg similarity index 100% rename from .kokoro/presubmit/node10/samples-test.cfg rename to .kokoro/presubmit/node12/samples-test.cfg diff --git a/.kokoro/presubmit/node10/system-test.cfg b/.kokoro/presubmit/node12/system-test.cfg similarity index 100% rename from .kokoro/presubmit/node10/system-test.cfg rename to .kokoro/presubmit/node12/system-test.cfg diff --git a/README.md b/README.md index 0c2d03cf..f29c488a 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,7 @@ const {GoogleAuth} = require('google-auth-library'); async function main() { const url = 'https://cloud-run-1234-uc.a.run.app'; const auth = new GoogleAuth(); - const client = auth.getIdTokenClient(url); + const client = await auth.getIdTokenClient(url); const res = await client.request({url}); console.log(res.data); } @@ -375,7 +375,7 @@ async function main() const targetAudience = 'iap-client-id'; const url = 'https://iap-url.com'; const auth = new GoogleAuth(); - const client = auth.getIdTokenClient(targetAudience); + const client = await auth.getIdTokenClient(targetAudience); const res = await client.request({url}); console.log(res.data); } diff --git a/browser-test/test.crypto.ts b/browser-test/test.crypto.ts index aaa13b3f..c30d04e0 100644 --- a/browser-test/test.crypto.ts +++ b/browser-test/test.crypto.ts @@ -14,7 +14,7 @@ import * as base64js from 'base64-js'; import {assert} from 'chai'; -import {createCrypto} from '../src/crypto/crypto'; +import {createCrypto, fromArrayBufferToHex} from '../src/crypto/crypto'; import {BrowserCrypto} from '../src/crypto/browser/crypto'; import {privateKey, publicKey} from './fixtures/keys'; import {describe, it} from 'mocha'; @@ -24,6 +24,21 @@ import {describe, it} from 'mocha'; // text encoding natively. require('fast-text-encoding'); +/** + * Converts a string to an ArrayBuffer. + * https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String + * @param str The string to convert to an ArrayBuffer. + * @return The ArrayBuffer representation of the string. + */ +function stringToArrayBuffer(str: string): ArrayBuffer { + const arrayBuffer = new ArrayBuffer(str.length * 2); + const arrayBufferView = new Uint16Array(arrayBuffer); + for (let i = 0; i < str.length; i++) { + arrayBufferView[i] = str.charCodeAt(i); + } + return arrayBuffer; +} + describe('Browser crypto tests', () => { const crypto = createCrypto(); @@ -99,4 +114,53 @@ describe('Browser crypto tests', () => { const encodedString = crypto.encodeBase64StringUtf8(originalString); assert.strictEqual(encodedString, base64String); }); + + it('should calculate SHA256 digest in hex encoding', async () => { + const input = 'I can calculate SHA256'; + const expectedHexDigest = + '73d08486d8bfd4fb4bc12dd8903604ddbde5ad95b6efa567bd723ce81a881122'; + + const calculatedHexDigest = await crypto.sha256DigestHex(input); + assert.strictEqual(calculatedHexDigest, expectedHexDigest); + }); + + describe('should compute the HMAC-SHA256 hash of a message', () => { + it('using a string key', async () => { + const message = 'The quick brown fox jumps over the lazy dog'; + const key = 'key'; + const expectedHexHash = + 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; + const extectedHash = new Uint8Array( + (expectedHexHash.match(/.{1,2}/g) as string[]).map(byte => + parseInt(byte, 16) + ) + ); + + const calculatedHash = await crypto.signWithHmacSha256(key, message); + assert.deepStrictEqual(calculatedHash, extectedHash.buffer); + }); + + it('using an ArrayBuffer key', async () => { + const message = 'The quick brown fox jumps over the lazy dog'; + const key = stringToArrayBuffer('key'); + const expectedHexHash = + 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; + const extectedHash = new Uint8Array( + (expectedHexHash.match(/.{1,2}/g) as string[]).map(byte => + parseInt(byte, 16) + ) + ); + + const calculatedHash = await crypto.signWithHmacSha256(key, message); + assert.deepStrictEqual(calculatedHash, extectedHash.buffer); + }); + }); + + it('should expose a method to convert an ArrayBuffer to hex', () => { + const arrayBuffer = stringToArrayBuffer('Hello World!'); + const expectedHexEncoding = '48656c6c6f20576f726c6421'; + + const calculatedHexEncoding = fromArrayBufferToHex(arrayBuffer); + assert.strictEqual(calculatedHexEncoding, expectedHexEncoding); + }); }); diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index 9719212c..046863f5 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -545,7 +545,7 @@ export class OAuth2Client extends AuthClient { } /** - * Convenience method to automatically generate a code_verifier, and it's + * Convenience method to automatically generate a code_verifier, and its * resulting SHA256. If used, this must be paired with a S256 * code_challenge_method. * diff --git a/src/crypto/browser/crypto.ts b/src/crypto/browser/crypto.ts index 5e1665f0..feba104a 100644 --- a/src/crypto/browser/crypto.ts +++ b/src/crypto/browser/crypto.ts @@ -26,7 +26,7 @@ if (typeof process === 'undefined' && typeof TextEncoder === 'undefined') { require('fast-text-encoding'); } -import {Crypto, JwkCertificate} from '../crypto'; +import {Crypto, JwkCertificate, fromArrayBufferToHex} from '../crypto'; export class BrowserCrypto implements Crypto { constructor() { @@ -140,4 +140,63 @@ export class BrowserCrypto implements Crypto { const result = base64js.fromByteArray(uint8array); return result; } + + /** + * Computes the SHA-256 hash of the provided string. + * @param str The plain text string to hash. + * @return A promise that resolves with the SHA-256 hash of the provided + * string in hexadecimal encoding. + */ + async sha256DigestHex(str: string): Promise { + // SubtleCrypto digest() method is async, so we must make + // this method async as well. + + // To calculate SHA256 digest using SubtleCrypto, we first + // need to convert an input string to an ArrayBuffer: + // eslint-disable-next-line node/no-unsupported-features/node-builtins + const inputBuffer = new TextEncoder().encode(str); + + // Result is ArrayBuffer as well. + const outputBuffer = await window.crypto.subtle.digest( + 'SHA-256', + inputBuffer + ); + + return fromArrayBufferToHex(outputBuffer); + } + + /** + * Computes the HMAC hash of a message using the provided crypto key and the + * SHA-256 algorithm. + * @param key The secret crypto key in utf-8 or ArrayBuffer format. + * @param msg The plain text message. + * @return A promise that resolves with the HMAC-SHA256 hash in ArrayBuffer + * format. + */ + async signWithHmacSha256( + key: string | ArrayBuffer, + msg: string + ): Promise { + // Convert key, if provided in ArrayBuffer format, to string. + const rawKey = + typeof key === 'string' + ? key + : String.fromCharCode(...new Uint16Array(key)); + + // eslint-disable-next-line node/no-unsupported-features/node-builtins + const enc = new TextEncoder(); + const cryptoKey = await window.crypto.subtle.importKey( + 'raw', + enc.encode(rawKey), + { + name: 'HMAC', + hash: { + name: 'SHA-256', + }, + }, + false, + ['sign'] + ); + return window.crypto.subtle.sign('HMAC', cryptoKey, enc.encode(msg)); + } } diff --git a/src/crypto/crypto.ts b/src/crypto/crypto.ts index 27ce5a9d..42fe5da3 100644 --- a/src/crypto/crypto.ts +++ b/src/crypto/crypto.ts @@ -51,6 +51,26 @@ export interface Crypto { ): Promise; decodeBase64StringUtf8(base64: string): string; encodeBase64StringUtf8(text: string): string; + /** + * Computes the SHA-256 hash of the provided string. + * @param str The plain text string to hash. + * @return A promise that resolves with the SHA-256 hash of the provided + * string in hexadecimal encoding. + */ + sha256DigestHex(str: string): Promise; + + /** + * Computes the HMAC hash of a message using the provided crypto key and the + * SHA-256 algorithm. + * @param key The secret crypto key in utf-8 or ArrayBuffer format. + * @param msg The plain text message. + * @return A promise that resolves with the HMAC-SHA256 hash in ArrayBuffer + * format. + */ + signWithHmacSha256( + key: string | ArrayBuffer, + msg: string + ): Promise; } export function createCrypto(): Crypto { @@ -67,3 +87,19 @@ export function hasBrowserCrypto() { typeof window.crypto.subtle !== 'undefined' ); } + +/** + * Converts an ArrayBuffer to a hexadecimal string. + * @param arrayBuffer The ArrayBuffer to convert to hexadecimal string. + * @return The hexadecimal encoding of the ArrayBuffer. + */ +export function fromArrayBufferToHex(arrayBuffer: ArrayBuffer): string { + // Convert buffer to byte array. + const byteArray = Array.from(new Uint8Array(arrayBuffer)); + // Convert bytes to hex string. + return byteArray + .map(byte => { + return byte === 0 ? '' : byte.toString(16).padStart(2, '0'); + }) + .join(''); +} diff --git a/src/crypto/node/crypto.ts b/src/crypto/node/crypto.ts index 58b60671..be08a71e 100644 --- a/src/crypto/node/crypto.ts +++ b/src/crypto/node/crypto.ts @@ -49,4 +49,61 @@ export class NodeCrypto implements Crypto { encodeBase64StringUtf8(text: string): string { return Buffer.from(text, 'utf-8').toString('base64'); } + + /** + * Computes the SHA-256 hash of the provided string. + * @param str The plain text string to hash. + * @return A promise that resolves with the SHA-256 hash of the provided + * string in hexadecimal encoding. + */ + async sha256DigestHex(str: string): Promise { + return crypto.createHash('sha256').update(str).digest('hex'); + } + + /** + * Computes the HMAC hash of a message using the provided crypto key and the + * SHA-256 algorithm. + * @param key The secret crypto key in utf-8 or ArrayBuffer format. + * @param msg The plain text message. + * @return A promise that resolves with the HMAC-SHA256 hash in ArrayBuffer + * format. + */ + async signWithHmacSha256( + key: string | ArrayBuffer, + msg: string + ): Promise { + const cryptoKey = typeof key === 'string' ? key : toBuffer(key); + return toArrayBuffer( + crypto.createHmac('sha256', cryptoKey).update(msg).digest() + ); + } +} + +/** + * Converts a Node.js Buffer to an ArrayBuffer. + * https://stackoverflow.com/questions/8609289/convert-a-binary-nodejs-buffer-to-javascript-arraybuffer + * @param buffer The Buffer input to covert. + * @return The ArrayBuffer representation of the input. + */ +function toArrayBuffer(buffer: Buffer): ArrayBuffer { + const arrayBuffer = new ArrayBuffer(buffer.length); + const arrayBufferView = new Uint8Array(arrayBuffer); + for (let i = 0; i < buffer.length; i++) { + arrayBufferView[i] = buffer[i]; + } + return arrayBuffer; +} + +/** + * Converts an ArrayBuffer to a Node.js Buffer. + * @param arrayBuffer The ArrayBuffer input to covert. + * @return The Buffer representation of the input. + */ +function toBuffer(arrayBuffer: ArrayBuffer): Buffer { + const buf = Buffer.alloc(arrayBuffer.byteLength); + const view = new Uint8Array(arrayBuffer); + for (let i = 0; i < buf.length; ++i) { + buf[i] = view[i]; + } + return buf; } diff --git a/synth.metadata b/synth.metadata index 0d36d211..9107ca41 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,15 +4,66 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", - "sha": "4830a5308d780822e884b0fb98fa605d3e7dc77b" + "sha": "0c8e086f3cad23efefb57418f1eeccba6674aaac" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "5747555f7620113d9a2078a48f4c047a99d31b3e" + "sha": "05de3e1e14a0b07eab8b474e669164dbd31f81fb" } } + ], + "generatedFiles": [ + ".eslintignore", + ".eslintrc.json", + ".gitattributes", + ".github/ISSUE_TEMPLATE/bug_report.md", + ".github/ISSUE_TEMPLATE/feature_request.md", + ".github/ISSUE_TEMPLATE/support_request.md", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/publish.yml", + ".github/release-please.yml", + ".github/workflows/ci.yaml", + ".kokoro/.gitattributes", + ".kokoro/common.cfg", + ".kokoro/continuous/node10/common.cfg", + ".kokoro/continuous/node10/docs.cfg", + ".kokoro/continuous/node10/test.cfg", + ".kokoro/continuous/node12/common.cfg", + ".kokoro/continuous/node12/lint.cfg", + ".kokoro/continuous/node12/samples-test.cfg", + ".kokoro/continuous/node12/system-test.cfg", + ".kokoro/continuous/node12/test.cfg", + ".kokoro/docs.sh", + ".kokoro/lint.sh", + ".kokoro/populate-secrets.sh", + ".kokoro/presubmit/node10/common.cfg", + ".kokoro/presubmit/node12/common.cfg", + ".kokoro/presubmit/node12/samples-test.cfg", + ".kokoro/presubmit/node12/system-test.cfg", + ".kokoro/presubmit/node12/test.cfg", + ".kokoro/publish.sh", + ".kokoro/release/docs-devsite.cfg", + ".kokoro/release/docs-devsite.sh", + ".kokoro/release/docs.cfg", + ".kokoro/release/docs.sh", + ".kokoro/release/publish.cfg", + ".kokoro/samples-test.sh", + ".kokoro/system-test.sh", + ".kokoro/test.bat", + ".kokoro/test.sh", + ".kokoro/trampoline.sh", + ".mocharc.js", + ".nycrc", + ".prettierignore", + ".prettierrc.js", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "LICENSE", + "api-extractor.json", + "renovate.json", + "samples/README.md" ] } \ No newline at end of file diff --git a/test/test.crypto.ts b/test/test.crypto.ts index f4faf670..bed2826b 100644 --- a/test/test.crypto.ts +++ b/test/test.crypto.ts @@ -1,12 +1,41 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import * as fs from 'fs'; import {assert} from 'chai'; import {describe, it} from 'mocha'; -import {createCrypto} from '../src/crypto/crypto'; +import {createCrypto, fromArrayBufferToHex} from '../src/crypto/crypto'; import {NodeCrypto} from '../src/crypto/node/crypto'; const publicKey = fs.readFileSync('./test/fixtures/public.pem', 'utf-8'); const privateKey = fs.readFileSync('./test/fixtures/private.pem', 'utf-8'); +/** + * Converts a Node.js Buffer to an ArrayBuffer. + * https://stackoverflow.com/questions/8609289/convert-a-binary-nodejs-buffer-to-javascript-arraybuffer + * @param buffer The Buffer input to covert. + * @return The ArrayBuffer representation of the input. + */ +function toArrayBuffer(buffer: Buffer): ArrayBuffer { + const arrayBuffer = new ArrayBuffer(buffer.length); + const arrayBufferView = new Uint8Array(arrayBuffer); + for (let i = 0; i < buffer.length; i++) { + arrayBufferView[i] = buffer[i]; + } + return arrayBuffer; +} + describe('crypto', () => { const crypto = createCrypto(); @@ -80,4 +109,53 @@ describe('crypto', () => { const hits = loadedModules.filter(x => x.includes('fast-text-encoding')); assert.strictEqual(hits.length, 0); }); + + it('should calculate SHA256 digest in hex encoding', async () => { + const input = 'I can calculate SHA256'; + const expectedHexDigest = + '73d08486d8bfd4fb4bc12dd8903604ddbde5ad95b6efa567bd723ce81a881122'; + + const calculatedHexDigest = await crypto.sha256DigestHex(input); + assert.strictEqual(calculatedHexDigest, expectedHexDigest); + }); + + describe('should compute the HMAC-SHA256 hash of a message', () => { + it('using string key', async () => { + const message = 'The quick brown fox jumps over the lazy dog'; + const key = 'key'; + const expectedHexHash = + 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; + const extectedHash = new Uint8Array( + (expectedHexHash.match(/.{1,2}/g) as string[]).map(byte => + parseInt(byte, 16) + ) + ); + + const calculatedHash = await crypto.signWithHmacSha256(key, message); + assert.deepStrictEqual(calculatedHash, extectedHash.buffer); + }); + + it('using an ArrayBuffer key', async () => { + const message = 'The quick brown fox jumps over the lazy dog'; + const key = toArrayBuffer(Buffer.from('key')); + const expectedHexHash = + 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; + const extectedHash = new Uint8Array( + (expectedHexHash.match(/.{1,2}/g) as string[]).map(byte => + parseInt(byte, 16) + ) + ); + + const calculatedHash = await crypto.signWithHmacSha256(key, message); + assert.deepStrictEqual(calculatedHash, extectedHash.buffer); + }); + }); + + it('should expose a method to convert an ArrayBuffer to hex', () => { + const arrayBuffer = toArrayBuffer(Buffer.from('Hello World!')); + const expectedHexEncoding = '48656c6c6f20576f726c6421'; + + const calculatedHexEncoding = fromArrayBufferToHex(arrayBuffer); + assert.strictEqual(calculatedHexEncoding, expectedHexEncoding); + }); });