From 728c7fbbf5972d961f597d8d5bc753191289f560 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Wed, 11 May 2022 20:38:08 +0100 Subject: [PATCH] feat: Implement private key store backed by GCP KMS (#1) https://cloud.google.com/security-key-management Fixes https://github.com/relaycorp/relayverse/issues/37 TODO - [x] Session keys: Use CRC32C to verify integrity: https://cloud.google.com/kms/docs/data-integrity-guidelines#calculating_and_verifying_checksums - [x] Identity keys: Use CRC32C: https://cloud.google.com/kms/docs/data-integrity-guidelines - [x] Verify the `.name` of the resource in each response: https://cloud.google.com/kms/docs/data-integrity-guidelines#verifying_resource_names - [x] Wrap API call errors, since their stack traces are utterly useless to infer context. - [x] Enable timeouts in all API calls, especially Datastore. - [x] Enable retries in all API calls. - [x] Remove code to automagically pick unlinked version (first run) - [x] Write functional tests against actual GCP APIs. --- .npmignore | 3 +- docs/gcp.md | 5 + jest.config.js | 2 +- package-lock.json | 534 +++++++++- package.json | 7 +- src/functional_tests/gcp.test.ts | 149 +++ src/functional_tests/gcpUtils.ts | 22 + src/functional_tests/jest.config.js | 8 + src/functional_tests/utils.ts | 4 + src/index.ts | 1 + src/lib/foo.spec.ts | 5 - src/lib/foo.ts | 1 - src/lib/gcp/DatastoreKinds.ts | 4 + src/lib/gcp/GCPKeystoreError.ts | 3 + src/lib/gcp/GCPPrivateKeyStore.spec.ts | 1031 ++++++++++++++++++++ src/lib/gcp/GCPPrivateKeyStore.ts | 248 +++++ src/lib/gcp/GcpKmsRsaPssPrivateKey.spec.ts | 33 + src/lib/gcp/GcpKmsRsaPssPrivateKey.ts | 11 + src/lib/gcp/GcpKmsRsaPssProvider.spec.ts | 269 +++++ src/lib/gcp/GcpKmsRsaPssProvider.ts | 87 ++ src/lib/gcp/datastoreEntities.ts | 16 + src/lib/gcp/gcpUtils.spec.ts | 41 + src/lib/gcp/gcpUtils.ts | 20 + src/lib/gcp/kmsUtils.spec.ts | 126 +++ src/lib/gcp/kmsUtils.ts | 56 ++ src/lib/utils/buffer.ts | 3 + src/lib/utils/timing.spec.ts | 25 + src/lib/utils/timing.ts | 3 + src/testUtils/asn1.ts | 4 + src/testUtils/jest.ts | 37 + src/testUtils/promises.ts | 12 + src/testUtils/timing.ts | 6 + src/types/fast-crc32c.d.ts | 3 + 33 files changed, 2751 insertions(+), 28 deletions(-) create mode 100644 docs/gcp.md create mode 100644 src/functional_tests/gcp.test.ts create mode 100644 src/functional_tests/gcpUtils.ts create mode 100644 src/functional_tests/jest.config.js create mode 100644 src/functional_tests/utils.ts delete mode 100644 src/lib/foo.spec.ts delete mode 100644 src/lib/foo.ts create mode 100644 src/lib/gcp/DatastoreKinds.ts create mode 100644 src/lib/gcp/GCPKeystoreError.ts create mode 100644 src/lib/gcp/GCPPrivateKeyStore.spec.ts create mode 100644 src/lib/gcp/GCPPrivateKeyStore.ts create mode 100644 src/lib/gcp/GcpKmsRsaPssPrivateKey.spec.ts create mode 100644 src/lib/gcp/GcpKmsRsaPssPrivateKey.ts create mode 100644 src/lib/gcp/GcpKmsRsaPssProvider.spec.ts create mode 100644 src/lib/gcp/GcpKmsRsaPssProvider.ts create mode 100644 src/lib/gcp/datastoreEntities.ts create mode 100644 src/lib/gcp/gcpUtils.spec.ts create mode 100644 src/lib/gcp/gcpUtils.ts create mode 100644 src/lib/gcp/kmsUtils.spec.ts create mode 100644 src/lib/gcp/kmsUtils.ts create mode 100644 src/lib/utils/buffer.ts create mode 100644 src/lib/utils/timing.spec.ts create mode 100644 src/lib/utils/timing.ts create mode 100644 src/testUtils/asn1.ts create mode 100644 src/testUtils/jest.ts create mode 100644 src/testUtils/promises.ts create mode 100644 src/testUtils/timing.ts create mode 100644 src/types/fast-crc32c.d.ts diff --git a/.npmignore b/.npmignore index 4099c701..296a1858 100644 --- a/.npmignore +++ b/.npmignore @@ -1,8 +1,9 @@ * !build/main/** +build/main/testUtils !build/module/** -_test_utils.* +build/modules/testUtils *.spec.js* !LICENSE diff --git a/docs/gcp.md b/docs/gcp.md new file mode 100644 index 00000000..8628a78a --- /dev/null +++ b/docs/gcp.md @@ -0,0 +1,5 @@ +# GCP-powered Awala key stores + +## Limitations + +- All the GCP resources must be located in the same GCP project and region. diff --git a/jest.config.js b/jest.config.js index 678d8bdb..95ed3611 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,7 +29,7 @@ module.exports = { // An array of regexp pattern strings used to skip coverage collection coveragePathIgnorePatterns: [ - "_test_utils\.[tj]s", + "/testUtils", ], // A list of reporter names that Jest uses when writing coverage reports diff --git a/package-lock.json b/package-lock.json index 44a8a7d7..b7937cef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -321,6 +321,62 @@ "@cspotcode/source-map-consumer": "0.8.0" } }, + "@google-cloud/datastore": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/@google-cloud/datastore/-/datastore-6.6.2.tgz", + "integrity": "sha512-gQxSusM1gREtUogVqtl/KuoFrstYns8ZxY3guso2Mg2eJ+ygJwWdxXmG23T+aSbzkofh2OF3Mz0p3a+0F9KoPg==", + "requires": { + "@google-cloud/promisify": "^2.0.0", + "arrify": "^2.0.1", + "concat-stream": "^2.0.0", + "extend": "^3.0.2", + "google-gax": "^2.24.1", + "is": "^3.3.0", + "split-array-stream": "^2.0.0", + "stream-events": "^1.0.5" + }, + "dependencies": { + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" + } + } + }, + "@google-cloud/kms": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@google-cloud/kms/-/kms-2.11.1.tgz", + "integrity": "sha512-rmRQ9MVlEeKySlTP4QAwP9CY5UZqQ1Mpdeqe0N3FW+2HjJ2uH0oOSawv7UKa8+nQEx6R4SJI2u0A9Le5Wrd72A==", + "requires": { + "google-gax": "^2.24.1" + } + }, + "@google-cloud/promisify": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-2.0.4.tgz", + "integrity": "sha512-j8yRSSqswWi1QqUGKVEKOG03Q7qOoZP6/h2zN2YO+F5h2+DHU0bSrHCK9Y7lo2DI9fBd8qGAw795sf+3Jva4yA==" + }, + "@grpc/grpc-js": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.6.7.tgz", + "integrity": "sha512-eBM03pu9hd3VqDQG+kHahiG1x80RGkkqqRb1Pchcwqej/KkAH95gAvKs6laqaHCycYaPK+TKuNQnOz9UXYA8qw==", + "requires": { + "@grpc/proto-loader": "^0.6.4", + "@types/node": ">=12.12.47" + } + }, + "@grpc/proto-loader": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.6.9.tgz", + "integrity": "sha512-UlcCS8VbsU9d3XTXGiEVFonN7hXk+oMXZtoHHG2oSA1/GcDP1q6OUgs20PzHDGizzyi8ufGSUDlk3O2NyY7leg==", + "requires": { + "@types/long": "^4.0.1", + "lodash.camelcase": "^4.3.0", + "long": "^4.0.0", + "protobufjs": "^6.10.0", + "yargs": "^16.2.0" + } + }, "@jest/console": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/@jest/console/-/console-24.9.0.tgz", @@ -851,6 +907,60 @@ "webcrypto-core": "^1.7.2" } }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A=" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=" + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=" + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" + }, "@relaycorp/relaynet-core": { "version": "1.77.1", "resolved": "https://registry.npmjs.org/@relaycorp/relaynet-core/-/relaynet-core-1.77.1.tgz", @@ -1038,6 +1148,11 @@ "pretty-format": "^27.0.0" } }, + "@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" + }, "@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -1047,8 +1162,7 @@ "@types/node": { "version": "17.0.29", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.29.tgz", - "integrity": "sha512-tx5jMmMFwx7wBwq/V7OohKDVb/JwJU5qCVkeLMh1//xycAJ/ESuw9aJ9SEtlCZDYi2pBfe4JkisSoAtbOsBNAA==", - "dev": true + "integrity": "sha512-tx5jMmMFwx7wBwq/V7OohKDVb/JwJU5qCVkeLMh1//xycAJ/ESuw9aJ9SEtlCZDYi2pBfe4JkisSoAtbOsBNAA==" }, "@types/normalize-package-data": { "version": "2.4.1", @@ -1088,6 +1202,14 @@ "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "dev": true }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "acorn": { "version": "5.7.4", "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", @@ -1118,6 +1240,29 @@ "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", "dev": true }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -1555,6 +1700,11 @@ } } }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -1564,11 +1714,15 @@ "tweetnacl": "^0.14.3" } }, + "bignumber.js": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.2.tgz", + "integrity": "sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw==" + }, "bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, "optional": true, "requires": { "file-uri-to-path": "1.0.0" @@ -1647,11 +1801,15 @@ "node-int64": "^0.4.0" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "buffer-to-arraybuffer": { "version": "0.0.6", @@ -1879,6 +2037,17 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "convert-source-map": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", @@ -2184,6 +2353,17 @@ } } }, + "duplexify": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", + "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", + "requires": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + } + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -2194,6 +2374,14 @@ "safer-buffer": "^2.1.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "electron-to-chromium": { "version": "1.4.123", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.123.tgz", @@ -2209,7 +2397,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, "requires": { "once": "^1.4.0" } @@ -2304,6 +2491,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, "exec-sh": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz", @@ -2469,8 +2661,7 @@ "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "extend-shallow": { "version": "3.0.2", @@ -2563,6 +2754,14 @@ "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==" }, + "fast-crc32c": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-crc32c/-/fast-crc32c-2.0.0.tgz", + "integrity": "sha512-LIREwygxtxzHF11oLJ4xIVKu/ZWNgrj/QaGvaSD8ZggIsgCyCtSYevlrpWVqNau57ZwezV8K1HFBSjQ7FcRbTQ==", + "requires": { + "sse4_crc32": "^6.0.1" + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2594,6 +2793,11 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "fast-text-encoding": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz", + "integrity": "sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig==" + }, "fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -2616,7 +2820,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, "optional": true }, "fill-range": { @@ -2702,6 +2905,34 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "gaxios": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-4.3.3.tgz", + "integrity": "sha512-gSaYYIO1Y3wUtdfHmjDUZ8LWaxJQpiavzbF5Kq53akSzvmVg0RfyOcFDbO1KJ/KCGRFz2qG+lS81F0nkr7cRJA==", + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.7" + }, + "dependencies": { + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + } + } + }, + "gcp-metadata": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.3.1.tgz", + "integrity": "sha512-x850LS5N7V1F3UcV7PoupzGsyD6iVwTVvsh3tbXfkctZnBnjW5yu5z1/3k3SehF7TyoTIe78rJs02GMMy+LF+A==", + "requires": { + "gaxios": "^4.0.0", + "json-bigint": "^1.0.0" + } + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2801,6 +3032,57 @@ "slash": "^3.0.0" } }, + "google-auth-library": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.14.1.tgz", + "integrity": "sha512-5Rk7iLNDFhFeBYc3s8l1CqzbEBcdhwR193RlD4vSNFajIcINKI8W8P0JLmBpwymHqqWbX34pJDQu39cSy/6RsA==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^4.0.0", + "gcp-metadata": "^4.2.0", + "gtoken": "^5.0.4", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + }, + "dependencies": { + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" + } + } + }, + "google-gax": { + "version": "2.30.2", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-2.30.2.tgz", + "integrity": "sha512-BCNCT26kb0iC52zj2SosyOZMhI5sVfXuul1h0Aw5uT9nGAbmS5eOvQ49ft53ft6XotDj11sUSDV6XESEiQqCqg==", + "requires": { + "@grpc/grpc-js": "~1.6.0", + "@grpc/proto-loader": "^0.6.1", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "fast-text-encoding": "^1.0.3", + "google-auth-library": "^7.14.0", + "is-stream-ended": "^0.1.4", + "node-fetch": "^2.6.1", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^0.1.8", + "protobufjs": "6.11.2", + "retry-request": "^4.0.0" + } + }, + "google-p12-pem": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.1.4.tgz", + "integrity": "sha512-HHuHmkLgwjdmVRngf5+gSmpkyaRI6QmOg77J8tkNBHhNEI62sGHyw4/+UkgyZEI7h84NbWprXDJ+sa3xOYFvTg==", + "requires": { + "node-forge": "^1.3.1" + } + }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -2813,6 +3095,16 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "dev": true }, + "gtoken": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-5.3.2.tgz", + "integrity": "sha512-gkvEKREW7dXWF8NV8pVrKfW7WqReAmjjkMBh6lNCCGOM4ucS0r0YyXXl0r/9Yj8wcW/32ISkfc8h5mPTDbtifQ==", + "requires": { + "gaxios": "^4.0.0", + "google-p12-pem": "^3.1.3", + "jws": "^4.0.0" + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -2967,6 +3259,30 @@ "sshpk": "^1.7.0" } }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "requires": { + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -3017,8 +3333,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "internal-slot": { "version": "1.0.3", @@ -3040,6 +3355,11 @@ "loose-envify": "^1.0.0" } }, + "is": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/is/-/is-3.3.0.tgz", + "integrity": "sha512-nW24QBoPcFGGHJGUwnfpI7Yc5CdqWNdsyHQszVE/z2pKHXzh7FZ5GWhJqSyaQ9wMkQnsTx+kAI8bHlCX4tKdbg==" + }, "is-accessor-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", @@ -3268,6 +3588,11 @@ "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", "dev": true }, + "is-stream-ended": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-stream-ended/-/is-stream-ended-0.1.4.tgz", + "integrity": "sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==" + }, "is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -5273,6 +5598,14 @@ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, + "json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "requires": { + "bignumber.js": "^9.0.0" + } + }, "json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -5346,6 +5679,25 @@ } } }, + "jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -5425,6 +5777,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5437,6 +5794,11 @@ "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", "dev": true }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5450,7 +5812,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -5695,6 +6056,12 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "optional": true + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -5703,6 +6070,11 @@ "whatwg-url": "^5.0.0" } }, + "node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==" + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5898,6 +6270,11 @@ } } }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" + }, "object-inspect": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", @@ -5955,7 +6332,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, "requires": { "wrappy": "1" } @@ -6235,6 +6611,34 @@ "sisteransi": "^1.0.5" } }, + "proto3-json-serializer": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-0.1.8.tgz", + "integrity": "sha512-ACilkB6s1U1gWnl5jtICpnDai4VCxmI9GFxuEaYdxtDG2oVI3sVFIUsvUZcQbJgtPM6p+zqKbjTKQZp6Y4FpQw==", + "requires": { + "protobufjs": "^6.11.2" + } + }, + "protobufjs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.2.tgz", + "integrity": "sha512-4BQJoPooKJl2G9j3XftkIXjoC9C0Av2NOrWmbLWT1vH32GcSUHjM0Arra6UfTsVyfMAuFzaLucXn1sadxJydAw==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + } + }, "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -6316,6 +6720,16 @@ "type-fest": "^1.0.1" } }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, "realpath-native": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/realpath-native/-/realpath-native-1.1.0.tgz", @@ -6468,6 +6882,30 @@ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", "dev": true }, + "retry-request": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-4.2.2.tgz", + "integrity": "sha512-xA93uxUD/rogV7BV59agW/JHPGXeREMWiZc9jhcwY4YdZ7QOtC7qbomYg0n4wyk2lJhggjvKvhNX8wln/Aldhg==", + "requires": { + "debug": "^4.1.1", + "extend": "^3.0.2" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -6501,8 +6939,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "safe-regex": { "version": "1.1.0", @@ -6934,6 +7371,14 @@ "integrity": "sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==", "dev": true }, + "split-array-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/split-array-stream/-/split-array-stream-2.0.0.tgz", + "integrity": "sha512-hmMswlVY91WvGMxs0k8MRgq8zb2mSen4FmDNc5AFiTWtrBpdZN6nwD6kROVe4vNL+ywrvbCKsWVCnEd4riELIg==", + "requires": { + "is-stream-ended": "^0.1.4" + } + }, "split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -6949,6 +7394,16 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "dev": true }, + "sse4_crc32": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/sse4_crc32/-/sse4_crc32-6.0.1.tgz", + "integrity": "sha512-FUTYXpLroqytNKWIfHzlDWoy9E4tmBB/RklNMy6w3VJs+/XEYAHgbiylg4SS43iOk/9bM0BlJ2EDpFAGT66IoQ==", + "optional": true, + "requires": { + "bindings": "^1.3.0", + "node-addon-api": "^1.3.0" + } + }, "sshpk": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", @@ -7010,6 +7465,19 @@ "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", "dev": true }, + "stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "requires": { + "stubs": "^3.0.0" + } + }, + "stream-shift": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" + }, "string-length": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz", @@ -7078,6 +7546,21 @@ "define-properties": "^1.1.3" } }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, "strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7107,6 +7590,11 @@ "min-indent": "^1.0.1" } }, + "stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha1-6NK6H6nJBXAwPAMLaQD31fiavls=" + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -7479,6 +7967,11 @@ "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, "typedoc": { "version": "0.22.15", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.22.15.tgz", @@ -7603,6 +8096,11 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, "util.promisify": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.1.1.tgz", @@ -7771,8 +8269,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write-file-atomic": { "version": "2.4.1", @@ -7808,8 +8305,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yargs": { "version": "16.2.0", diff --git a/package.json b/package.json index e4ee3a15..b8ffcf96 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "test:ci:unit": "run-s build test:ci:unit:jest", "test:ci:unit:jest": "jest --config jest.config.ci.js --coverage", "test:integration": "exit 0", + "test:integration:local": "jest --config src/functional_tests/jest.config.js --runInBand --detectOpenHandles", "doc-api": "typedoc src/index.ts --out build/docs", "clean": "del-cli build test" }, @@ -39,7 +40,11 @@ "node": ">=12" }, "dependencies": { - "@relaycorp/relaynet-core": "^1.77.1" + "@google-cloud/datastore": "^6.6.2", + "@google-cloud/kms": "^2.11.1", + "@relaycorp/relaynet-core": "^1.77.1", + "fast-crc32c": "^2.0.0", + "webcrypto-core": "^1.7.3" }, "peerDependencies": { "@relaycorp/relaynet-core": "< 2.0" diff --git a/src/functional_tests/gcp.test.ts b/src/functional_tests/gcp.test.ts new file mode 100644 index 00000000..a90433ca --- /dev/null +++ b/src/functional_tests/gcp.test.ts @@ -0,0 +1,149 @@ +import { Datastore } from '@google-cloud/datastore'; +import { KeyManagementServiceClient } from '@google-cloud/kms'; +import { + derSerializePrivateKey, + derSerializePublicKey, + SessionKeyPair, +} from '@relaycorp/relaynet-core'; + +import { constants, createVerify } from 'crypto'; +import { DatastoreKinds } from '../lib/gcp/DatastoreKinds'; +import { GcpKmsRsaPssPrivateKey } from '../lib/gcp/GcpKmsRsaPssPrivateKey'; +import { GcpKmsRsaPssProvider } from '../lib/gcp/GcpKmsRsaPssProvider'; +import { GCPPrivateKeyStore, KMSConfig } from '../lib/gcp/GCPPrivateKeyStore'; +import { derPublicKeyToPem } from '../testUtils/asn1'; +import { createKeyRingIfMissing } from './gcpUtils'; +import { TEST_RUN_ID } from './utils'; + +if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { + throw new Error('GOOGLE_APPLICATION_CREDENTIALS must be defined'); +} +const GCP_LOCATION = process.env.GCP_LOCATION ?? 'europe-west3'; + +const KMS_KEY_RING = 'keystore-cloud-tests'; +const kmsClient = new KeyManagementServiceClient(); +let identityKeyName: string; +let sessionEncryptionKeyName: string; +beforeAll(async () => { + const keyIdPrefix = `keystore-cloud-tests-${TEST_RUN_ID}`; + const keyRingName = await createKeyRingIfMissing(KMS_KEY_RING, kmsClient, GCP_LOCATION); + const destroyScheduledDuration = { seconds: 86400 }; // It should be at least a day :( + + const [createIdKeyResponse] = await kmsClient.createCryptoKey({ + cryptoKey: { + destroyScheduledDuration, + purpose: 'ASYMMETRIC_SIGN', + versionTemplate: { algorithm: 'RSA_SIGN_PSS_2048_SHA256', protectionLevel: 'SOFTWARE' }, + }, + cryptoKeyId: `${keyIdPrefix}-id`, + parent: keyRingName, + skipInitialVersionCreation: true, + }); + identityKeyName = createIdKeyResponse.name!; // Only set once key is actually created + + const [createSessionEncryptionKeyResponse] = await kmsClient.createCryptoKey({ + cryptoKey: { + destroyScheduledDuration, + purpose: 'ENCRYPT_DECRYPT', + versionTemplate: { algorithm: 'GOOGLE_SYMMETRIC_ENCRYPTION', protectionLevel: 'SOFTWARE' }, + }, + cryptoKeyId: `${keyIdPrefix}-session`, + parent: keyRingName, + skipInitialVersionCreation: false, + }); + sessionEncryptionKeyName = createSessionEncryptionKeyResponse.name!; // Only set once key is actually created +}); +afterAll(async () => { + if (identityKeyName) { + await deleteAllKMSKeyVersions(identityKeyName); + } + if (sessionEncryptionKeyName) { + await deleteAllKMSKeyVersions(sessionEncryptionKeyName); + } + await kmsClient.close(); +}); + +const datastoreClient = new Datastore({ namespace: `keystores-${TEST_RUN_ID}` }); +afterAll(async () => { + await emptyDatastoreKind(DatastoreKinds.IDENTITY_KEYS); + await emptyDatastoreKind(DatastoreKinds.SESSION_KEYS); +}); + +describe('Private key store', () => { + test('Generate identity key pair', async () => { + const store = new GCPPrivateKeyStore(kmsClient, datastoreClient, getKMSConfig()); + + const { privateKey, privateAddress } = await store.generateIdentityKeyPair(); + + const privateKeyRetrieved = await store.retrieveIdentityKey(privateAddress); + + expect(privateKeyRetrieved?.kmsKeyVersionPath).toEqual( + (privateKey as GcpKmsRsaPssPrivateKey).kmsKeyVersionPath, + ); + }); + + test('Save and retrieve session key', async () => { + const store = new GCPPrivateKeyStore(kmsClient, datastoreClient, getKMSConfig()); + const { privateKey, sessionKey } = await SessionKeyPair.generate(); + + await store.saveUnboundSessionKey(privateKey, sessionKey.keyId); + + const privateKeyRetrieved = await store.retrieveSessionKey(sessionKey.keyId, '0deadbeef'); + await expect(derSerializePrivateKey(privateKeyRetrieved)).resolves.toEqual( + await derSerializePrivateKey(privateKey), + ); + }); +}); + +describe('WebCrypto provider', () => { + test('Sign with identity key', async () => { + const store = new GCPPrivateKeyStore(kmsClient, datastoreClient, getKMSConfig()); + const provider = new GcpKmsRsaPssProvider(kmsClient); + const { privateKey, publicKey } = await store.generateIdentityKeyPair(); + const plaintext = Buffer.from('this is the plaintext'); + + const signature = await provider.sign( + { name: 'RSA-PSS', saltLength: 32 } as any, + privateKey, + plaintext, + ); + + await expect(verifyAsymmetricSignature(publicKey, signature, plaintext)).resolves.toBeTrue(); + }); + + async function verifyAsymmetricSignature( + publicKey: CryptoKey, + signature: ArrayBuffer, + plaintext: Buffer, + ): Promise { + const verify = createVerify('sha256'); + verify.update(plaintext); + verify.end(); + + const publicKeyDer = await derSerializePublicKey(publicKey); + return verify.verify( + { key: derPublicKeyToPem(publicKeyDer), padding: constants.RSA_PKCS1_PSS_PADDING }, + new Uint8Array(signature), + ); + } +}); + +function getKMSConfig(): KMSConfig { + const identityKeyId = kmsClient.matchCryptoKeyFromCryptoKeyName(identityKeyName) as string; + const sessionEncryptionKeyId = kmsClient.matchCryptoKeyFromCryptoKeyName( + sessionEncryptionKeyName, + ) as string; + return { identityKeyId, keyRing: KMS_KEY_RING, location: GCP_LOCATION, sessionEncryptionKeyId }; +} + +async function deleteAllKMSKeyVersions(kmsKeyName: string): Promise { + const [listResponse] = await kmsClient.listCryptoKeyVersions({ parent: kmsKeyName }); + await Promise.all(listResponse.map((k) => kmsClient.destroyCryptoKeyVersion({ name: k.name }))); +} + +async function emptyDatastoreKind(kind: DatastoreKinds): Promise { + const query = await datastoreClient.createQuery(kind).select('__key__'); + const [entities] = await datastoreClient.runQuery(query); + const entityKeys = entities.map((e) => e[Datastore.KEY]); + await datastoreClient.delete(entityKeys); +} diff --git a/src/functional_tests/gcpUtils.ts b/src/functional_tests/gcpUtils.ts new file mode 100644 index 00000000..d2f27d43 --- /dev/null +++ b/src/functional_tests/gcpUtils.ts @@ -0,0 +1,22 @@ +import { KeyManagementServiceClient } from '@google-cloud/kms'; + +export async function createKeyRingIfMissing( + keyRingId: string, + kmsClient: KeyManagementServiceClient, + location: string, +): Promise { + const project = await kmsClient.getProjectId(); + const keyRingName = kmsClient.keyRingPath(project, location, keyRingId); + try { + await kmsClient.getKeyRing({ name: keyRingName }); + } catch (err) { + if ((err as any).code !== 5) { + throw err; + } + + // Key ring was not found + const locationPath = kmsClient.locationPath(project, location); + await kmsClient.createKeyRing({ parent: locationPath, keyRingId }); + } + return keyRingName; +} diff --git a/src/functional_tests/jest.config.js b/src/functional_tests/jest.config.js new file mode 100644 index 00000000..a023a431 --- /dev/null +++ b/src/functional_tests/jest.config.js @@ -0,0 +1,8 @@ +const mainJestConfig = require('../../jest.config'); + +module.exports = { + preset: mainJestConfig.preset, + roots: ['.'], + testEnvironment: mainJestConfig.testEnvironment, + setupFilesAfterEnv: mainJestConfig.setupFilesAfterEnv +}; diff --git a/src/functional_tests/utils.ts b/src/functional_tests/utils.ts new file mode 100644 index 00000000..7143c0dd --- /dev/null +++ b/src/functional_tests/utils.ts @@ -0,0 +1,4 @@ +import { randomBytes } from 'crypto'; + +const todayString = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD +export const TEST_RUN_ID = todayString + '-' + randomBytes(4).toString('hex'); diff --git a/src/index.ts b/src/index.ts index e69de29b..b960a95d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1 @@ +export { GCPPrivateKeyStore } from './lib/gcp/GCPPrivateKeyStore'; diff --git a/src/lib/foo.spec.ts b/src/lib/foo.spec.ts deleted file mode 100644 index 0147ce57..00000000 --- a/src/lib/foo.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { FOO } from './foo'; - -test('Please pass', () => { - expect(FOO).toEqual('bar'); -}); diff --git a/src/lib/foo.ts b/src/lib/foo.ts deleted file mode 100644 index 91145db6..00000000 --- a/src/lib/foo.ts +++ /dev/null @@ -1 +0,0 @@ -export const FOO = 'bar'; diff --git a/src/lib/gcp/DatastoreKinds.ts b/src/lib/gcp/DatastoreKinds.ts new file mode 100644 index 00000000..a6228bc9 --- /dev/null +++ b/src/lib/gcp/DatastoreKinds.ts @@ -0,0 +1,4 @@ +export enum DatastoreKinds { + IDENTITY_KEYS = 'identity_keys', + SESSION_KEYS = 'session_keys', +} diff --git a/src/lib/gcp/GCPKeystoreError.ts b/src/lib/gcp/GCPKeystoreError.ts new file mode 100644 index 00000000..d01c1061 --- /dev/null +++ b/src/lib/gcp/GCPKeystoreError.ts @@ -0,0 +1,3 @@ +import { RelaynetError } from '@relaycorp/relaynet-core'; + +export class GCPKeystoreError extends RelaynetError {} diff --git a/src/lib/gcp/GCPPrivateKeyStore.spec.ts b/src/lib/gcp/GCPPrivateKeyStore.spec.ts new file mode 100644 index 00000000..2e9302e0 --- /dev/null +++ b/src/lib/gcp/GCPPrivateKeyStore.spec.ts @@ -0,0 +1,1031 @@ +// tslint:disable:max-classes-per-file + +import { Datastore } from '@google-cloud/datastore'; +import { KeyManagementServiceClient } from '@google-cloud/kms'; +import { + derSerializePrivateKey, + derSerializePublicKey, + generateRSAKeyPair, + getPrivateAddressFromIdentityKey, + PrivateKeyStoreError, + SessionKeyPair, + UnknownKeyError, +} from '@relaycorp/relaynet-core'; +import { calculate as calculateCRC32C } from 'fast-crc32c'; + +import { getMockInstance, mockSpy } from '../../testUtils/jest'; +import { catchPromiseRejection } from '../../testUtils/promises'; +import { bufferToArrayBuffer } from '../utils/buffer'; +import { IdentityKeyEntity, SessionKeyEntity } from './datastoreEntities'; +import { DatastoreKinds } from './DatastoreKinds'; +import { GCPKeystoreError } from './GCPKeystoreError'; +import { GcpKmsRsaPssPrivateKey } from './GcpKmsRsaPssPrivateKey'; +import { GCPPrivateKeyStore, KMSConfig } from './GCPPrivateKeyStore'; +import * as kmsUtils from './kmsUtils'; + +const GCP_PROJECT = 'the-project'; +const KMS_CONFIG: KMSConfig = { + identityKeyId: 'the-id-key', + keyRing: 'the-ring', + location: 'westeros-east1', + sessionEncryptionKeyId: 'the-session-key', +}; + +describe('Identity keys', () => { + let kmsIdentityKeyPath: string; + beforeAll(async () => { + const kmsClient = new KeyManagementServiceClient(); + kmsIdentityKeyPath = kmsClient.cryptoKeyPath( + GCP_PROJECT, + KMS_CONFIG.location, + KMS_CONFIG.keyRing, + KMS_CONFIG.identityKeyId, + ); + }); + + describe('generateIdentityKeyPair', () => { + let stubPublicKey: CryptoKey; + let stubPublicKeySerialized: ArrayBuffer; + let stubPrivateAddress: string; + beforeAll(async () => { + const keyPair = await generateRSAKeyPair(); + stubPublicKey = keyPair.publicKey; + stubPublicKeySerialized = bufferToArrayBuffer(await derSerializePublicKey(stubPublicKey)); + stubPrivateAddress = await getPrivateAddressFromIdentityKey(stubPublicKey); + }); + + const mockRetrieveKMSPublicKey = mockSpy( + jest.spyOn(kmsUtils, 'retrieveKMSPublicKey'), + () => stubPublicKeySerialized, + ); + + describe('Key validation', () => { + test('Key should use be a signing key with RSA-PSS algorithm', async () => { + const kmsClient = makeKmsClient({ cryptoKeyAlgorithm: 'RSA_DECRYPT_OAEP_2048_SHA256' }); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await expect(store.generateIdentityKeyPair()).rejects.toThrowWithMessage( + GCPKeystoreError, + `Key ${kmsIdentityKeyPath} is not an RSA-PSS key`, + ); + }); + + test('Key should use modulus 2048 if hashing algorithm is unspecified', async () => { + const kmsClient = makeKmsClient({ cryptoKeyAlgorithm: 'RSA_SIGN_PSS_4096_SHA256' }); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await expect(store.generateIdentityKeyPair()).rejects.toThrowWithMessage( + GCPKeystoreError, + `Key ${kmsIdentityKeyPath} does not use modulus 2048`, + ); + }); + + test('RSA modulus should match any explicitly set', async () => { + const kmsClient = makeKmsClient({ cryptoKeyAlgorithm: 'RSA_SIGN_PSS_2048_SHA256' }); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + const modulus = 3072; + + await expect(store.generateIdentityKeyPair({ modulus })).rejects.toThrowWithMessage( + GCPKeystoreError, + `Key ${kmsIdentityKeyPath} does not use modulus ${modulus}`, + ); + }); + + test('Key should use SHA-256 if hashing algorithm is unspecified', async () => { + const kmsClient = makeKmsClient({ cryptoKeyAlgorithm: 'RSA_SIGN_PSS_2048_SHA512' }); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await expect(store.generateIdentityKeyPair()).rejects.toThrowWithMessage( + GCPKeystoreError, + `Key ${kmsIdentityKeyPath} does not use SHA-256`, + ); + }); + + test('Hashing algorithm should match any explicitly set', async () => { + const kmsClient = makeKmsClient({ cryptoKeyAlgorithm: 'RSA_SIGN_PSS_2048_SHA256' }); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + const hashingAlgorithm = 'SHA-512'; + + await expect( + store.generateIdentityKeyPair({ hashingAlgorithm }), + ).rejects.toThrowWithMessage( + GCPKeystoreError, + `Key ${kmsIdentityKeyPath} does not use ${hashingAlgorithm}`, + ); + }); + }); + + describe('KMS key creation', () => { + test('Version should be created under the pre-set key and ring', async () => { + const kmsClient = makeKmsClient(); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await store.generateIdentityKeyPair(); + + expect(kmsClient.createCryptoKeyVersion).toHaveBeenCalledWith( + expect.objectContaining({ parent: kmsIdentityKeyPath }), + expect.anything(), + ); + }); + + test('Version creation call should time out after 500ms', async () => { + const kmsClient = makeKmsClient(); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await store.generateIdentityKeyPair(); + + expect(kmsClient.createCryptoKeyVersion).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 500 }), + ); + }); + + test('Error to create key version should be wrapped', async () => { + const callError = new Error('Cannot create key version'); + const kmsClient = makeKmsClient(); + jest.spyOn(kmsClient, 'createCryptoKeyVersion').mockImplementation(async () => { + throw callError; + }); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + const error = await catchPromiseRejection( + store.generateIdentityKeyPair(), + GCPKeystoreError, + ); + + expect(error.message).toStartWith('Failed to create key version'); + expect(error.cause()).toEqual(callError); + }); + }); + + describe('Datastore document', () => { + test('Document should be saved to identity keys collection', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKmsClient(), datastoreClient, KMS_CONFIG); + + await store.generateIdentityKeyPair(); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + key: expect.objectContaining({ kind: DatastoreKinds.IDENTITY_KEYS }), + }), + expect.anything(), + ); + }); + + test('Document name should be private address derived from key', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKmsClient(), datastoreClient, KMS_CONFIG); + + await store.generateIdentityKeyPair(); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + key: expect.objectContaining({ name: stubPrivateAddress }), + }), + expect.anything(), + ); + }); + + test('KMS key id should be stored but not indexed', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKmsClient(), datastoreClient, KMS_CONFIG); + + await store.generateIdentityKeyPair(); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining>({ + key: KMS_CONFIG.identityKeyId, + }), + excludeFromIndexes: expect.arrayContaining(['key']), + }), + expect.anything(), + ); + }); + + test('KMS key version id should be stored but not indexed', async () => { + const datastoreClient = makeDatastoreClient(); + const kmsKeyVersion = '42'; + const store = new GCPPrivateKeyStore( + makeKmsClient({ versionId: kmsKeyVersion }), + datastoreClient, + KMS_CONFIG, + ); + + await store.generateIdentityKeyPair(); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining>({ + version: kmsKeyVersion, + }), + excludeFromIndexes: expect.arrayContaining(['version']), + }), + expect.anything(), + ); + }); + + test('Document creation should time out after 500ms', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKmsClient(), datastoreClient, KMS_CONFIG); + + await store.generateIdentityKeyPair(); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 500 }), + ); + }); + + test('Error to create document should be wrapped', async () => { + const datastoreClient = makeDatastoreClient(); + const callError = new Error('I refuse to save it'); + getMockInstance(datastoreClient.save).mockRejectedValue(callError); + const store = new GCPPrivateKeyStore(makeKmsClient(), datastoreClient, KMS_CONFIG); + + const error = await catchPromiseRejection( + store.generateIdentityKeyPair(), + GCPKeystoreError, + ); + + expect(error.message).toStartWith('Failed to register identity key on Datastore'); + expect(error.cause()).toEqual(callError); + }); + }); + + describe('Output', () => { + test('Public key should match private key', async () => { + const kmsClient = makeKmsClient(); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + const { privateKey, publicKey } = await store.generateIdentityKeyPair(); + + expect(mockRetrieveKMSPublicKey).toHaveBeenCalledWith( + (privateKey as GcpKmsRsaPssPrivateKey).kmsKeyVersionPath, + kmsClient, + ); + await expect(derSerializePublicKey(publicKey)).resolves.toEqual( + Buffer.from(stubPublicKeySerialized), + ); + }); + + test('Private address should match public key', async () => { + const store = new GCPPrivateKeyStore(makeKmsClient(), makeDatastoreClient(), KMS_CONFIG); + + const { privateAddress } = await store.generateIdentityKeyPair(); + + expect(privateAddress).toEqual(stubPrivateAddress); + }); + }); + + function makeKmsClient({ + cryptoKeyAlgorithm = 'RSA_SIGN_PSS_2048_SHA256', + versionId = '1', + } = {}): KeyManagementServiceClient { + const kmsClient = new KeyManagementServiceClient(); + + jest.spyOn(kmsClient, 'getCryptoKey').mockImplementation(async (request) => { + expect(request.name).toEqual(kmsIdentityKeyPath); + return [{ versionTemplate: { algorithm: cryptoKeyAlgorithm } }]; + }); + + const versionName = kmsClient.cryptoKeyVersionPath( + GCP_PROJECT, + KMS_CONFIG.location, + KMS_CONFIG.keyRing, + KMS_CONFIG.identityKeyId, + versionId, + ); + jest + .spyOn(kmsClient, 'createCryptoKeyVersion') + .mockImplementation(() => [{ name: versionName }, undefined, undefined]); + + jest.spyOn(kmsClient, 'getProjectId').mockImplementation(() => GCP_PROJECT); + + return kmsClient; + } + + function makeDatastoreClient(): Datastore { + const datastore = new Datastore(); + jest.spyOn(datastore, 'save').mockImplementation(() => undefined); + return datastore; + } + }); + + describe('retrieveIdentityKey', () => { + test('Null should be returned if key is not found on Datastore', async () => { + const store = new GCPPrivateKeyStore( + makeKmsClientWithMockProject(), + makeDatastoreClient(null), + KMS_CONFIG, + ); + + await expect(store.retrieveIdentityKey('non-existing')).resolves.toBeNull(); + }); + + test('Datastore lookup error should be wrapped', async () => { + const datastoreError = new Error('the planets were not aligned'); + const store = new GCPPrivateKeyStore( + makeKmsClientWithMockProject(), + makeDatastoreClient(datastoreError), + KMS_CONFIG, + ); + const privateAddress = '0deadbeef'; + + const error = await catchPromiseRejection( + store.retrieveIdentityKey(privateAddress), + GCPKeystoreError, + ); + + expect(error.message).toStartWith(`Failed to look up KMS key version for ${privateAddress}`); + expect(error.cause()).toEqual(datastoreError); + }); + + test('Key should be returned if found', async () => { + const datastoreClient = makeDatastoreClient(); + const kmsClient = makeKmsClientWithMockProject(); + const store = new GCPPrivateKeyStore(kmsClient, datastoreClient, KMS_CONFIG); + const privateAddress = '0deadbeef'; + + const privateKey = await store.retrieveIdentityKey(privateAddress); + + const kmsKeyVersionPath = kmsClient.cryptoKeyVersionPath( + GCP_PROJECT, + KMS_CONFIG.location, + KMS_CONFIG.keyRing, + KMS_CONFIG.identityKeyId, + '1', + ); + expect(privateKey?.kmsKeyVersionPath).toEqual(kmsKeyVersionPath); + expect(datastoreClient.get).toHaveBeenCalledWith( + datastoreClient.key([DatastoreKinds.IDENTITY_KEYS, privateAddress]), + ); + }); + + test('Stored key name should override that of configuration', async () => { + const kmsKey = `not-${KMS_CONFIG.identityKeyId}`; + const kmsClient = makeKmsClientWithMockProject(); + const store = new GCPPrivateKeyStore( + kmsClient, + makeDatastoreClient({ + key: kmsKey, + version: '1', + }), + KMS_CONFIG, + ); + + const privateKey = await store.retrieveIdentityKey('0deadbeef'); + + expect( + kmsClient.matchCryptoKeyFromCryptoKeyVersionName(privateKey!.kmsKeyVersionPath), + ).toEqual(kmsKey); + }); + + function makeDatastoreClient( + existingIdKey: IdentityKeyEntity | Error | null = { + key: KMS_CONFIG.identityKeyId, + version: '1', + }, + ): Datastore { + const datastore = new Datastore(); + jest.spyOn(datastore, 'get').mockImplementation(() => { + if (existingIdKey instanceof Error) { + throw existingIdKey; + } + return [existingIdKey ?? undefined]; + }); + + return datastore; + } + }); + + describe('saveIdentityKey', () => { + test('Method should not be supported', async () => { + const store = new (class extends GCPPrivateKeyStore { + public async callSaveIdentityKey(): Promise { + await this.saveIdentityKey(); + } + })(null as any, null as any, KMS_CONFIG); + + await expect(store.callSaveIdentityKey()).rejects.toThrowWithMessage( + GCPKeystoreError, + 'Method is not supported', + ); + }); + }); +}); + +describe('Session keys', () => { + const peerPrivateAddress = '0deadbeef'; + + let sessionKeyPair: SessionKeyPair; + let kmsSessionKeyPath: string; + beforeAll(async () => { + sessionKeyPair = await SessionKeyPair.generate(); + + const kmsClient = new KeyManagementServiceClient(); + kmsSessionKeyPath = kmsClient.cryptoKeyPath( + GCP_PROJECT, + KMS_CONFIG.location, + KMS_CONFIG.keyRing, + KMS_CONFIG.sessionEncryptionKeyId, + ); + }); + + describe('saveSessionKeySerialized', () => { + test('Document should be saved to session keys collection', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + + await store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + key: expect.objectContaining({ kind: DatastoreKinds.SESSION_KEYS }), + }), + expect.anything(), + ); + }); + + test('Document name should be session key id', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + + await store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + key: expect.objectContaining({ name: sessionKeyPair.sessionKey.keyId.toString('hex') }), + }), + expect.anything(), + ); + }); + + test('Peer private address should be stored (but not indexed) if key is bound', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + + await store.saveBoundSessionKey( + sessionKeyPair.privateKey, + sessionKeyPair.sessionKey.keyId, + peerPrivateAddress, + ); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining>({ peerPrivateAddress }), + excludeFromIndexes: expect.arrayContaining([ + 'peerPrivateAddress', + ]), + }), + expect.anything(), + ); + }); + + test('Peer private address should not be stored if key is unbound', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + + await store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining>({ + peerPrivateAddress: undefined, + }), + }), + expect.anything(), + ); + }); + + test('Private key should be stored encrypted', async () => { + const privateKeyCiphertext = Buffer.from('encrypted real hard'); + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore( + makeKMSClient({ ciphertext: privateKeyCiphertext }), + datastoreClient, + KMS_CONFIG, + ); + + await store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining>({ privateKeyCiphertext }), + }), + expect.anything(), + ); + }); + + test('Private key field should not be indexed', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + + await store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + excludeFromIndexes: expect.arrayContaining([ + 'privateKeyCiphertext', + ]), + }), + expect.anything(), + ); + }); + + test('Creation date should be stored and indexed', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + const beforeDate = new Date(); + + await store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId); + + const afterDate = new Date(); + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining>({ + creationDate: expect.toSatisfy((date) => beforeDate <= date && date <= afterDate), + }), + excludeFromIndexes: expect.not.arrayContaining(['creationDate']), + }), + expect.anything(), + ); + }); + + test('Datastore call should time out after 500ms', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + + await store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId); + + expect(datastoreClient.save).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 500 }), + ); + }); + + test('Error to store Datastore document should be wrapped', async () => { + const callError = new Error('Sorry'); + const store = new GCPPrivateKeyStore( + makeKMSClient(), + makeDatastoreClient(callError), + KMS_CONFIG, + ); + + const error = await catchPromiseRejection( + store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId), + PrivateKeyStoreError, + ); + + expect(error.cause()?.message).toStartWith('Failed to store session key in Datastore'); + expect((error.cause() as GCPKeystoreError).cause()).toEqual(callError); + }); + + describe('KMS encryption', () => { + test('Specified KMS key should be used', async () => { + const kmsClient = makeKMSClient(); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await store.saveUnboundSessionKey( + sessionKeyPair.privateKey, + sessionKeyPair.sessionKey.keyId, + ); + + expect(kmsClient.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ name: kmsSessionKeyPath }), + expect.anything(), + ); + }); + + test('Plaintext should be session key serialized', async () => { + const kmsClient = makeKMSClient(); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await store.saveUnboundSessionKey( + sessionKeyPair.privateKey, + sessionKeyPair.sessionKey.keyId, + ); + + expect(kmsClient.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + plaintext: Buffer.from(await derSerializePrivateKey(sessionKeyPair.privateKey)), + }), + expect.anything(), + ); + }); + + test('Plaintext CRC32C checksum should be passed to KMS', async () => { + const kmsClient = makeKMSClient(); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await store.saveUnboundSessionKey( + sessionKeyPair.privateKey, + sessionKeyPair.sessionKey.keyId, + ); + + const privateKeySerialized = await derSerializePrivateKey(sessionKeyPair.privateKey); + expect(kmsClient.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + plaintextCrc32c: { value: calculateCRC32C(privateKeySerialized) }, + }), + expect.anything(), + ); + }); + + test('KMS should verify CRC32 checksum from client', async () => { + const store = new GCPPrivateKeyStore( + makeKMSClient({ verifiedPlaintextCrc32c: false }), + makeDatastoreClient(), + KMS_CONFIG, + ); + + const error = await catchPromiseRejection( + store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId), + PrivateKeyStoreError, + ); + + expect(error.cause()?.message).toEqual('KMS failed to verify plaintext CRC32C checksum'); + }); + + test('Client should verify CRC32 checksum from KMS', async () => { + const ciphertext = Buffer.from('the private key'); + const store = new GCPPrivateKeyStore( + makeKMSClient({ + ciphertext, + ciphertextCrc32cValue: calculateCRC32C(ciphertext) + 1, + }), + makeDatastoreClient(), + KMS_CONFIG, + ); + + const error = await catchPromiseRejection( + store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId), + PrivateKeyStoreError, + ); + + expect(error.cause()?.message).toEqual( + 'Ciphertext CRC32C checksum does not match that from KMS', + ); + }); + + test('KMS should encrypt with the specified key', async () => { + const kmsKeyName = 'this/is/not/even/well-formed'; + const store = new GCPPrivateKeyStore( + makeKMSClient({ kmsKeyVersionName: kmsKeyName }), + makeDatastoreClient(), + KMS_CONFIG, + ); + + const error = await catchPromiseRejection( + store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId), + PrivateKeyStoreError, + ); + + expect(error.cause()?.message).toEqual(`KMS used the wrong encryption key (${kmsKeyName})`); + }); + + test('KMS should encrypt with a similarly-named key', async () => { + const kmsKeyName = `${kmsSessionKeyPath}-not/cryptoKeyVersions/1`; + const store = new GCPPrivateKeyStore( + makeKMSClient({ kmsKeyVersionName: kmsKeyName }), + makeDatastoreClient(), + KMS_CONFIG, + ); + + const error = await catchPromiseRejection( + store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId), + PrivateKeyStoreError, + ); + + expect(error.cause()?.message).toEqual(`KMS used the wrong encryption key (${kmsKeyName})`); + }); + + test('Request should time out after 500ms', async () => { + const kmsClient = makeKMSClient(); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await store.saveUnboundSessionKey( + sessionKeyPair.privateKey, + sessionKeyPair.sessionKey.keyId, + ); + + expect(kmsClient.encrypt).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 500 }), + ); + }); + + test('API call error should be wrapped', async () => { + const kmsError = new Error('Someone talked about Bruno'); + const store = new GCPPrivateKeyStore( + makeKMSClient(kmsError), + makeDatastoreClient(), + KMS_CONFIG, + ); + + const error = await catchPromiseRejection( + store.saveUnboundSessionKey(sessionKeyPair.privateKey, sessionKeyPair.sessionKey.keyId), + PrivateKeyStoreError, + ); + + expect(error.message).toContain('Failed to encrypt session key with KMS'); + expect(error.cause()).toBeInstanceOf(GCPKeystoreError); + expect((error.cause() as GCPKeystoreError).cause()).toEqual(kmsError); + }); + }); + + interface KMSEncryptResponse { + readonly ciphertext: Buffer; + readonly verifiedPlaintextCrc32c: boolean; + readonly ciphertextCrc32cValue: number; + readonly kmsKeyVersionName: string; + } + + function makeKMSClient( + responseOrError: Partial | Error = {}, + ): KeyManagementServiceClient { + const kmsClient = makeKmsClientWithMockProject(); + jest.spyOn(kmsClient, 'encrypt').mockImplementation(async () => { + if (responseOrError instanceof Error) { + throw responseOrError; + } + const ciphertext = responseOrError.ciphertext ?? Buffer.from([]); + const ciphertextCrc32c = + responseOrError.ciphertextCrc32cValue ?? calculateCRC32C(ciphertext); + const kmsKeyVersionName = + responseOrError.kmsKeyVersionName ?? `${kmsSessionKeyPath}/cryptoKeyVersions/1`; + return [ + { + ciphertext, + ciphertextCrc32c: { value: ciphertextCrc32c.toString() }, + name: kmsKeyVersionName, + verifiedPlaintextCrc32c: responseOrError.verifiedPlaintextCrc32c ?? true, + }, + ]; + }); + return kmsClient; + } + + function makeDatastoreClient(error?: Error): Datastore { + const datastore = new Datastore(); + jest.spyOn(datastore, 'save').mockImplementation(async () => { + if (error) { + throw error; + } + }); + return datastore; + } + }); + + describe('retrieveSessionKeyData', () => { + test('Document should be retrieved from session keys collection', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + + await store.retrieveUnboundSessionKey(sessionKeyPair.sessionKey.keyId); + + expect(datastoreClient.get).toHaveBeenCalledWith( + expect.objectContaining({ kind: DatastoreKinds.SESSION_KEYS }), + expect.anything(), + ); + }); + + test('Document name should be session key id', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + + await store.retrieveUnboundSessionKey(sessionKeyPair.sessionKey.keyId); + + expect(datastoreClient.get).toHaveBeenCalledWith( + expect.objectContaining({ name: sessionKeyPair.sessionKey.keyId.toString('hex') }), + expect.anything(), + ); + }); + + test('Key should be regarded missing if it does not exist on Datastore', async () => { + const datastoreClient = makeDatastoreClient(null); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + + await expect( + store.retrieveUnboundSessionKey(sessionKeyPair.sessionKey.keyId), + ).rejects.toBeInstanceOf(UnknownKeyError); + }); + + test('Unbound key should be returned regardless of peer', async () => { + const store = new GCPPrivateKeyStore(makeKMSClient(), makeDatastoreClient(), KMS_CONFIG); + + const key = await store.retrieveSessionKey( + sessionKeyPair.sessionKey.keyId, + peerPrivateAddress, + ); + + await expect(derSerializePrivateKey(key)).resolves.toEqual( + await derSerializePrivateKey(sessionKeyPair.privateKey), + ); + }); + + test('Bound key should not be returned if peer does not match', async () => { + const store = new GCPPrivateKeyStore( + makeKMSClient(), + makeDatastoreClient({ + creationDate: new Date(), + peerPrivateAddress, + privateKeyCiphertext: Buffer.from('ciphertext'), + }), + KMS_CONFIG, + ); + + await expect( + store.retrieveSessionKey(sessionKeyPair.sessionKey.keyId, `not${peerPrivateAddress}`), + ).rejects.toBeInstanceOf(UnknownKeyError); + }); + + test('Bound key should be returned if peer matches', async () => { + const store = new GCPPrivateKeyStore( + makeKMSClient(), + makeDatastoreClient({ + creationDate: new Date(), + peerPrivateAddress, + privateKeyCiphertext: Buffer.from('ciphertext'), + }), + KMS_CONFIG, + ); + + const key = await store.retrieveSessionKey( + sessionKeyPair.sessionKey.keyId, + peerPrivateAddress, + ); + + await expect(derSerializePrivateKey(key)).resolves.toEqual( + await derSerializePrivateKey(sessionKeyPair.privateKey), + ); + }); + + test('Datastore call should time out after 500ms', async () => { + const datastoreClient = makeDatastoreClient(); + const store = new GCPPrivateKeyStore(makeKMSClient(), datastoreClient, KMS_CONFIG); + + await store.retrieveUnboundSessionKey(sessionKeyPair.sessionKey.keyId); + + expect(datastoreClient.get).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ gaxOptions: expect.objectContaining({ timeout: 500 }) }), + ); + }); + + test('Error to retrieve Datastore document should be wrapped', async () => { + const callError = new Error('The error'); + const store = new GCPPrivateKeyStore( + makeKMSClient(), + makeDatastoreClient(callError), + KMS_CONFIG, + ); + + const error = await catchPromiseRejection( + store.retrieveSessionKey(sessionKeyPair.sessionKey.keyId, peerPrivateAddress), + PrivateKeyStoreError, + ); + + expect(error.cause()?.message).toStartWith('Failed to retrieve key'); + expect((error.cause() as GCPKeystoreError).cause()).toEqual(callError); + }); + + describe('KMS decryption', () => { + test('Specified KMS key should be used', async () => { + const kmsClient = makeKMSClient(); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await store.retrieveSessionKey(sessionKeyPair.sessionKey.keyId, peerPrivateAddress); + + expect(kmsClient.decrypt).toHaveBeenCalledWith( + expect.objectContaining({ name: kmsSessionKeyPath }), + expect.anything(), + ); + }); + + test('Ciphertext should be taken from Datastore document', async () => { + const privateKeyCiphertext = Buffer.from('the ciphertext'); + const kmsClient = makeKMSClient(); + const store = new GCPPrivateKeyStore( + kmsClient, + makeDatastoreClient({ creationDate: new Date(), privateKeyCiphertext }), + KMS_CONFIG, + ); + + await store.retrieveSessionKey(sessionKeyPair.sessionKey.keyId, peerPrivateAddress); + + expect(kmsClient.decrypt).toHaveBeenCalledWith( + expect.objectContaining({ ciphertext: privateKeyCiphertext }), + expect.anything(), + ); + }); + + test('Ciphertext CRC32C checksum should be passed to KMS', async () => { + const privateKeyCiphertext = Buffer.from('the ciphertext'); + const kmsClient = makeKMSClient(); + const store = new GCPPrivateKeyStore( + kmsClient, + makeDatastoreClient({ creationDate: new Date(), privateKeyCiphertext }), + KMS_CONFIG, + ); + + await store.retrieveSessionKey(sessionKeyPair.sessionKey.keyId, peerPrivateAddress); + + expect(kmsClient.decrypt).toHaveBeenCalledWith( + expect.objectContaining({ + ciphertextCrc32c: { value: calculateCRC32C(privateKeyCiphertext) }, + }), + expect.anything(), + ); + }); + + test('Client should verify CRC32 checksum from KMS', async () => { + const kmsClient = makeKMSClient({ plaintextCrc32cValue: 42 }); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + const error = await catchPromiseRejection( + store.retrieveSessionKey(sessionKeyPair.sessionKey.keyId, peerPrivateAddress), + PrivateKeyStoreError, + ); + + expect(error.cause()?.message).toEqual( + 'Plaintext CRC32C checksum does not match that from KMS', + ); + }); + + test('Request should time out after 500ms', async () => { + const kmsClient = makeKMSClient(); + const store = new GCPPrivateKeyStore(kmsClient, makeDatastoreClient(), KMS_CONFIG); + + await store.retrieveSessionKey(sessionKeyPair.sessionKey.keyId, peerPrivateAddress); + + expect(kmsClient.decrypt).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 500 }), + ); + }); + + test('API call error should be wrapped', async () => { + const kmsError = new Error('Someone talked about Bruno'); + const store = new GCPPrivateKeyStore( + makeKMSClient(kmsError), + makeDatastoreClient(), + KMS_CONFIG, + ); + + const error = await catchPromiseRejection( + store.retrieveSessionKey(sessionKeyPair.sessionKey.keyId, peerPrivateAddress), + PrivateKeyStoreError, + ); + + expect(error.message).toContain('Failed to decrypt session key with KMS'); + expect(error.cause()).toBeInstanceOf(GCPKeystoreError); + expect((error.cause() as GCPKeystoreError).cause()).toEqual(kmsError); + }); + }); + + interface KMSDecryptResponse { + readonly plaintext: Buffer; + readonly plaintextCrc32cValue: number; + } + + function makeKMSClient( + responseOrError: Partial | Error = {}, + ): KeyManagementServiceClient { + const kmsClient = makeKmsClientWithMockProject(); + jest.spyOn(kmsClient, 'decrypt').mockImplementation(async () => { + if (responseOrError instanceof Error) { + throw responseOrError; + } + const plaintext = + responseOrError.plaintext ?? (await derSerializePrivateKey(sessionKeyPair.privateKey)); + const plaintextCrc32c = responseOrError.plaintextCrc32cValue ?? calculateCRC32C(plaintext); + return [{ plaintext, plaintextCrc32c: { value: plaintextCrc32c.toString() } }]; + }); + return kmsClient; + } + + function makeDatastoreClient( + keyDocumentOrError: SessionKeyEntity | Error | null = { + creationDate: new Date(), + privateKeyCiphertext: Buffer.from([]), + }, + ): Datastore { + const datastore = new Datastore(); + jest.spyOn(datastore, 'get').mockImplementation(async () => { + if (keyDocumentOrError instanceof Error) { + throw keyDocumentOrError; + } + return [keyDocumentOrError ?? undefined]; + }); + return datastore; + } + }); +}); + +function makeKmsClientWithMockProject(): KeyManagementServiceClient { + const kmsClient = new KeyManagementServiceClient(); + jest.spyOn(kmsClient, 'getProjectId').mockImplementation(() => GCP_PROJECT); + return kmsClient; +} diff --git a/src/lib/gcp/GCPPrivateKeyStore.ts b/src/lib/gcp/GCPPrivateKeyStore.ts new file mode 100644 index 00000000..ee2a6d17 --- /dev/null +++ b/src/lib/gcp/GCPPrivateKeyStore.ts @@ -0,0 +1,248 @@ +import { Datastore } from '@google-cloud/datastore'; +import { KeyManagementServiceClient } from '@google-cloud/kms'; +import { + derDeserializeRSAPublicKey, + getPrivateAddressFromIdentityKey, + IdentityKeyPair, + PrivateKeyStore, + RSAKeyGenOptions, + SessionPrivateKeyData, +} from '@relaycorp/relaynet-core'; +import { calculate as calculateCRC32C } from 'fast-crc32c'; + +import { IdentityKeyEntity, SessionKeyEntity } from './datastoreEntities'; +import { DatastoreKinds } from './DatastoreKinds'; +import { GCPKeystoreError } from './GCPKeystoreError'; +import { GcpKmsRsaPssPrivateKey } from './GcpKmsRsaPssPrivateKey'; +import { wrapGCPCallError } from './gcpUtils'; +import { retrieveKMSPublicKey } from './kmsUtils'; + +export interface KMSConfig { + readonly location: string; + readonly keyRing: string; + readonly identityKeyId: string; + readonly sessionEncryptionKeyId: string; +} + +const SESSION_KEY_INDEX_EXCLUSIONS: ReadonlyArray = [ + 'peerPrivateAddress', + 'privateKeyCiphertext', +]; + +export class GCPPrivateKeyStore extends PrivateKeyStore { + constructor( + protected kmsClient: KeyManagementServiceClient, + protected datastoreClient: Datastore, + protected kmsConfig: KMSConfig, + ) { + super(); + } + + public override async generateIdentityKeyPair( + options: Partial = {}, + ): Promise { + const kmsKeyName = this.kmsClient.cryptoKeyPath( + await this.getGCPProjectId(), + this.kmsConfig.location, + this.kmsConfig.keyRing, + this.kmsConfig.identityKeyId, + ); + await this.validateExistingSigningKey(kmsKeyName, options); + + const kmsKeyVersionPath = await this.createSigningKMSKeyVersion(kmsKeyName); + + const privateKey = new GcpKmsRsaPssPrivateKey(kmsKeyVersionPath); + const publicKeySerialized = await retrieveKMSPublicKey( + privateKey.kmsKeyVersionPath, + this.kmsClient, + ); + const publicKey = await derDeserializeRSAPublicKey(publicKeySerialized); + const privateAddress = await getPrivateAddressFromIdentityKey(publicKey); + + await this.linkKMSKeyVersion(kmsKeyVersionPath, privateAddress); + + return { privateAddress, privateKey, publicKey }; + } + + public async retrieveIdentityKey(privateAddress: string): Promise { + const datastoreKey = this.datastoreClient.key([DatastoreKinds.IDENTITY_KEYS, privateAddress]); + let keyDocument: IdentityKeyEntity | undefined; + try { + const [entity] = await this.datastoreClient.get(datastoreKey); + keyDocument = entity; + } catch (err) { + throw new GCPKeystoreError( + err as Error, + `Failed to look up KMS key version for ${privateAddress}`, + ); + } + if (!keyDocument) { + return null; + } + const kmsKeyPath = this.kmsClient.cryptoKeyVersionPath( + await this.getGCPProjectId(), + this.kmsConfig.location, + this.kmsConfig.keyRing, + keyDocument.key, // Ignore the KMS key in the constructor + keyDocument.version, + ); + return new GcpKmsRsaPssPrivateKey(kmsKeyPath); + } + + protected async saveIdentityKey(): Promise { + throw new GCPKeystoreError('Method is not supported'); + } + + protected async saveSessionKeySerialized( + keyId: string, + keySerialized: Buffer, + peerPrivateAddress?: string, + ): Promise { + const datastoreKey = this.datastoreClient.key([DatastoreKinds.SESSION_KEYS, keyId]); + const data: SessionKeyEntity = { + creationDate: new Date(), + peerPrivateAddress, + privateKeyCiphertext: await this.encryptSessionPrivateKey(keySerialized), + }; + await wrapGCPCallError( + this.datastoreClient.save( + { data, excludeFromIndexes: SESSION_KEY_INDEX_EXCLUSIONS, key: datastoreKey }, + { timeout: 500 }, + ), + 'Failed to store session key in Datastore', + ); + } + + protected async retrieveSessionKeyData(keyId: string): Promise { + const datastoreKey = this.datastoreClient.key([DatastoreKinds.SESSION_KEYS, keyId]); + const [entity] = await wrapGCPCallError( + this.datastoreClient.get(datastoreKey, { gaxOptions: { timeout: 500 } }), + 'Failed to retrieve key', + ); + if (!entity) { + return null; + } + const keyData: SessionKeyEntity = entity; + const keySerialized = await this.decryptSessionPrivateKey(keyData.privateKeyCiphertext); + return { keySerialized, peerPrivateAddress: keyData.peerPrivateAddress }; + } + + //region Identity key utilities + + private async validateExistingSigningKey( + kmsKeyName: string, + options: Partial, + ): Promise { + const [kmsKey] = await this.kmsClient.getCryptoKey({ name: kmsKeyName }); + const keyAlgorithm = kmsKey.versionTemplate!.algorithm as string; + if (!keyAlgorithm.startsWith('RSA_SIGN_PSS_')) { + throw new GCPKeystoreError(`Key ${kmsKeyName} is not an RSA-PSS key`); + } + + const requiredRSAModulus = options.modulus ?? 2048; + if (!keyAlgorithm.includes(`_${requiredRSAModulus}_`)) { + throw new GCPKeystoreError(`Key ${kmsKeyName} does not use modulus ${requiredRSAModulus}`); + } + + const requiredHashingAlgorithm = options.hashingAlgorithm ?? 'SHA-256'; + if (!keyAlgorithm.endsWith(requiredHashingAlgorithm.replace('-', ''))) { + throw new GCPKeystoreError(`Key ${kmsKeyName} does not use ${requiredHashingAlgorithm}`); + } + } + + private async createSigningKMSKeyVersion(kmsKeyName: string): Promise { + // Version 1 of the KMS key was already linked, so create a new version. + const [kmsVersionResponse] = await wrapGCPCallError( + this.kmsClient.createCryptoKeyVersion({ parent: kmsKeyName }, { timeout: 500 }), + 'Failed to create key version', + ); + return kmsVersionResponse.name!; + } + + private async linkKMSKeyVersion( + kmsKeyVersionPath: string, + privateAddress: string, + ): Promise { + const datastoreKey = this.datastoreClient.key([DatastoreKinds.IDENTITY_KEYS, privateAddress]); + const identityKeyEntity: IdentityKeyEntity = { + key: this.kmsConfig.identityKeyId, + version: this.kmsClient.matchCryptoKeyVersionFromCryptoKeyVersionName( + kmsKeyVersionPath, + ) as string, + }; + await wrapGCPCallError( + this.datastoreClient.save( + { + data: identityKeyEntity, + excludeFromIndexes: ['version', 'key'], + key: datastoreKey, + }, + { timeout: 500 }, + ), + 'Failed to register identity key on Datastore', + ); + } + + //endregion + //region Session key handling utilities + + private async encryptSessionPrivateKey(keySerialized: Buffer): Promise { + const kmsKeyName = await this.getKMSKeyForSessionKey(); + const plaintextCRC32C = calculateCRC32C(keySerialized); + const [encryptResponse] = await wrapGCPCallError( + this.kmsClient.encrypt( + { name: kmsKeyName, plaintext: keySerialized, plaintextCrc32c: { value: plaintextCRC32C } }, + { timeout: 500 }, + ), + 'Failed to encrypt session key with KMS', + ); + if (!encryptResponse.name!.startsWith(kmsKeyName + '/')) { + throw new GCPKeystoreError(`KMS used the wrong encryption key (${encryptResponse.name})`); + } + if (!encryptResponse.verifiedPlaintextCrc32c) { + throw new GCPKeystoreError('KMS failed to verify plaintext CRC32C checksum'); + } + const ciphertext = encryptResponse.ciphertext as Buffer; + if (calculateCRC32C(ciphertext) !== Number(encryptResponse.ciphertextCrc32c!.value)) { + throw new GCPKeystoreError('Ciphertext CRC32C checksum does not match that from KMS'); + } + return ciphertext; + } + + private async decryptSessionPrivateKey(privateKeyCiphertext: Buffer): Promise { + const kmsKeyName = await this.getKMSKeyForSessionKey(); + const ciphertextCRC32C = calculateCRC32C(privateKeyCiphertext); + const [decryptionResponse] = await wrapGCPCallError( + this.kmsClient.decrypt( + { + ciphertext: privateKeyCiphertext, + ciphertextCrc32c: { value: ciphertextCRC32C }, + name: kmsKeyName, + }, + { timeout: 500 }, + ), + 'Failed to decrypt session key with KMS', + ); + const plaintext = decryptionResponse.plaintext as Buffer; + if (calculateCRC32C(plaintext) !== Number(decryptionResponse.plaintextCrc32c!.value)) { + throw new GCPKeystoreError('Plaintext CRC32C checksum does not match that from KMS'); + } + return plaintext; + } + + private async getKMSKeyForSessionKey(): Promise { + return this.kmsClient.cryptoKeyPath( + await this.getGCPProjectId(), + this.kmsConfig.location, + this.kmsConfig.keyRing, + this.kmsConfig.sessionEncryptionKeyId, + ); + } + + //endregion + + private async getGCPProjectId(): Promise { + // GCP client library already caches the project id. + return this.kmsClient.getProjectId(); + } +} diff --git a/src/lib/gcp/GcpKmsRsaPssPrivateKey.spec.ts b/src/lib/gcp/GcpKmsRsaPssPrivateKey.spec.ts new file mode 100644 index 00000000..662765d8 --- /dev/null +++ b/src/lib/gcp/GcpKmsRsaPssPrivateKey.spec.ts @@ -0,0 +1,33 @@ +import { GcpKmsRsaPssPrivateKey } from './GcpKmsRsaPssPrivateKey'; + +const KMS_KEY_PATH = 'projects/foo/key/42'; + +test('KMS key path should be honored', () => { + const key = new GcpKmsRsaPssPrivateKey(KMS_KEY_PATH); + + expect(key.kmsKeyVersionPath).toEqual(KMS_KEY_PATH); +}); + +test('Algorithm should be RSA-PSS', () => { + const key = new GcpKmsRsaPssPrivateKey(KMS_KEY_PATH); + + expect(key.algorithm.name).toEqual('RSA-PSS'); +}); + +test('Usage should be "sign"', () => { + const key = new GcpKmsRsaPssPrivateKey(KMS_KEY_PATH); + + expect(key.usages).toEqual(['sign']); +}); + +test('Key type should be private', () => { + const key = new GcpKmsRsaPssPrivateKey(KMS_KEY_PATH); + + expect(key.type).toEqual('private'); +}); + +test('Key should be extractable', () => { + const key = new GcpKmsRsaPssPrivateKey(KMS_KEY_PATH); + + expect(key.extractable).toBeTrue(); +}); diff --git a/src/lib/gcp/GcpKmsRsaPssPrivateKey.ts b/src/lib/gcp/GcpKmsRsaPssPrivateKey.ts new file mode 100644 index 00000000..3dde22ff --- /dev/null +++ b/src/lib/gcp/GcpKmsRsaPssPrivateKey.ts @@ -0,0 +1,11 @@ +import { CryptoKey } from 'webcrypto-core'; + +export class GcpKmsRsaPssPrivateKey extends CryptoKey { + constructor(public kmsKeyVersionPath: string) { + super(); + this.algorithm = { name: 'RSA-PSS' }; + this.type = 'private'; + this.usages = ['sign']; + this.extractable = true; // The public key is exportable + } +} diff --git a/src/lib/gcp/GcpKmsRsaPssProvider.spec.ts b/src/lib/gcp/GcpKmsRsaPssProvider.spec.ts new file mode 100644 index 00000000..f9d0e284 --- /dev/null +++ b/src/lib/gcp/GcpKmsRsaPssProvider.spec.ts @@ -0,0 +1,269 @@ +import { KeyManagementServiceClient } from '@google-cloud/kms'; +import { calculate as calculateCRC32C } from 'fast-crc32c'; +import { CryptoKey } from 'webcrypto-core'; + +import { mockSpy } from '../../testUtils/jest'; +import { catchPromiseRejection } from '../../testUtils/promises'; +import { bufferToArrayBuffer } from '../utils/buffer'; +import { GCPKeystoreError } from './GCPKeystoreError'; +import { GcpKmsRsaPssPrivateKey } from './GcpKmsRsaPssPrivateKey'; +import { GcpKmsRsaPssProvider } from './GcpKmsRsaPssProvider'; +import * as kmsUtils from './kmsUtils'; + +const ALGORITHM = { name: 'RSA-PSS', saltLength: 32 }; + +const PRIVATE_KEY = new GcpKmsRsaPssPrivateKey('/the/path/key-name'); + +describe('hashingAlgorithms', () => { + test('Only SHA-256 and SHA-512 should be supported', async () => { + const provider = new GcpKmsRsaPssProvider(null as any); + + expect(provider.hashAlgorithms).toEqual(['SHA-256', 'SHA-512']); + }); +}); + +describe('onGenerateKey', () => { + test('Method should not be supported', async () => { + const provider = new GcpKmsRsaPssProvider(null as any); + + await expect(provider.onGenerateKey()).rejects.toThrowWithMessage( + GCPKeystoreError, + 'Key generation is unsupported', + ); + }); +}); + +describe('onImportKey', () => { + test('Method should not be supported', async () => { + const provider = new GcpKmsRsaPssProvider(null as any); + + await expect(provider.onImportKey()).rejects.toThrowWithMessage( + GCPKeystoreError, + 'Key import is unsupported', + ); + }); +}); + +describe('onSign', () => { + const PLAINTEXT = bufferToArrayBuffer(Buffer.from('the plaintext')); + const SIGNATURE = Buffer.from('the signature'); + + test('Non-KMS key should be refused', async () => { + const kmsClient = makeKmsClient(); + const provider = new GcpKmsRsaPssProvider(kmsClient); + const invalidKey = CryptoKey.create({ name: 'RSA-PSS' }, 'private', true, ['sign']); + + await expect(provider.sign(ALGORITHM, invalidKey, PLAINTEXT)).rejects.toThrowWithMessage( + GCPKeystoreError, + `Cannot sign with key of unsupported type (${invalidKey.constructor.name})`, + ); + + expect(kmsClient.asymmetricSign).not.toHaveBeenCalled(); + }); + + test('Signature should be output', async () => { + const kmsClient = makeKmsClient({ signature: SIGNATURE }); + const provider = new GcpKmsRsaPssProvider(kmsClient); + + const signature = await provider.sign(ALGORITHM, PRIVATE_KEY, PLAINTEXT); + + expect(Buffer.from(signature)).toEqual(SIGNATURE); + }); + + test('Correct key path should be used', async () => { + const kmsClient = makeKmsClient(); + const provider = new GcpKmsRsaPssProvider(kmsClient); + + await provider.sign(ALGORITHM, PRIVATE_KEY, PLAINTEXT); + + expect(kmsClient.asymmetricSign).toHaveBeenCalledWith( + expect.objectContaining({ name: PRIVATE_KEY.kmsKeyVersionPath }), + expect.anything(), + ); + }); + + test('Correct plaintext should be passed', async () => { + const kmsClient = makeKmsClient(); + const provider = new GcpKmsRsaPssProvider(kmsClient); + + await provider.sign(ALGORITHM, PRIVATE_KEY, PLAINTEXT); + + expect(kmsClient.asymmetricSign).toHaveBeenCalledWith( + expect.toSatisfy((req) => Buffer.from(req.data).equals(Buffer.from(PLAINTEXT))), + expect.anything(), + ); + }); + + test('Plaintext CRC32C checksum should be passed to KMS', async () => { + const kmsClient = makeKmsClient(); + const provider = new GcpKmsRsaPssProvider(kmsClient); + + await provider.sign(ALGORITHM, PRIVATE_KEY, PLAINTEXT); + + expect(kmsClient.asymmetricSign).toHaveBeenCalledWith( + expect.objectContaining({ dataCrc32c: { value: calculateCRC32C(Buffer.from(PLAINTEXT)) } }), + expect.anything(), + ); + }); + + test('KMS should verify signature CRC32C checksum from the client', async () => { + const provider = new GcpKmsRsaPssProvider(makeKmsClient({ verifiedSignatureCRC32C: false })); + + await expect(provider.sign(ALGORITHM, PRIVATE_KEY, PLAINTEXT)).rejects.toThrowWithMessage( + GCPKeystoreError, + 'KMS failed to verify plaintext CRC32C checksum', + ); + }); + + test('Signature CRC32C checksum from the KMS should be verified', async () => { + const provider = new GcpKmsRsaPssProvider( + makeKmsClient({ signatureCRC32C: calculateCRC32C(SIGNATURE) - 1 }), + ); + + await expect(provider.sign(ALGORITHM, PRIVATE_KEY, PLAINTEXT)).rejects.toThrowWithMessage( + GCPKeystoreError, + 'Signature CRC32C checksum does not match one received from KMS', + ); + }); + + test('KMS should sign with the specified key', async () => { + const kmsKeyVersionName = `not-${PRIVATE_KEY.kmsKeyVersionPath}`; + const provider = new GcpKmsRsaPssProvider(makeKmsClient({ kmsKeyVersionName })); + + await expect(provider.sign(ALGORITHM, PRIVATE_KEY, PLAINTEXT)).rejects.toThrowWithMessage( + GCPKeystoreError, + `KMS used the wrong key version (${kmsKeyVersionName})`, + ); + }); + + test('Request should time out after 500ms', async () => { + const kmsClient = makeKmsClient(); + const provider = new GcpKmsRsaPssProvider(kmsClient); + + await provider.sign(ALGORITHM, PRIVATE_KEY, PLAINTEXT); + + expect(kmsClient.asymmetricSign).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 500 }), + ); + }); + + test('API call errors should be wrapped', async () => { + const callError = new Error('Bruno. There. I said it.'); + const provider = new GcpKmsRsaPssProvider(makeKmsClient(callError)); + + const error = await catchPromiseRejection( + provider.sign(ALGORITHM, PRIVATE_KEY, PLAINTEXT), + GCPKeystoreError, + ); + + expect(error.message).toStartWith('KMS signature request failed'); + expect(error.cause()).toEqual(callError); + }); + + describe('Algorithm parameters', () => { + test.each([32, 64])('Salt length of %s should be accepted', async (saltLength) => { + const kmsClient = makeKmsClient(); + const provider = new GcpKmsRsaPssProvider(kmsClient); + const algorithm = { ...ALGORITHM, saltLength }; + + await provider.sign(algorithm, PRIVATE_KEY, PLAINTEXT); + }); + + test.each([20, 48])('Salt length of %s should be refused', async (saltLength) => { + // 20 and 48 are used by SHA-1 and SHA-384, respectively, which are unsupported + const kmsClient = makeKmsClient(); + const provider = new GcpKmsRsaPssProvider(kmsClient); + const algorithm = { ...ALGORITHM, saltLength }; + + await expect(provider.sign(algorithm, PRIVATE_KEY, PLAINTEXT)).rejects.toThrowWithMessage( + GCPKeystoreError, + `Unsupported salt length of ${saltLength} octets`, + ); + }); + }); + + interface KMSSignatureResponse { + readonly signature: Buffer; + readonly signatureCRC32C: number; + readonly verifiedSignatureCRC32C: boolean; + readonly kmsKeyVersionName: string; + } + + function makeKmsClient( + responseOrError: Partial | Error = {}, + ): KeyManagementServiceClient { + const kmsClient = new KeyManagementServiceClient(); + jest.spyOn(kmsClient, 'asymmetricSign').mockImplementation(async () => { + if (responseOrError instanceof Error) { + throw responseOrError; + } + + const signature = responseOrError.signature ?? SIGNATURE; + const signatureCrc32c = responseOrError.signatureCRC32C ?? calculateCRC32C(signature); + const response = { + name: responseOrError.kmsKeyVersionName ?? PRIVATE_KEY.kmsKeyVersionPath, + signature, + signatureCrc32c: { value: signatureCrc32c.toString() }, + verifiedDataCrc32c: responseOrError.verifiedSignatureCRC32C ?? true, + }; + return [response, undefined, undefined]; + }); + return kmsClient; + } +}); + +describe('onVerify', () => { + test('Method should not be supported', async () => { + const provider = new GcpKmsRsaPssProvider(null as any); + + await expect(provider.onVerify()).rejects.toThrowWithMessage( + GCPKeystoreError, + 'Signature verification is unsupported', + ); + }); +}); + +describe('onExportKey', () => { + const publicKeyDer = Buffer.from('This is a DER-encoded public key :wink:'); + const mockRetrieveKMSPublicKey = mockSpy(jest.spyOn(kmsUtils, 'retrieveKMSPublicKey'), () => + bufferToArrayBuffer(publicKeyDer), + ); + + test.each(['jwt', 'pkcs8', 'raw'] as readonly KeyFormat[])( + '%s export should be unsupported', + async (format) => { + const provider = new GcpKmsRsaPssProvider(null as any); + + await expect(provider.onExportKey(format, PRIVATE_KEY)).rejects.toThrowWithMessage( + GCPKeystoreError, + 'Private key cannot be exported', + ); + + expect(mockRetrieveKMSPublicKey).not.toHaveBeenCalled(); + }, + ); + + test('SPKI format should be supported', async () => { + const kmsClient = new KeyManagementServiceClient(); + const provider = new GcpKmsRsaPssProvider(kmsClient); + + const publicKey = await provider.exportKey('spki', PRIVATE_KEY); + + expect(publicKey).toBeInstanceOf(ArrayBuffer); + expect(Buffer.from(publicKey as ArrayBuffer)).toEqual(publicKeyDer); + expect(mockRetrieveKMSPublicKey).toHaveBeenCalledWith(PRIVATE_KEY.kmsKeyVersionPath, kmsClient); + }); + + test('Non-KMS key should be refused', async () => { + const provider = new GcpKmsRsaPssProvider(null as any); + const invalidKey = new CryptoKey(); + + await expect(provider.onExportKey('spki', invalidKey)).rejects.toThrowWithMessage( + GCPKeystoreError, + 'Key is not managed by KMS', + ); + + expect(mockRetrieveKMSPublicKey).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/gcp/GcpKmsRsaPssProvider.ts b/src/lib/gcp/GcpKmsRsaPssProvider.ts new file mode 100644 index 00000000..ac050f1c --- /dev/null +++ b/src/lib/gcp/GcpKmsRsaPssProvider.ts @@ -0,0 +1,87 @@ +import { KeyManagementServiceClient } from '@google-cloud/kms'; +import { calculate as calculateCRC32C } from 'fast-crc32c'; +import { CryptoKey, RsaPssProvider } from 'webcrypto-core'; + +import { bufferToArrayBuffer } from '../utils/buffer'; +import { GCPKeystoreError } from './GCPKeystoreError'; +import { GcpKmsRsaPssPrivateKey } from './GcpKmsRsaPssPrivateKey'; +import { wrapGCPCallError } from './gcpUtils'; +import { retrieveKMSPublicKey } from './kmsUtils'; + +// See: https://cloud.google.com/kms/docs/algorithms#rsa_signing_algorithms +const SUPPORTED_SALT_LENGTHS: readonly number[] = [ + 256 / 8, // SHA-256 + 512 / 8, // SHA-512 +]; + +export class GcpKmsRsaPssProvider extends RsaPssProvider { + constructor(protected kmsClient: KeyManagementServiceClient) { + super(); + + // See: https://cloud.google.com/kms/docs/algorithms#rsa_signing_algorithms + this.hashAlgorithms = ['SHA-256', 'SHA-512']; + } + + public async onGenerateKey(): Promise { + throw new GCPKeystoreError('Key generation is unsupported'); + } + + public async onImportKey(): Promise { + throw new GCPKeystoreError('Key import is unsupported'); + } + + public async onExportKey(format: KeyFormat, key: CryptoKey): Promise { + if (format !== 'spki') { + throw new GCPKeystoreError('Private key cannot be exported'); + } + if (!(key instanceof GcpKmsRsaPssPrivateKey)) { + throw new GCPKeystoreError('Key is not managed by KMS'); + } + return retrieveKMSPublicKey(key.kmsKeyVersionPath, this.kmsClient); + } + + public async onSign( + algorithm: RsaPssParams, + key: CryptoKey, + data: ArrayBuffer, + ): Promise { + if (!(key instanceof GcpKmsRsaPssPrivateKey)) { + throw new GCPKeystoreError( + `Cannot sign with key of unsupported type (${key.constructor.name})`, + ); + } + + if (!SUPPORTED_SALT_LENGTHS.includes(algorithm.saltLength)) { + throw new GCPKeystoreError(`Unsupported salt length of ${algorithm.saltLength} octets`); + } + + return this.kmsSign(Buffer.from(data), key); + } + + public async onVerify(): Promise { + throw new GCPKeystoreError('Signature verification is unsupported'); + } + + private async kmsSign(plaintext: Buffer, key: GcpKmsRsaPssPrivateKey): Promise { + const plaintextChecksum = calculateCRC32C(plaintext); + const [response] = await wrapGCPCallError( + this.kmsClient.asymmetricSign( + { data: plaintext, dataCrc32c: { value: plaintextChecksum }, name: key.kmsKeyVersionPath }, + { timeout: 500 }, + ), + 'KMS signature request failed', + ); + + if (response.name !== key.kmsKeyVersionPath) { + throw new GCPKeystoreError(`KMS used the wrong key version (${response.name})`); + } + if (!response.verifiedDataCrc32c) { + throw new GCPKeystoreError('KMS failed to verify plaintext CRC32C checksum'); + } + const signature = response.signature as Buffer; + if (calculateCRC32C(signature) !== Number(response.signatureCrc32c!.value)) { + throw new GCPKeystoreError('Signature CRC32C checksum does not match one received from KMS'); + } + return bufferToArrayBuffer(signature); + } +} diff --git a/src/lib/gcp/datastoreEntities.ts b/src/lib/gcp/datastoreEntities.ts new file mode 100644 index 00000000..ea5213b1 --- /dev/null +++ b/src/lib/gcp/datastoreEntities.ts @@ -0,0 +1,16 @@ +export interface IdentityKeyEntity { + /** + * The KMS key id. + * + * This is stored in order to support the ability to migrate KMS keys within the same key ring. + */ + readonly key: string; + + readonly version: string; +} + +export interface SessionKeyEntity { + readonly creationDate: Date; + readonly privateKeyCiphertext: Buffer; + readonly peerPrivateAddress?: string; +} diff --git a/src/lib/gcp/gcpUtils.spec.ts b/src/lib/gcp/gcpUtils.spec.ts new file mode 100644 index 00000000..414d76e6 --- /dev/null +++ b/src/lib/gcp/gcpUtils.spec.ts @@ -0,0 +1,41 @@ +import { catchPromiseRejection } from '../../testUtils/promises'; +import { GCPKeystoreError } from './GCPKeystoreError'; +import { wrapGCPCallError } from './gcpUtils'; + +describe('wrapGCPCallError', () => { + const gcpError = new Error('Someone talked about Bruno'); + + test('Successful calls should resolve', async () => { + const resolvedValue = 42; + + await expect(wrapGCPCallError(Promise.resolve(resolvedValue), '')).resolves.toEqual( + resolvedValue, + ); + }); + + test('Failed calls should be wrapped in custom error', async () => { + await expect(wrapGCPCallError(Promise.reject(gcpError), '')).rejects.toBeInstanceOf( + GCPKeystoreError, + ); + }); + + test('Wrapping exception should use specified error message', async () => { + const errorMessage = 'The error message'; + + const error = await catchPromiseRejection( + wrapGCPCallError(Promise.reject(gcpError), errorMessage), + GCPKeystoreError, + ); + + expect(error.message).toStartWith(`${errorMessage}:`); + }); + + test('Wrapped exception should be original one from GCP API client', async () => { + const error = await catchPromiseRejection( + wrapGCPCallError(Promise.reject(gcpError), ''), + GCPKeystoreError, + ); + + expect(error.cause()).toEqual(gcpError); + }); +}); diff --git a/src/lib/gcp/gcpUtils.ts b/src/lib/gcp/gcpUtils.ts new file mode 100644 index 00000000..c5bf36ed --- /dev/null +++ b/src/lib/gcp/gcpUtils.ts @@ -0,0 +1,20 @@ +import { GCPKeystoreError } from './GCPKeystoreError'; + +/** + * Wrap GCP API call errors + * + * To provide meaningful a useful stack trace and error message. + * + * @param callPromise + * @param errorMessage + */ +export async function wrapGCPCallError( + callPromise: Promise, + errorMessage: string, +): Promise { + try { + return await callPromise; + } catch (err) { + throw new GCPKeystoreError(err as Error, errorMessage); + } +} diff --git a/src/lib/gcp/kmsUtils.spec.ts b/src/lib/gcp/kmsUtils.spec.ts new file mode 100644 index 00000000..2e0984ac --- /dev/null +++ b/src/lib/gcp/kmsUtils.spec.ts @@ -0,0 +1,126 @@ +import { KeyManagementServiceClient } from '@google-cloud/kms'; + +import { derPublicKeyToPem } from '../../testUtils/asn1'; +import { getMockContext, getMockInstance } from '../../testUtils/jest'; +import { catchPromiseRejection } from '../../testUtils/promises'; +import { mockSleep } from '../../testUtils/timing'; +import { GCPKeystoreError } from './GCPKeystoreError'; +import { retrieveKMSPublicKey } from './kmsUtils'; + +const sleepMock = mockSleep(); + +describe('retrieveKMSPublicKey', () => { + const KMS_KEY_VERSION_NAME = 'projects/foo/key/42'; + + test('Specified key version name should be honored', async () => { + const kmsClient = makeKmsClient(); + + await retrieveKMSPublicKey(KMS_KEY_VERSION_NAME, kmsClient); + + expect(kmsClient.getPublicKey).toHaveBeenCalledWith( + expect.objectContaining({ name: KMS_KEY_VERSION_NAME }), + expect.anything(), + ); + }); + + test('Public key should be output DER-serialized', async () => { + const publicKeyDer = Buffer.from('This is a DER-encoded public key :wink:'); + const kmsClient = makeKmsClient(derPublicKeyToPem(publicKeyDer)); + + const publicKey = await retrieveKMSPublicKey(KMS_KEY_VERSION_NAME, kmsClient); + + expect(publicKey).toBeInstanceOf(ArrayBuffer); + expect(Buffer.from(publicKey as ArrayBuffer)).toEqual(publicKeyDer); + }); + + test('Public key export should time out after 300ms', async () => { + const kmsClient = makeKmsClient(); + + await retrieveKMSPublicKey(KMS_KEY_VERSION_NAME, kmsClient); + + expect(kmsClient.getPublicKey).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 300 }), + ); + }); + + test('Public key export should be retried up to 3 times', async () => { + const kmsClient = makeKmsClient(); + + await retrieveKMSPublicKey(KMS_KEY_VERSION_NAME, kmsClient); + + expect(kmsClient.getPublicKey).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ maxRetries: 3 }), + ); + }); + + test('Retrieval should be retried after 500ms if key is pending generation', async () => { + const publicKeyDer = Buffer.from('This is a DER-encoded public key :wink:'); + const kmsClient = makeKmsClient(); + const callError = new MockGCPError('Whoops', 'KEY_PENDING_GENERATION'); + getMockInstance(kmsClient.getPublicKey) + .mockRejectedValueOnce(callError) + .mockResolvedValueOnce([{ pem: derPublicKeyToPem(publicKeyDer) }]); + + const publicKey = await retrieveKMSPublicKey(KMS_KEY_VERSION_NAME, kmsClient); + + expect(kmsClient.getPublicKey).toHaveBeenCalledTimes(2); + expect(sleepMock).toHaveBeenCalledWith(500); + expect(getMockContext(kmsClient.getPublicKey).invocationCallOrder[0]).toBeLessThan( + getMockContext(sleepMock).invocationCallOrder[0], + ); + expect(getMockContext(kmsClient.getPublicKey).invocationCallOrder[1]).toBeGreaterThan( + getMockContext(sleepMock).invocationCallOrder[0], + ); + expect(Buffer.from(publicKey as ArrayBuffer)).toEqual(publicKeyDer); + }); + + test('Non-KEY_PENDING_GENERATION violations should be propagated immediately', async () => { + const callError = new MockGCPError('Whoops', 'NOT-KEY_PENDING_GENERATION'); + const kmsClient = makeKmsClient(callError); + + await catchPromiseRejection( + retrieveKMSPublicKey(KMS_KEY_VERSION_NAME, kmsClient), + GCPKeystoreError, + ); + + expect(kmsClient.getPublicKey).toHaveBeenCalledTimes(1); + }); + + test('Any other errors should be wrapped', async () => { + const callError = new Error('The service is down'); + const kmsClient = makeKmsClient(callError); + + const error = await catchPromiseRejection( + retrieveKMSPublicKey(KMS_KEY_VERSION_NAME, kmsClient), + GCPKeystoreError, + ); + + expect(error.message).toStartWith('Failed to retrieve public key'); + expect(error.cause()).toEqual(callError); + expect(kmsClient.getPublicKey).toHaveBeenCalledTimes(1); + }); + + function makeKmsClient( + publicKeyPemOrError: string | Error = 'pub key', + ): KeyManagementServiceClient { + const kmsClient = new KeyManagementServiceClient(); + jest.spyOn(kmsClient, 'getPublicKey').mockImplementation(async () => { + if (publicKeyPemOrError instanceof Error) { + throw publicKeyPemOrError; + } + return [{ pem: publicKeyPemOrError }, undefined, undefined]; + }); + return kmsClient; + } +}); + +class MockGCPError extends Error { + public readonly statusDetails: readonly any[]; + constructor(message: string, violationType: string) { + super(message); + + this.statusDetails = [{ violations: [{ type: violationType }] }]; + } +} diff --git a/src/lib/gcp/kmsUtils.ts b/src/lib/gcp/kmsUtils.ts new file mode 100644 index 00000000..d1be831c --- /dev/null +++ b/src/lib/gcp/kmsUtils.ts @@ -0,0 +1,56 @@ +import { KeyManagementServiceClient } from '@google-cloud/kms'; + +import { bufferToArrayBuffer } from '../utils/buffer'; +import { sleep } from '../utils/timing'; +import { wrapGCPCallError } from './gcpUtils'; + +export async function retrieveKMSPublicKey( + kmsKeyVersionName: string, + kmsClient: KeyManagementServiceClient, +): Promise { + const retrieveWhenReady = async () => { + let key: string; + try { + key = await _retrieveKMSPublicKey(kmsKeyVersionName, kmsClient); + } catch (err) { + if (!isKeyPendingCreation(err as Error)) { + throw err; + } + + // Let's give KMS a bit more time to generate the key + await sleep(500); + key = await _retrieveKMSPublicKey(kmsKeyVersionName, kmsClient); + } + return key; + }; + const publicKeyPEM = await wrapGCPCallError(retrieveWhenReady(), 'Failed to retrieve public key'); + const publicKeyDer = pemToDer(publicKeyPEM); + return bufferToArrayBuffer(publicKeyDer); +} + +async function _retrieveKMSPublicKey( + kmsKeyVersionName: string, + kmsClient: KeyManagementServiceClient, +): Promise { + const [response] = await kmsClient.getPublicKey( + { name: kmsKeyVersionName }, + { + maxRetries: 3, + timeout: 300, + }, + ); + return response.pem!; +} + +function isKeyPendingCreation(err: Error): boolean { + const statusDetails = (err as any).statusDetails ?? []; + const pendingCreationViolations = statusDetails.filter( + (d: any) => 0 < d.violations.filter((v: any) => v.type === 'KEY_PENDING_GENERATION').length, + ); + return !!pendingCreationViolations.length; +} + +function pemToDer(pemBuffer: string): Buffer { + const oneliner = pemBuffer.toString().replace(/(-----[\w ]*-----|\n)/g, ''); + return Buffer.from(oneliner, 'base64'); +} diff --git a/src/lib/utils/buffer.ts b/src/lib/utils/buffer.ts new file mode 100644 index 00000000..0189db11 --- /dev/null +++ b/src/lib/utils/buffer.ts @@ -0,0 +1,3 @@ +export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); +} diff --git a/src/lib/utils/timing.spec.ts b/src/lib/utils/timing.spec.ts new file mode 100644 index 00000000..08b4220f --- /dev/null +++ b/src/lib/utils/timing.spec.ts @@ -0,0 +1,25 @@ +import { useFakeTimers } from '../../testUtils/jest'; +import { sleep } from './timing'; + +useFakeTimers(); + +describe('sleep', () => { + const TIMEOUT_MS = 60; + + test('Promise should not resolve before specified milliseconds have elapsed', async () => { + let promiseResolved = false; + + sleep(TIMEOUT_MS).then(() => (promiseResolved = true)); + + jest.advanceTimersByTime(TIMEOUT_MS - 1); + expect(promiseResolved).toBeFalse(); + jest.runAllTimers(); + }); + + test('Promise should resolve once specified milliseconds have elapsed', async () => { + const sleepSecondsPromise = sleep(TIMEOUT_MS); + + jest.advanceTimersByTime(TIMEOUT_MS); + await sleepSecondsPromise; + }); +}); diff --git a/src/lib/utils/timing.ts b/src/lib/utils/timing.ts new file mode 100644 index 00000000..361b7bc6 --- /dev/null +++ b/src/lib/utils/timing.ts @@ -0,0 +1,3 @@ +export async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/testUtils/asn1.ts b/src/testUtils/asn1.ts new file mode 100644 index 00000000..9032163f --- /dev/null +++ b/src/testUtils/asn1.ts @@ -0,0 +1,4 @@ +export function derPublicKeyToPem(derBuffer: Buffer): string { + const lines = derBuffer.toString('base64').match(/.{1,64}/g)!; + return [`-----BEGIN PUBLIC KEY-----`, ...lines, `-----END PUBLIC KEY-----`].join('\n'); +} diff --git a/src/testUtils/jest.ts b/src/testUtils/jest.ts new file mode 100644 index 00000000..67ae5d09 --- /dev/null +++ b/src/testUtils/jest.ts @@ -0,0 +1,37 @@ +// tslint:disable-next-line:readonly-array +export function mockSpy( + spy: jest.MockInstance, + mockImplementation?: () => any, +): jest.MockInstance { + beforeEach(() => { + spy.mockReset(); + if (mockImplementation) { + spy.mockImplementation(mockImplementation); + } + }); + + afterAll(() => { + spy.mockRestore(); + }); + + return spy; +} + +export function getMockInstance(mockedObject: any): jest.MockInstance { + return mockedObject as any; +} + +export function getMockContext(mockedObject: any): jest.MockContext { + const mockInstance = getMockInstance(mockedObject); + return mockInstance.mock; +} + +export function useFakeTimers(): void { + beforeEach(() => { + jest.useFakeTimers('modern'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); +} diff --git a/src/testUtils/promises.ts b/src/testUtils/promises.ts new file mode 100644 index 00000000..d41a5a77 --- /dev/null +++ b/src/testUtils/promises.ts @@ -0,0 +1,12 @@ +export async function catchPromiseRejection( + promise: Promise, + errorClass: new () => ErrorType, +): Promise { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(errorClass); + return error as ErrorType; + } + throw new Error('Expected promise to throw'); +} diff --git a/src/testUtils/timing.ts b/src/testUtils/timing.ts new file mode 100644 index 00000000..5643a34b --- /dev/null +++ b/src/testUtils/timing.ts @@ -0,0 +1,6 @@ +import * as timing from '../lib/utils/timing'; +import { mockSpy } from './jest'; + +export function mockSleep(): jest.SpyInstance { + return mockSpy(jest.spyOn(timing, 'sleep'), () => undefined); +} diff --git a/src/types/fast-crc32c.d.ts b/src/types/fast-crc32c.d.ts new file mode 100644 index 00000000..867edfac --- /dev/null +++ b/src/types/fast-crc32c.d.ts @@ -0,0 +1,3 @@ +declare module 'fast-crc32c' { + export function calculate(plaintext: string | Buffer): number; +}